From 502bb318f70676e6d478008af505298f276ecc36 Mon Sep 17 00:00:00 2001 From: Fabro Date: Sat, 23 May 2026 16:23:53 -0400 Subject: [PATCH] =?UTF-8?q?checkpoint=20=E2=9A=92=EF=B8=8F=20Generated=20w?= =?UTF-8?q?ith=20[Fabro](https://fabro.sh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run.json | 347 +++++- stages/005-implement@1/diff.patch | 992 ++++++++++++++++++ stages/005-implement@1/status.json | 6 + stages/006-simplify_opus@1/prompt.md | 957 +++++++++++++++++ stages/006-simplify_opus@1/provider_used.json | 5 + stages/006-simplify_opus@1/response.md | 20 + 6 files changed, 2306 insertions(+), 21 deletions(-) create mode 100644 stages/005-implement@1/diff.patch create mode 100644 stages/005-implement@1/status.json create mode 100644 stages/006-simplify_opus@1/prompt.md create mode 100644 stages/006-simplify_opus@1/provider_used.json create mode 100644 stages/006-simplify_opus@1/response.md diff --git a/run.json b/run.json index 531ac8d8e..4904dd3c9 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:13:15.136450Z", + "last_event_at": "2026-05-23T20:23:53.000860Z", "pending_control": null, "checkpoints": [ { @@ -740,9 +740,9 @@ } }, { - "seq": 0, + "seq": 507, "checkpoint": { - "timestamp": "2026-05-23T20:13:15.232971Z", + "timestamp": "2026-05-23T20:13:19.147537Z", "current_node": "implement", "completed_nodes": [ "start", @@ -753,33 +753,192 @@ ], "node_retries": {}, "context_values": { - "internal.run_id": "01KSB6HFNMJ802AXGBAV5JP7ZS", - "internal.work_dir": "/home/daytona/workspace/fabro", - "outcome": "succeeded", - "thread.start.current_node": "toolchain", - "internal.thread_id": "preflight_lint", - "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\"`", - "internal.retry_count.preflight_compile": 0, + "graph.rankdir": "LR", + "internal.retry_count.preflight_lint": 0, "current_node": "implement", + "internal.retry_count.start": 0, + "thread.preflight_lint.current_node": "implement", + "thread.toolchain.current_node": "preflight_compile", + "internal.work_dir": "/home/daytona/workspace/fabro", + "internal.retry_count.toolchain": 0, + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.retry_count.implement": 0, + "internal.retry_count.preflight_compile": 0, + "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.", + "failure_signature": "", + "internal.run_id": "01KSB6HFNMJ802AXGBAV5JP7ZS", + "failure_class": "", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "internal.thread_id": "preflight_lint", + "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\"`", + "last_stage": "implement", + "outcome": "succeeded", + "thread.preflight_compile.current_node": "preflight_lint", + "thread.start.current_node": "toolchain", + "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.fidelity": "compact", + "internal.node_visit_count": 1 + }, + "node_outcomes": { + "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 + }, + "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_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 + }, + "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_opus", + "git_commit_sha": "b8c87afb29aa1e477b14528e4e6dc49f5c8c1347", + "node_visits": { + "implement": 1, + "preflight_compile": 1, + "start": 1, + "preflight_lint": 1, + "toolchain": 1 + } + }, + "diff": { + "patch": "diff --git a/Cargo.lock b/Cargo.lock\nindex c50b6c665..bddde03c4 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -1859,6 +1859,7 @@ dependencies = [\n \"tempfile\",\n \"thiserror 2.0.18\",\n \"toml 0.8.23\",\n+ \"toml_edit\",\n \"tracing\",\n \"tracing-subscriber\",\n \"ulid\",\ndiff --git a/docs/public/changelog/2026-05-23.mdx b/docs/public/changelog/2026-05-23.mdx\nnew file mode 100644\nindex 000000000..a5c1843ad\n--- /dev/null\n+++ b/docs/public/changelog/2026-05-23.mdx\n@@ -0,0 +1,8 @@\n+---\n+title: \"Legacy sandbox config migration\"\n+date: \"2026-05-23\"\n+---\n+\n+## Legacy sandbox config auto-migration\n+\n+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.\ndiff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx\nindex c75686e24..b45c4eb74 100644\n--- a/docs/public/execution/environments.mdx\n+++ b/docs/public/execution/environments.mdx\n@@ -8,6 +8,12 @@ Fabro separates **environments** from **sandboxes**:\n - An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, volumes, and environment variables.\n - A **sandbox** is the concrete runtime instance Fabro creates for a run from the selected environment.\n \n+\n+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.\n+\n+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.\n+\n+\n Runs select environments by slug:\n \n ```toml title=\"workflow.toml\"\ndiff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml\nindex 7e8290f6a..feb448314 100644\n--- a/lib/crates/fabro-config/Cargo.toml\n+++ b/lib/crates/fabro-config/Cargo.toml\n@@ -34,6 +34,7 @@ serde_json.workspace = true\n strsim = \"0.11\"\n tempfile = \"3\"\n toml.workspace = true\n+toml_edit.workspace = true\n tracing.workspace = true\n tracing-subscriber.workspace = true\n thiserror.workspace = true\ndiff --git a/lib/crates/fabro-config/src/legacy_sandbox_migration.rs b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs\nnew file mode 100644\nindex 000000000..a8d428929\n--- /dev/null\n+++ b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs\n@@ -0,0 +1,799 @@\n+#![expect(\n+ clippy::disallowed_methods,\n+ reason = \"temporary startup config migration uses synchronous file I/O before config is loaded\"\n+)]\n+\n+use std::fmt;\n+use std::path::{Path, PathBuf};\n+\n+use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value};\n+\n+use crate::{Error, Result, SettingsLayer};\n+\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+pub(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)]\n+struct MigrationFailure {\n+ unsupported_keys: Vec,\n+}\n+\n+impl fmt::Display for MigrationFailure {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\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+\n+pub(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+\n+fn migrate_contents(original_contents: &str, path: &Path) -> Result> {\n+ let Ok(mut doc) = original_contents.parse::() else {\n+ 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+\n+fn 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+\n+fn 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+\n+fn 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, item) in &sandbox {\n+ if !matches!(key, \"provider\" | \"preserve\" | \"env\" | \"daytona\" | \"docker\") {\n+ item_path_keys(&format!(\"run.sandbox.{key}\"), item, &mut unsupported);\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+ 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+ }\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+ }\n+\n+ let env = path_table(doc, &[\"environments\", \"default\"]);\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+ } 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+ } 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+ _ => {}\n+ }\n+\n+ if !unsupported.is_empty() {\n+ return Err(MigrationFailure {\n+ unsupported_keys: unsupported,\n+ });\n+ }\n+\n+ remove_run_sandbox(doc);\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+ };\n+ let Some(daytona) = daytona_item.as_table() else {\n+ unsupported.push(\"run.sandbox.daytona\".to_string());\n+ return;\n+ };\n+\n+ for (key, item) in daytona {\n+ match key {\n+ \"skip_clone\" => {\n+ if item.as_bool() != Some(true) {\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+ } 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+ } else {\n+ unsupported.push(\"run.sandbox.daytona.labels\".to_string());\n+ }\n+ }\n+ \"snapshot\" => migrate_daytona_snapshot(item, env, unsupported),\n+ \"volumes\" => copy_array_of_tables_with_volume_id(item, env, unsupported),\n+ _ => item_path_keys(&format!(\"run.sandbox.daytona.{key}\"), item, unsupported),\n+ }\n+ }\n+}\n+\n+fn migrate_daytona_snapshot(snapshot_item: &Item, env: &mut Table, unsupported: &mut Vec) {\n+ let Some(snapshot) = snapshot_item.as_table() else {\n+ unsupported.push(\"run.sandbox.daytona.snapshot\".to_string());\n+ return;\n+ };\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+ _ => item_path_keys(\n+ &format!(\"run.sandbox.daytona.snapshot.{key}\"),\n+ item,\n+ unsupported,\n+ ),\n+ }\n+ }\n+}\n+\n+fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec) {\n+ let Some(docker_item) = sandbox.get(\"docker\") else {\n+ return;\n+ };\n+ let Some(docker) = docker_item.as_table() else {\n+ unsupported.push(\"run.sandbox.docker\".to_string());\n+ return;\n+ };\n+\n+ for (key, item) in docker {\n+ match key {\n+ \"skip_clone\" => {\n+ if item.as_bool() != Some(true) {\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+ \"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+ } else {\n+ unsupported.push(\"run.sandbox.docker.cpu_quota\".to_string());\n+ }\n+ } else {\n+ unsupported.push(\"run.sandbox.docker.cpu_quota\".to_string());\n+ }\n+ }\n+ _ => item_path_keys(&format!(\"run.sandbox.docker.{key}\"), item, unsupported),\n+ }\n+ }\n+}\n+\n+fn 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+\n+fn path_table_in_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+ if !item.is_table() {\n+ *item = Item::Table(Table::new());\n+ }\n+ table = item.as_table_mut().expect(\"path item should be a table\");\n+ }\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+ 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+}\n+\n+fn copy_array_of_tables_with_volume_id(\n+ source: &Item,\n+ target: &mut Table,\n+ unsupported: &mut Vec,\n+) {\n+ let Some(volumes) = source.as_array_of_tables() else {\n+ unsupported.push(\"run.sandbox.daytona.volumes\".to_string());\n+ return;\n+ };\n+\n+ let mut migrated = ArrayOfTables::new();\n+ for volume in volumes {\n+ let mut migrated_volume = Table::new();\n+ let mut has_id = false;\n+ let mut has_mount_path = false;\n+ for (key, item) in volume {\n+ match key {\n+ \"volume_id\" => {\n+ has_id = true;\n+ set_item(&mut 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+ }\n+ \"subpath\" => set_item(&mut migrated_volume, \"subpath\", item.clone()),\n+ _ => item_path_keys(\n+ &format!(\"run.sandbox.daytona.volumes.{key}\"),\n+ item,\n+ unsupported,\n+ ),\n+ }\n+ }\n+ if !has_id {\n+ unsupported.push(\"run.sandbox.daytona.volumes.volume_id\".to_string());\n+ }\n+ if !has_mount_path {\n+ unsupported.push(\"run.sandbox.daytona.volumes.mount_path\".to_string());\n+ }\n+ migrated.push(migrated_volume);\n+ }\n+ target[\"volumes\"] = Item::ArrayOfTables(migrated);\n+}\n+\n+fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec) {\n+ if let Some(table) = item.as_table() {\n+ if table.is_empty() {\n+ out.push(prefix.to_string());\n+ }\n+ for (key, child) in table {\n+ item_path_keys(&format!(\"{prefix}.{key}\"), child, out);\n+ }\n+ return;\n+ }\n+\n+ if let Some(array) = item.as_array_of_tables() {\n+ if array.is_empty() {\n+ out.push(prefix.to_string());\n+ }\n+ for table in array {\n+ if table.is_empty() {\n+ out.push(prefix.to_string());\n+ }\n+ for (key, child) in table {\n+ item_path_keys(&format!(\"{prefix}.{key}\"), child, out);\n+ }\n+ }\n+ return;\n+ }\n+\n+ out.push(prefix.to_string());\n+}\n+\n+fn 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+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+ 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+#[cfg(test)]\n+mod tests {\n+ use fabro_types::settings::InterpString;\n+ use fabro_types::settings::run::EnvironmentProvider;\n+\n+ use super::*;\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]\n+provider = \"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+ #[test]\n+ fn daytona_snapshot_labels_lifecycle_and_volumes_migrate() {\n+ let migrated = migrate(\n+ r#\"\n+_version = 1\n+\n+[run.sandbox]\n+provider = \"daytona\"\n+preserve = true\n+\n+[run.sandbox.env]\n+NODE_ENV = \"development\"\n+\n+[run.sandbox.daytona]\n+auto_stop_interval = 30\n+\n+[run.sandbox.daytona.labels]\n+repo = \"fabro-sh/fabro\"\n+\n+[run.sandbox.daytona.snapshot]\n+name = \"fabro-v11\"\n+cpu = 8\n+memory = \"16GB\"\n+disk = \"20GB\"\n+dockerfile = { path = \"Dockerfile\" }\n+\n+[[run.sandbox.daytona.volumes]]\n+volume_id = \"vol_auth\"\n+mount_path = \"/home/daytona/.config\"\n+subpath = \"agents\"\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+ .environment;\n+\n+ assert_eq!(resolved.image.reference.as_deref(), Some(\"fabro-v11\"));\n+ assert_eq!(resolved.resources.cpu, Some(8));\n+ assert_eq!(\n+ resolved.resources.memory.map(|size| size.as_bytes()),\n+ Some(16_000_000_000)\n+ );\n+ assert_eq!(\n+ resolved.resources.disk.map(|size| size.as_bytes()),\n+ Some(20_000_000_000)\n+ );\n+ assert!(resolved.lifecycle.preserve);\n+ assert_eq!(\n+ resolved\n+ .lifecycle\n+ .auto_stop\n+ .map(|duration| duration.as_std().as_secs()),\n+ Some(1800)\n+ );\n+ assert_eq!(\n+ resolved.labels.get(\"repo\").map(String::as_str),\n+ Some(\"fabro-sh/fabro\")\n+ );\n+ assert_eq!(\n+ resolved.env.get(\"NODE_ENV\").map(InterpString::as_source),\n+ Some(\"development\".to_string())\n+ );\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]\n+ fn docker_image_memory_and_cpu_quota_migrate() {\n+ let migrated = migrate(\n+ r#\"\n+_version = 1\n+\n+[run.sandbox]\n+provider = \"docker\"\n+\n+[run.sandbox.docker]\n+image = \"buildpack-deps:noble\"\n+memory_limit = \"4GB\"\n+cpu_quota = 200000\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+ .environment;\n+\n+ assert_eq!(resolved.provider, EnvironmentProvider::Docker);\n+ assert_eq!(\n+ resolved.image.reference.as_deref(),\n+ Some(\"buildpack-deps:noble\")\n+ );\n+ assert_eq!(resolved.resources.cpu, Some(2));\n+ assert_eq!(\n+ resolved.resources.memory.map(|size| size.as_bytes()),\n+ Some(4_000_000_000)\n+ );\n+ }\n+\n+ #[test]\n+ fn provider_skip_clone_true_migrates_to_run_clone_disabled() {\n+ let migrated = migrate(\n+ r#\"\n+_version = 1\n+\n+[run.sandbox]\n+provider = \"docker\"\n+\n+[run.sandbox.docker]\n+skip_clone = true\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+\n+ #[test]\n+ fn 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]\n+provider = \"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]\n+ fn 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(\n+ path.with_file_name(\"settings.toml.legacy-sandbox-migration.bak\"),\n+ \"old\",\n+ )\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+ #[test]\n+ fn existing_new_environment_config_is_ambiguous() {\n+ let err = migrate_contents(\n+ r#\"\n+_version = 1\n+\n+[run.environment]\n+id = \"default\"\n+\n+[run.sandbox]\n+provider = \"daytona\"\n+\"#,\n+ Path::new(\"settings.toml\"),\n+ )\n+ .expect_err(\"mixed old and new config should fail\");\n+\n+ assert!(\n+ err.to_string()\n+ .contains(\"already contains [run.environment]\")\n+ );\n+ }\n+\n+ #[test]\n+ fn unsupported_keys_are_reported_with_full_paths() {\n+ let err = migrate_contents(\n+ r#\"\n+_version = 1\n+\n+[run.sandbox]\n+provider = \"daytona\"\n+\n+[run.sandbox.daytona]\n+unknown = 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]\n+ fn unsupported_docker_cpu_quota_is_reported() {\n+ let err = migrate_contents(\n+ r#\"\n+_version = 1\n+\n+[run.sandbox]\n+provider = \"docker\"\n+\n+[run.sandbox.docker]\n+cpu_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+}\ndiff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs\nindex e2b6fb3b8..7ae698007 100644\n--- a/lib/crates/fabro-config/src/lib.rs\n+++ b/lib/crates/fabro-config/src/lib.rs\n@@ -16,6 +16,7 @@ pub mod envfile;\n pub mod error;\n pub mod home;\n pub mod input_overrides;\n+mod legacy_sandbox_migration;\n mod load;\n pub mod logging;\n pub mod parse;\ndiff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs\nindex 5a2254603..94eba41fc 100644\n--- a/lib/crates/fabro-config/src/load.rs\n+++ b/lib/crates/fabro-config/src/load.rs\n@@ -7,13 +7,33 @@ use std::path::{Path, PathBuf};\n \n use fabro_types::settings::InterpString;\n \n-use crate::{Error, Result, RunGoalLayer, SettingsLayer};\n+use crate::{Error, Result, RunGoalLayer, SettingsLayer, legacy_sandbox_migration};\n \n+#[expect(\n+ clippy::print_stderr,\n+ reason = \"startup config auto-migration warning must be visible before caller logging is configured\"\n+)]\n pub(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 = content\n- .parse::()\n- .map_err(|err| Error::parse_file(\"Failed to parse settings file\", path, err))?;\n+ let mut layer = match content.parse::() {\n+ Ok(layer) => layer,\n+ Err(err) => match 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 => {\n+ return Err(Error::parse_file(\n+ \"Failed to parse settings file\",\n+ path,\n+ err,\n+ ));\n+ }\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@@ -45,3 +65,38 @@ pub(crate) fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf\n base_dir.join(path)\n }\n }\n+\n+#[cfg(test)]\n+mod tests {\n+ use fabro_types::settings::run::EnvironmentProvider;\n+\n+ use super::*;\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]\n+provider = \"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!(\n+ std::fs::read_to_string(&path)\n+ .expect(\"read rewritten settings\")\n+ .contains(\"[run.environment]\")\n+ );\n+ }\n+}\ndiff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs\nindex 030a234b2..7554a3429 100644\n--- a/lib/crates/fabro-config/src/user.rs\n+++ b/lib/crates/fabro-config/src/user.rs\n@@ -72,6 +72,9 @@ fn load_v2_layer_from_path(path: &Path) -> Result {\n \n #[cfg(test)]\n mod tests {\n+ use fabro_static::EnvVars;\n+ use temp_env::with_var;\n+\n use super::{\n SETTINGS_CONFIG_FILENAME, active_settings_path_with_lookup, default_settings_path,\n default_socket_path, default_storage_dir,\n@@ -79,14 +82,16 @@ mod tests {\n \n #[test]\n fn settings_paths_use_expected_filenames() {\n- let home = dirs::home_dir().unwrap();\n-\n- assert_eq!(\n- default_settings_path(),\n- home.join(\".fabro\").join(SETTINGS_CONFIG_FILENAME)\n- );\n- assert_eq!(default_storage_dir(), home.join(\".fabro/storage\"));\n- assert_eq!(default_socket_path(), home.join(\".fabro/fabro.sock\"));\n+ with_var(EnvVars::FABRO_HOME, None::<&str>, || {\n+ let home = dirs::home_dir().unwrap();\n+\n+ assert_eq!(\n+ default_settings_path(),\n+ home.join(\".fabro\").join(SETTINGS_CONFIG_FILENAME)\n+ );\n+ assert_eq!(default_storage_dir(), home.join(\".fabro/storage\"));\n+ assert_eq!(default_socket_path(), home.join(\".fabro/fabro.sock\"));\n+ });\n }\n \n #[test]\n", + "summary": { + "files_changed": 8, + "additions": 888, + "deletions": 12 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-23T20:23:53.080105Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { + "outcome": "succeeded", + "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", + "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).", + "internal.retry_count.preflight_compile": 0, "internal.retry_count.preflight_lint": 0, "failure_class": "", - "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, + "thread.toolchain.current_node": "preflight_compile", "failure_signature": "", - "internal.retry_count.start": 0, - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "thread.implement.current_node": "simplify_opus", + "internal.retry_count.simplify_opus": 0, "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", "internal.fidelity": "compact", - "internal.retry_count.toolchain": 0, - "thread.toolchain.current_node": "preflight_compile", - "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.", "thread.preflight_compile.current_node": "preflight_lint", "internal.retry_count.implement": 0, "graph.rankdir": "LR", + "internal.run_id": "01KSB6HFNMJ802AXGBAV5JP7ZS", + "last_stage": "simplify_opus", + "current_node": "simplify_opus", + "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, + "internal.retry_count.start": 0, + "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.", + "internal.retry_count.toolchain": 0, "thread.preflight_lint.current_node": "implement" }, "node_outcomes": { + "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" + ] + }, "preflight_lint": { "status": "succeeded", "context_updates": { @@ -839,10 +998,11 @@ "usage": null } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { "preflight_lint": 1, "toolchain": 1, + "simplify_opus": 1, "preflight_compile": 1, "implement": 1, "start": 1 @@ -919,6 +1079,140 @@ }, "state": "succeeded" }, + "simplify_opus@1": { + "first_event_seq": 510, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-23T20:13:19.149546Z", + "handler": "agent", + "usage": { + "input_tokens": 86218, + "output_tokens": 29230, + "total_tokens": 3541326, + "reasoning_tokens": 0, + "cache_read_tokens": 3120558, + "cache_write_tokens": 305320, + "total_usd_micros": 4630369 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "todos": { + "kind": "anthropic_tasks", + "list_id": "anthropic_tasks:85b59e83-ec8f-427b-b898-0a85ea9eb03a", + "items": [ + { + "id": "1", + "status": "completed", + "order": 0, + "subject": "Fix: skip_clone = false wrongly rejected as unsupported", + "description": "When legacy [run.sandbox.daytona] or [run.sandbox.docker] explicitly sets skip_clone = false (the default), migration currently rejects it. Treat Some(false) as no-op; only reject non-bool.", + "active_form": "Fixing skip_clone=false rejection bug" + }, + { + "id": "2", + "status": "completed", + "order": 1, + "subject": "Replace is_table_like/copy_table with toml_edit built-ins", + "description": "Use Item::is_table_like() and Item::as_table_like() + TableLike iter to remove the custom helper and collapse copy_table.", + "active_form": "Using toml_edit TableLike abstractions" + }, + { + "id": "3", + "status": "completed", + "order": 2, + "subject": "Collapse path_table into path_table_in_table", + "description": "Use DocumentMut::as_table_mut() to delete duplicate path_table function.", + "active_form": "Consolidating path_table helpers" + }, + { + "id": "4", + "status": "completed", + "order": 3, + "subject": "Replace stringly-typed \"daytona\"/\"docker\" with EnvironmentProvider enum", + "description": "Use EnvironmentProvider::from_str + IntoStaticStr to handle provider keys.", + "active_form": "Replacing provider strings with enum" + }, + { + "id": "5", + "status": "completed", + "order": 4, + "subject": "Drop trivial set_value/set_item wrappers", + "description": "Inline table[key] = value assignments; remove one-line wrappers.", + "active_form": "Inlining set_value/set_item wrappers" + }, + { + "id": "6", + "status": "completed", + "order": 5, + "subject": "Avoid double SettingsLayer parse on migration path", + "description": "Return the parsed SettingsLayer from migrate_settings_path instead of re-parsing in load_settings_path.", + "active_form": "Removing double SettingsLayer parse" + }, + { + "id": "7", + "status": "completed", + "order": 6, + "subject": "Fix double-report of empty arrays in item_path_keys", + "description": "When array_of_tables is non-empty but each contained table is empty, prefix is reported multiple times. Pick one level.", + "active_form": "Fixing item_path_keys double-report" + }, + { + "id": "8", + "status": "completed", + "order": 7, + "subject": "Trim dead unwrap_or fallback in next_backup_path", + "description": "Remove dead \"settings.toml\" fallback; bind file_name once before loop.", + "active_form": "Cleaning up next_backup_path" + } + ] + }, + "subagents": [ + { + "agent_id": "736f97fd", + "depth": 1, + "task": "You are doing a code reuse review on a Rust change. The diff is at /tmp/changes.diff. The new code is at lib/crates/fabro-config/src/legacy_sandbox_migration.rs and the loader changes are at lib/crates/fabro-config/src/load.rs.\n\nYour job: For each new helper, function, or pattern introduced, search the codebase (especially lib/crates/fabro-config/, lib/crates/fabro-util/, and lib/crates/fabro-types/) for existing utilities that could replace it. Use Grep extensively.\n\nSpecifically check:\n1. Is there an existing toml_edit helper for \"ensure path exists as a table\" / `path_table`?\n2. Is there an existing helper for finding the next non-existent backup filename?\n3. Is there an existing helper for \"copy table contents\"?\n4. Is there an existing error type/constructor better than `Error::other` for migration errors?\n5. Is the redundant `path_table` (operating on DocumentMut) vs `path_table_in_table` (operating on Table) duplication necessary, or could one wrap the other?\n6. Is there a shared error display pattern (anyhow/thiserror context) that fits better than the custom MigrationFailure Display impl with manual ordered listing?\n7. Is `with_file_name(format!(\"{}.suffix\", file_name))` available as a helper anywhere?\n8. The `is_table_like` and `copy_table` patterns for both Table and InlineTable — does toml_edit have a unified abstraction?\n\nRead the diff carefully. Return concrete findings as: location → suggested replacement → rationale. Skip false positives. Be terse.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 58 + } + }, + { + "agent_id": "3acd2b78", + "depth": 1, + "task": "You are doing a code quality review on a Rust change. The diff is at /tmp/changes.diff. The new code is at lib/crates/fabro-config/src/legacy_sandbox_migration.rs and the loader changes are at lib/crates/fabro-config/src/load.rs.\n\nLook for:\n1. Redundant state or repeated calls (e.g. is `path_table(doc, &[\"environments\", \"default\"])` called twice? could the result be cached in a variable?)\n2. Parameter sprawl: are `unsupported: &mut Vec` threaded through many functions in a way that suggests a builder/context object?\n3. Copy-paste with slight variation: migrate_daytona vs migrate_docker — what's actually shared and what's different? Could a small dispatch table or a `ProviderMigration` trait/struct unify them? Also `migrate_skip_clone` is called twice with the rejection of the *other* provider — is this dispatch awkward?\n4. Leaky abstractions: does the migration module reach into too many internals? Does `next_backup_path` belong in a generic path util?\n5. Stringly-typed: provider keys \"daytona\" / \"docker\" appear as raw strings in many places. Is there a `Provider` enum/strum in fabro-types that could be used?\n6. The `item_path_keys` recursion is reasonable but is the \"if table is empty, push prefix\" branch dead in practice (called only on non-empty tables)?\n7. The unsupported-vs-skip_clone logic: when skip_clone is `false`, it's silently dropped (the rules above say `skip_clone = true` maps to clone.enabled=false). Verify nothing in legacy permitted explicit `skip_clone = false`.\n8. The dance with `set_value` vs `set_item` — is one wrapper unnecessary?\n9. Is there test fixture duplication that could use a builder?\n10. The `user.rs` change uses temp_env::with_var — is that necessary or was it just to fix flakiness from FABRO_HOME being set during tests? Note this looks unrelated to the main feature.\n\nRead the diff. Return concrete findings: file:line → issue → suggested fix. Be terse and prioritize the top 5 most impactful issues.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 12 + } + }, + { + "agent_id": "23de00dc", + "depth": 1, + "task": "You are doing an efficiency review on a Rust change. The diff is at /tmp/changes.diff. The new code is at lib/crates/fabro-config/src/legacy_sandbox_migration.rs and the loader changes are at lib/crates/fabro-config/src/load.rs.\n\nLook for:\n1. Unnecessary work: e.g. `sandbox_item.as_table().cloned()` — the whole sandbox table is cloned at the start. Is this needed, or could we operate on the reference?\n2. Repeated parsing: the migrated content is parsed twice (once in migrate_settings_path verification, once in load_settings_path after migration). Can we avoid double-parsing?\n3. Repeated file operations: `next_backup_path` calls `.exists()` in a loop — TOCTOU but also wasteful. Is the loop bound (`for index in 1..`) reasonable?\n4. Hot path: `load_settings_path` is called at startup. Failure case (no legacy sandbox) requires parsing TOML twice — once as SettingsLayer (fails), once attempted as DocumentMut. Is this acceptable?\n5. The unsupported-key recursion `item_path_keys` walks entire subtrees of unknown items — fine but verify it's bounded.\n6. The `next_backup_path` has `with_file_name(format!(...))` allocating per iteration — minor.\n7. Verify `set_value`/`set_item` don't break TOML formatting in a way that forces full re-serialization (toml_edit normally preserves formatting).\n8. Cloning of items: `item.clone()` is called often when copying — necessary for toml_edit?\n\nRead the diff. Return concrete findings: file:line → issue → suggested fix. Skip cosmetic perf issues; focus on anything that could cause user-visible slowness or wasted work. Be terse.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 4 + } + } + ], + "state": "running" + }, "toolchain@1": { "first_event_seq": 22, "prompt": null, @@ -1005,7 +1299,12 @@ "first_event_seq": 87, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-23T20:13:15.232065Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -1019,6 +1318,12 @@ "output": null, "started_at": "2026-05-23T20:00:58.522143Z", "handler": "agent", + "timing": { + "wall_time_ms": 736701, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 148246, "output_tokens": 18262, @@ -1092,7 +1397,7 @@ } } ], - "state": "running" + "state": "succeeded" }, "preflight_compile@1": { "first_event_seq": 32, diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..95621272f --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,992 @@ +diff --git a/Cargo.lock b/Cargo.lock +index c50b6c665..bddde03c4 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -1859,6 +1859,7 @@ dependencies = [ + "tempfile", + "thiserror 2.0.18", + "toml 0.8.23", ++ "toml_edit", + "tracing", + "tracing-subscriber", + "ulid", +diff --git a/docs/public/changelog/2026-05-23.mdx b/docs/public/changelog/2026-05-23.mdx +new file mode 100644 +index 000000000..a5c1843ad +--- /dev/null ++++ b/docs/public/changelog/2026-05-23.mdx +@@ -0,0 +1,8 @@ ++--- ++title: "Legacy sandbox config migration" ++date: "2026-05-23" ++--- ++ ++## Legacy sandbox config auto-migration ++ ++Fabro now temporarily rewrites confidently migratable pre-v1.0 `[run.sandbox]` config files to the named environment syntax. A backup is written next to the original file before rewriting. Ambiguous or unsupported legacy keys fail with a targeted migration message instead of the generic TOML unknown-field error. +diff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx +index c75686e24..b45c4eb74 100644 +--- a/docs/public/execution/environments.mdx ++++ b/docs/public/execution/environments.mdx +@@ -8,6 +8,12 @@ Fabro separates **environments** from **sandboxes**: + - An **environment** is reusable desired configuration: provider, image, resources, network, lifecycle, labels, volumes, and environment variables. + - A **sandbox** is the concrete runtime instance Fabro creates for a run from the selected environment. + ++ ++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. ++ ++ + Runs select environments by slug: + + ```toml title="workflow.toml" +diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml +index 7e8290f6a..feb448314 100644 +--- a/lib/crates/fabro-config/Cargo.toml ++++ b/lib/crates/fabro-config/Cargo.toml +@@ -34,6 +34,7 @@ serde_json.workspace = true + strsim = "0.11" + tempfile = "3" + toml.workspace = true ++toml_edit.workspace = true + tracing.workspace = true + tracing-subscriber.workspace = true + thiserror.workspace = true +diff --git a/lib/crates/fabro-config/src/legacy_sandbox_migration.rs b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs +new file mode 100644 +index 000000000..a8d428929 +--- /dev/null ++++ b/lib/crates/fabro-config/src/legacy_sandbox_migration.rs +@@ -0,0 +1,799 @@ ++#![expect( ++ clippy::disallowed_methods, ++ reason = "temporary startup config migration uses synchronous file I/O before config is loaded" ++)] ++ ++use std::fmt; ++use std::path::{Path, PathBuf}; ++ ++use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value}; ++ ++use crate::{Error, Result, SettingsLayer}; ++ ++pub(crate) const REMOVAL_NOTE: &str = ++ "This temporary compatibility migration will be removed before v1.0."; ++ ++#[derive(Debug, Clone, PartialEq, Eq)] ++pub(crate) struct LegacySandboxMigrationReport { ++ pub(crate) contents: String, ++ pub(crate) backup_path: PathBuf, ++ pub(crate) warning: String, ++} ++ ++#[derive(Debug, Clone, PartialEq, Eq)] ++struct MigrationFailure { ++ unsupported_keys: Vec, ++} ++ ++impl fmt::Display for MigrationFailure { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ writeln!(f, "Unsupported keys:")?; ++ for key in &self.unsupported_keys { ++ writeln!(f, " - {key}")?; ++ } ++ writeln!(f)?; ++ write!( ++ f, ++ "Rename legacy sandbox configuration to [run.environment] and [environments.]. 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 Ok(mut doc) = original_contents.parse::() else { ++ return Ok(None); ++ }; ++ ++ if !has_legacy_run_sandbox(&doc) { ++ return Ok(None); ++ } ++ if has_new_environment_config(&doc) { ++ return Err(Error::other(format!( ++ "Legacy [run.sandbox] settings in {} could not be auto-migrated because the file already contains [run.environment] or [environments.default]. Remove one config style and retry.", ++ path.display() ++ ))); ++ } ++ ++ migrate_document(&mut doc).map_err(|failure| { ++ Error::other(format!( ++ "Legacy [run.sandbox] settings in {} could not be auto-migrated.\n\n{}", ++ path.display(), ++ failure ++ )) ++ })?; ++ ++ Ok(Some(doc.to_string())) ++} ++ ++fn has_legacy_run_sandbox(doc: &DocumentMut) -> bool { ++ doc.get("run") ++ .and_then(Item::as_table) ++ .and_then(|run| run.get("sandbox")) ++ .is_some() ++} ++ ++fn has_new_environment_config(doc: &DocumentMut) -> bool { ++ let has_run_environment = doc ++ .get("run") ++ .and_then(Item::as_table) ++ .and_then(|run| run.get("environment")) ++ .is_some(); ++ let has_default_environment = doc ++ .get("environments") ++ .and_then(Item::as_table) ++ .and_then(|envs| envs.get("default")) ++ .is_some(); ++ has_run_environment || has_default_environment ++} ++ ++fn migrate_document(doc: &mut DocumentMut) -> std::result::Result<(), MigrationFailure> { ++ let Some(sandbox_item) = doc ++ .get("run") ++ .and_then(Item::as_table) ++ .and_then(|run| run.get("sandbox")) ++ else { ++ return Ok(()); ++ }; ++ let Some(sandbox) = sandbox_item.as_table().cloned() else { ++ return Err(MigrationFailure { ++ unsupported_keys: vec!["run.sandbox".to_string()], ++ }); ++ }; ++ ++ let mut unsupported = Vec::new(); ++ for (key, item) in &sandbox { ++ if !matches!(key, "provider" | "preserve" | "env" | "daytona" | "docker") { ++ item_path_keys(&format!("run.sandbox.{key}"), item, &mut unsupported); ++ } ++ } ++ ++ let provider = sandbox.get("provider").and_then(Item::as_str); ++ let provider_key = provider.unwrap_or_default().to_ascii_lowercase(); ++ if provider.is_none() { ++ unsupported.push("run.sandbox.provider".to_string()); ++ } ++ ++ match provider_key.as_str() { ++ "daytona" => { ++ migrate_skip_clone(doc, &sandbox, "daytona"); ++ reject_provider_table(&sandbox, "docker", &mut unsupported); ++ } ++ "docker" => { ++ migrate_skip_clone(doc, &sandbox, "docker"); ++ reject_provider_table(&sandbox, "daytona", &mut unsupported); ++ } ++ _ => { ++ reject_provider_table(&sandbox, "daytona", &mut unsupported); ++ reject_provider_table(&sandbox, "docker", &mut unsupported); ++ } ++ } ++ ++ set_value( ++ path_table(doc, &["run", "environment"]), ++ "id", ++ Value::from("default"), ++ ); ++ if let Some(provider) = provider { ++ set_value( ++ path_table(doc, &["environments", "default"]), ++ "provider", ++ Value::from(provider), ++ ); ++ } ++ ++ let env = path_table(doc, &["environments", "default"]); ++ if let Some(preserve) = sandbox.get("preserve") { ++ if preserve.as_bool().is_some() { ++ set_item( ++ path_table_in_table(env, &["lifecycle"]), ++ "preserve", ++ preserve.clone(), ++ ); ++ } else { ++ unsupported.push("run.sandbox.preserve".to_string()); ++ } ++ } ++ if let Some(env_item) = sandbox.get("env") { ++ if is_table_like(env_item) { ++ copy_table(env_item, path_table_in_table(env, &["env"])); ++ } else { ++ unsupported.push("run.sandbox.env".to_string()); ++ } ++ } ++ ++ match provider_key.as_str() { ++ "daytona" => migrate_daytona(&sandbox, env, &mut unsupported), ++ "docker" => migrate_docker(&sandbox, env, &mut unsupported), ++ _ => {} ++ } ++ ++ if !unsupported.is_empty() { ++ return Err(MigrationFailure { ++ unsupported_keys: unsupported, ++ }); ++ } ++ ++ remove_run_sandbox(doc); ++ Ok(()) ++} ++ ++fn migrate_skip_clone(doc: &mut DocumentMut, sandbox: &Table, provider: &str) { ++ let Some(provider_table) = sandbox.get(provider).and_then(Item::as_table) else { ++ return; ++ }; ++ if provider_table ++ .get("skip_clone") ++ .and_then(Item::as_bool) ++ .unwrap_or(false) ++ { ++ set_value( ++ path_table(doc, &["run", "clone"]), ++ "enabled", ++ Value::from(false), ++ ); ++ } ++} ++ ++fn reject_provider_table(sandbox: &Table, provider: &str, unsupported: &mut Vec) { ++ 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; ++ }; ++ let Some(daytona) = daytona_item.as_table() else { ++ unsupported.push("run.sandbox.daytona".to_string()); ++ return; ++ }; ++ ++ for (key, item) in daytona { ++ match key { ++ "skip_clone" => { ++ if item.as_bool() != Some(true) { ++ unsupported.push("run.sandbox.daytona.skip_clone".to_string()); ++ } ++ } ++ "auto_stop_interval" => { ++ if let Some(minutes) = item.as_integer().filter(|minutes| *minutes >= 0) { ++ set_value( ++ path_table_in_table(env, &["lifecycle"]), ++ "auto_stop", ++ Value::from(format!("{minutes}m")), ++ ); ++ } else { ++ unsupported.push("run.sandbox.daytona.auto_stop_interval".to_string()); ++ } ++ } ++ "labels" => { ++ if is_table_like(item) { ++ copy_table(item, path_table_in_table(env, &["labels"])); ++ } else { ++ unsupported.push("run.sandbox.daytona.labels".to_string()); ++ } ++ } ++ "snapshot" => migrate_daytona_snapshot(item, env, unsupported), ++ "volumes" => copy_array_of_tables_with_volume_id(item, env, unsupported), ++ _ => item_path_keys(&format!("run.sandbox.daytona.{key}"), item, unsupported), ++ } ++ } ++} ++ ++fn migrate_daytona_snapshot(snapshot_item: &Item, env: &mut Table, unsupported: &mut Vec) { ++ let Some(snapshot) = snapshot_item.as_table() else { ++ unsupported.push("run.sandbox.daytona.snapshot".to_string()); ++ return; ++ }; ++ ++ for (key, item) in snapshot { ++ match key { ++ "name" => set_item(path_table_in_table(env, &["image"]), "ref", item.clone()), ++ "cpu" => set_item( ++ path_table_in_table(env, &["resources"]), ++ "cpu", ++ item.clone(), ++ ), ++ "memory" => set_item( ++ path_table_in_table(env, &["resources"]), ++ "memory", ++ item.clone(), ++ ), ++ "disk" => set_item( ++ path_table_in_table(env, &["resources"]), ++ "disk", ++ item.clone(), ++ ), ++ "dockerfile" => set_item( ++ path_table_in_table(env, &["image"]), ++ "dockerfile", ++ item.clone(), ++ ), ++ _ => item_path_keys( ++ &format!("run.sandbox.daytona.snapshot.{key}"), ++ item, ++ unsupported, ++ ), ++ } ++ } ++} ++ ++fn migrate_docker(sandbox: &Table, env: &mut Table, unsupported: &mut Vec) { ++ let Some(docker_item) = sandbox.get("docker") else { ++ return; ++ }; ++ let Some(docker) = docker_item.as_table() else { ++ unsupported.push("run.sandbox.docker".to_string()); ++ return; ++ }; ++ ++ for (key, item) in docker { ++ match key { ++ "skip_clone" => { ++ if item.as_bool() != Some(true) { ++ unsupported.push("run.sandbox.docker.skip_clone".to_string()); ++ } ++ } ++ "image" => set_item(path_table_in_table(env, &["image"]), "ref", item.clone()), ++ "memory_limit" => set_item( ++ path_table_in_table(env, &["resources"]), ++ "memory", ++ item.clone(), ++ ), ++ "cpu_quota" => { ++ if let Some(cpu_quota) = item.as_integer() { ++ let cpu_count = cpu_quota / 100_000; ++ if cpu_quota > 0 && cpu_quota % 100_000 == 0 && i32::try_from(cpu_count).is_ok() ++ { ++ set_value( ++ path_table_in_table(env, &["resources"]), ++ "cpu", ++ Value::from(cpu_count), ++ ); ++ } else { ++ unsupported.push("run.sandbox.docker.cpu_quota".to_string()); ++ } ++ } else { ++ unsupported.push("run.sandbox.docker.cpu_quota".to_string()); ++ } ++ } ++ _ => item_path_keys(&format!("run.sandbox.docker.{key}"), item, unsupported), ++ } ++ } ++} ++ ++fn path_table<'a>(doc: &'a mut DocumentMut, path: &[&str]) -> &'a mut Table { ++ let mut item = doc.as_item_mut(); ++ for segment in path { ++ item = &mut item[segment]; ++ if !item.is_table() { ++ *item = Item::Table(Table::new()); ++ } ++ } ++ item.as_table_mut().expect("path item should be a table") ++} ++ ++fn path_table_in_table<'a>(table: &'a mut Table, path: &[&str]) -> &'a mut Table { ++ let mut table = table; ++ for segment in path { ++ let item = &mut table[segment]; ++ if !item.is_table() { ++ *item = Item::Table(Table::new()); ++ } ++ table = item.as_table_mut().expect("path item should be a table"); ++ } ++ table ++} ++ ++fn set_value(table: &mut Table, key: &str, value: Value) { ++ table[key] = Item::Value(value); ++} ++ ++fn set_item(table: &mut Table, key: &str, item: Item) { ++ table[key] = item; ++} ++ ++fn is_table_like(item: &Item) -> bool { ++ item.is_table() || item.as_value().and_then(Value::as_inline_table).is_some() ++} ++ ++fn copy_table(source: &Item, target: &mut Table) { ++ if let Some(table) = source.as_table() { ++ for (key, item) in table { ++ target[key] = item.clone(); ++ } ++ return; ++ } ++ ++ if let Some(inline_table) = source.as_value().and_then(Value::as_inline_table) { ++ for (key, value) in inline_table { ++ target[key] = Item::Value(value.clone()); ++ } ++ } ++} ++ ++fn copy_array_of_tables_with_volume_id( ++ source: &Item, ++ target: &mut Table, ++ unsupported: &mut Vec, ++) { ++ let Some(volumes) = source.as_array_of_tables() else { ++ unsupported.push("run.sandbox.daytona.volumes".to_string()); ++ return; ++ }; ++ ++ let mut migrated = ArrayOfTables::new(); ++ for volume in volumes { ++ let mut migrated_volume = Table::new(); ++ let mut has_id = false; ++ let mut has_mount_path = false; ++ for (key, item) in volume { ++ match key { ++ "volume_id" => { ++ has_id = true; ++ set_item(&mut migrated_volume, "id", item.clone()); ++ } ++ "mount_path" => { ++ has_mount_path = true; ++ set_item(&mut migrated_volume, "mount_path", item.clone()); ++ } ++ "subpath" => set_item(&mut migrated_volume, "subpath", item.clone()), ++ _ => item_path_keys( ++ &format!("run.sandbox.daytona.volumes.{key}"), ++ item, ++ unsupported, ++ ), ++ } ++ } ++ if !has_id { ++ unsupported.push("run.sandbox.daytona.volumes.volume_id".to_string()); ++ } ++ if !has_mount_path { ++ unsupported.push("run.sandbox.daytona.volumes.mount_path".to_string()); ++ } ++ migrated.push(migrated_volume); ++ } ++ target["volumes"] = Item::ArrayOfTables(migrated); ++} ++ ++fn item_path_keys(prefix: &str, item: &Item, out: &mut Vec) { ++ if let Some(table) = item.as_table() { ++ if table.is_empty() { ++ out.push(prefix.to_string()); ++ } ++ for (key, child) in table { ++ item_path_keys(&format!("{prefix}.{key}"), child, out); ++ } ++ return; ++ } ++ ++ if let Some(array) = item.as_array_of_tables() { ++ if array.is_empty() { ++ out.push(prefix.to_string()); ++ } ++ for table in array { ++ if table.is_empty() { ++ out.push(prefix.to_string()); ++ } ++ for (key, child) in table { ++ item_path_keys(&format!("{prefix}.{key}"), child, out); ++ } ++ } ++ return; ++ } ++ ++ out.push(prefix.to_string()); ++} ++ ++fn remove_run_sandbox(doc: &mut DocumentMut) { ++ if let Some(run) = doc.get_mut("run").and_then(Item::as_table_mut) { ++ run.remove("sandbox"); ++ } ++} ++ ++fn next_backup_path(path: &Path) -> PathBuf { ++ let base = path.with_file_name(format!( ++ "{}.legacy-sandbox-migration.bak", ++ path.file_name() ++ .and_then(|name| name.to_str()) ++ .unwrap_or("settings.toml") ++ )); ++ if !base.exists() { ++ return base; ++ } ++ ++ for index in 1.. { ++ let candidate = path.with_file_name(format!( ++ "{}.legacy-sandbox-migration.{index}.bak", ++ path.file_name() ++ .and_then(|name| name.to_str()) ++ .unwrap_or("settings.toml") ++ )); ++ if !candidate.exists() { ++ return candidate; ++ } ++ } ++ unreachable!("unbounded backup suffix search should return") ++} ++ ++#[cfg(test)] ++mod tests { ++ use fabro_types::settings::InterpString; ++ use fabro_types::settings::run::EnvironmentProvider; ++ ++ use super::*; ++ ++ fn migrate(source: &str) -> String { ++ migrate_contents(source, Path::new("settings.toml")) ++ .expect("migration should not error") ++ .expect("legacy sandbox should migrate") ++ } ++ ++ #[test] ++ fn provider_only_daytona_config_migrates_to_default_environment() { ++ let migrated = migrate( ++ r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "daytona" ++"#, ++ ); ++ ++ let settings = migrated ++ .parse::() ++ .expect("migrated TOML should parse"); ++ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings) ++ .expect("migrated settings should resolve") ++ .run; ++ ++ assert_eq!(resolved.environment.id, "default"); ++ assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona); ++ assert!(migrated.contains("[run.environment]")); ++ assert!(migrated.contains("[environments.default]")); ++ assert!(!migrated.contains("[run.sandbox]")); ++ } ++ ++ #[test] ++ fn non_legacy_config_is_not_migrated() { ++ let migrated = migrate_contents("_version = 1\n", Path::new("settings.toml")) ++ .expect("non-legacy TOML should not error"); ++ ++ assert!(migrated.is_none()); ++ } ++ ++ #[test] ++ fn daytona_snapshot_labels_lifecycle_and_volumes_migrate() { ++ let migrated = migrate( ++ r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "daytona" ++preserve = true ++ ++[run.sandbox.env] ++NODE_ENV = "development" ++ ++[run.sandbox.daytona] ++auto_stop_interval = 30 ++ ++[run.sandbox.daytona.labels] ++repo = "fabro-sh/fabro" ++ ++[run.sandbox.daytona.snapshot] ++name = "fabro-v11" ++cpu = 8 ++memory = "16GB" ++disk = "20GB" ++dockerfile = { path = "Dockerfile" } ++ ++[[run.sandbox.daytona.volumes]] ++volume_id = "vol_auth" ++mount_path = "/home/daytona/.config" ++subpath = "agents" ++"#, ++ ); ++ ++ let settings = migrated ++ .parse::() ++ .expect("migrated TOML should parse"); ++ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings) ++ .expect("migrated settings should resolve") ++ .run ++ .environment; ++ ++ assert_eq!(resolved.image.reference.as_deref(), Some("fabro-v11")); ++ assert_eq!(resolved.resources.cpu, Some(8)); ++ assert_eq!( ++ resolved.resources.memory.map(|size| size.as_bytes()), ++ Some(16_000_000_000) ++ ); ++ assert_eq!( ++ resolved.resources.disk.map(|size| size.as_bytes()), ++ Some(20_000_000_000) ++ ); ++ assert!(resolved.lifecycle.preserve); ++ assert_eq!( ++ resolved ++ .lifecycle ++ .auto_stop ++ .map(|duration| duration.as_std().as_secs()), ++ Some(1800) ++ ); ++ assert_eq!( ++ resolved.labels.get("repo").map(String::as_str), ++ Some("fabro-sh/fabro") ++ ); ++ assert_eq!( ++ resolved.env.get("NODE_ENV").map(InterpString::as_source), ++ Some("development".to_string()) ++ ); ++ assert_eq!(resolved.volumes.len(), 1); ++ assert_eq!(resolved.volumes[0].id, "vol_auth"); ++ assert_eq!(resolved.volumes[0].mount_path, "/home/daytona/.config"); ++ assert_eq!(resolved.volumes[0].subpath.as_deref(), Some("agents")); ++ } ++ ++ #[test] ++ fn docker_image_memory_and_cpu_quota_migrate() { ++ let migrated = migrate( ++ r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "docker" ++ ++[run.sandbox.docker] ++image = "buildpack-deps:noble" ++memory_limit = "4GB" ++cpu_quota = 200000 ++"#, ++ ); ++ ++ let settings = migrated ++ .parse::() ++ .expect("migrated TOML should parse"); ++ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings) ++ .expect("migrated settings should resolve") ++ .run ++ .environment; ++ ++ assert_eq!(resolved.provider, EnvironmentProvider::Docker); ++ assert_eq!( ++ resolved.image.reference.as_deref(), ++ Some("buildpack-deps:noble") ++ ); ++ assert_eq!(resolved.resources.cpu, Some(2)); ++ assert_eq!( ++ resolved.resources.memory.map(|size| size.as_bytes()), ++ Some(4_000_000_000) ++ ); ++ } ++ ++ #[test] ++ fn provider_skip_clone_true_migrates_to_run_clone_disabled() { ++ let migrated = migrate( ++ r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "docker" ++ ++[run.sandbox.docker] ++skip_clone = true ++"#, ++ ); ++ ++ let settings = migrated ++ .parse::() ++ .expect("migrated TOML should parse"); ++ let resolved = crate::WorkflowSettingsBuilder::from_layer(&settings) ++ .expect("migrated settings should resolve") ++ .run; ++ ++ assert!(!resolved.clone.enabled); ++ } ++ ++ #[test] ++ fn migrate_settings_path_writes_backup_and_rewrites_original() { ++ let dir = tempfile::tempdir().expect("temp dir"); ++ let path = dir.path().join("settings.toml"); ++ let original = r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "daytona" ++"#; ++ std::fs::write(&path, original).expect("write fixture"); ++ ++ let report = migrate_settings_path(&path, original) ++ .expect("migration should succeed") ++ .expect("legacy config should migrate"); ++ ++ let rewritten = std::fs::read_to_string(&path).expect("read rewritten settings"); ++ let backup = std::fs::read_to_string(&report.backup_path).expect("read backup"); ++ ++ assert_eq!(backup, original); ++ assert!(rewritten.contains("[run.environment]")); ++ assert!(rewritten.contains("[environments.default]")); ++ assert!(report.warning.contains("temporary compatibility migration")); ++ } ++ ++ #[test] ++ fn existing_backup_uses_numbered_suffix() { ++ let dir = tempfile::tempdir().expect("temp dir"); ++ let path = dir.path().join("settings.toml"); ++ std::fs::write( ++ path.with_file_name("settings.toml.legacy-sandbox-migration.bak"), ++ "old", ++ ) ++ .expect("write existing backup"); ++ ++ let next = next_backup_path(&path); ++ ++ assert!(next.ends_with("settings.toml.legacy-sandbox-migration.1.bak")); ++ } ++ ++ #[test] ++ fn existing_new_environment_config_is_ambiguous() { ++ let err = migrate_contents( ++ r#" ++_version = 1 ++ ++[run.environment] ++id = "default" ++ ++[run.sandbox] ++provider = "daytona" ++"#, ++ Path::new("settings.toml"), ++ ) ++ .expect_err("mixed old and new config should fail"); ++ ++ assert!( ++ err.to_string() ++ .contains("already contains [run.environment]") ++ ); ++ } ++ ++ #[test] ++ fn unsupported_keys_are_reported_with_full_paths() { ++ let err = migrate_contents( ++ r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "daytona" ++ ++[run.sandbox.daytona] ++unknown = true ++"#, ++ Path::new("settings.toml"), ++ ) ++ .expect_err("unsupported keys should fail migration"); ++ ++ let rendered = err.to_string(); ++ assert!(rendered.contains("run.sandbox.daytona.unknown")); ++ assert!(rendered.contains("docs/public/execution/environments.mdx")); ++ } ++ ++ #[test] ++ fn unsupported_docker_cpu_quota_is_reported() { ++ let err = migrate_contents( ++ r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "docker" ++ ++[run.sandbox.docker] ++cpu_quota = 250000 ++"#, ++ Path::new("settings.toml"), ++ ) ++ .expect_err("non-divisible cpu quota should fail migration"); ++ ++ assert!(err.to_string().contains("run.sandbox.docker.cpu_quota")); ++ } ++} +diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs +index e2b6fb3b8..7ae698007 100644 +--- a/lib/crates/fabro-config/src/lib.rs ++++ b/lib/crates/fabro-config/src/lib.rs +@@ -16,6 +16,7 @@ pub mod envfile; + pub mod error; + pub mod home; + pub mod input_overrides; ++mod legacy_sandbox_migration; + mod load; + pub mod logging; + pub mod parse; +diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs +index 5a2254603..94eba41fc 100644 +--- a/lib/crates/fabro-config/src/load.rs ++++ b/lib/crates/fabro-config/src/load.rs +@@ -7,13 +7,33 @@ use std::path::{Path, PathBuf}; + + use fabro_types::settings::InterpString; + +-use crate::{Error, Result, RunGoalLayer, SettingsLayer}; ++use crate::{Error, Result, RunGoalLayer, SettingsLayer, legacy_sandbox_migration}; + ++#[expect( ++ clippy::print_stderr, ++ reason = "startup config auto-migration warning must be visible before caller logging is configured" ++)] + pub(crate) fn load_settings_path(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|source| Error::read_file(path, source))?; +- let mut layer = content +- .parse::() +- .map_err(|err| Error::parse_file("Failed to parse settings file", path, err))?; ++ let mut layer = match content.parse::() { ++ Ok(layer) => layer, ++ Err(err) => match legacy_sandbox_migration::migrate_settings_path(path, &content)? { ++ Some(report) => { ++ tracing::warn!("{}", report.warning); ++ eprintln!("{}", report.warning); ++ report.contents.parse::().map_err(|err| { ++ Error::parse_file("Migrated settings file is invalid", path, err) ++ })? ++ } ++ None => { ++ return Err(Error::parse_file( ++ "Failed to parse settings file", ++ path, ++ err, ++ )); ++ } ++ }, ++ }; + let base_dir = path.parent().unwrap_or_else(|| Path::new(".")); + resolve_goal_file_paths(&mut layer, base_dir); + Ok(layer) +@@ -45,3 +65,38 @@ pub(crate) fn resolve_goal_file_path(path_str: &str, base_dir: &Path) -> PathBuf + base_dir.join(path) + } + } ++ ++#[cfg(test)] ++mod tests { ++ use fabro_types::settings::run::EnvironmentProvider; ++ ++ use super::*; ++ ++ #[test] ++ fn load_settings_path_auto_migrates_legacy_sandbox_file() { ++ let dir = tempfile::tempdir().expect("temp dir"); ++ let path = dir.path().join("settings.toml"); ++ std::fs::write( ++ &path, ++ r#" ++_version = 1 ++ ++[run.sandbox] ++provider = "daytona" ++"#, ++ ) ++ .expect("write legacy settings"); ++ ++ let layer = load_settings_path(&path).expect("legacy settings should auto-migrate"); ++ let resolved = crate::WorkflowSettingsBuilder::from_layer(&layer) ++ .expect("migrated settings should resolve") ++ .run; ++ ++ assert_eq!(resolved.environment.provider, EnvironmentProvider::Daytona); ++ assert!( ++ std::fs::read_to_string(&path) ++ .expect("read rewritten settings") ++ .contains("[run.environment]") ++ ); ++ } ++} +diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs +index 030a234b2..7554a3429 100644 +--- a/lib/crates/fabro-config/src/user.rs ++++ b/lib/crates/fabro-config/src/user.rs +@@ -72,6 +72,9 @@ fn load_v2_layer_from_path(path: &Path) -> Result { + + #[cfg(test)] + mod tests { ++ use fabro_static::EnvVars; ++ use temp_env::with_var; ++ + use super::{ + SETTINGS_CONFIG_FILENAME, active_settings_path_with_lookup, default_settings_path, + default_socket_path, default_storage_dir, +@@ -79,14 +82,16 @@ mod tests { + + #[test] + fn settings_paths_use_expected_filenames() { +- let home = dirs::home_dir().unwrap(); +- +- assert_eq!( +- default_settings_path(), +- home.join(".fabro").join(SETTINGS_CONFIG_FILENAME) +- ); +- assert_eq!(default_storage_dir(), home.join(".fabro/storage")); +- assert_eq!(default_socket_path(), home.join(".fabro/fabro.sock")); ++ with_var(EnvVars::FABRO_HOME, None::<&str>, || { ++ let home = dirs::home_dir().unwrap(); ++ ++ assert_eq!( ++ default_settings_path(), ++ home.join(".fabro").join(SETTINGS_CONFIG_FILENAME) ++ ); ++ assert_eq!(default_storage_dir(), home.join(".fabro/storage")); ++ assert_eq!(default_socket_path(), home.join(".fabro/fabro.sock")); ++ }); + } + + #[test] diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..589cfadbe --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-23T20:13:15.232065Z" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/prompt.md b/stages/006-simplify_opus@1/prompt.md new file mode 100644 index 000000000..3e14fa5b6 --- /dev/null +++ b/stages/006-simplify_opus@1/prompt.md @@ -0,0 +1,957 @@ +Goal: # Legacy Sandbox Config Auto-Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically rewrite confidently migratable legacy `[run.sandbox]` config files to the named-environments syntax during startup. + +**Architecture:** Keep legacy behavior isolated in a removable `fabro-config` module. The normal settings schema stays strict; only file-based loads get a temporary parse-failure recovery path that rewrites the file, writes a backup, warns, and then resumes normal parsing. + +**Tech Stack:** Rust, `toml_edit`, existing `fabro-config` settings builders, `tracing`, `tempfile` tests, public docs under `docs/public`. + +--- + +## File Structure + +- Create `lib/crates/fabro-config/src/legacy_sandbox_migration.rs` + - Owns detection, TOML rewriting, backup naming/writing, unsupported-key diagnostics, and tests for legacy mappings. +- Modify `lib/crates/fabro-config/src/lib.rs` + - Register the module privately. +- Modify `lib/crates/fabro-config/Cargo.toml` + - Add the existing workspace `toml_edit` dependency; current main does not depend on it from `fabro-config`. +- Modify `lib/crates/fabro-config/src/load.rs` + - Add a small parse-failure hook that delegates to the migration module, then returns to normal parsing. +- Modify docs: + - `docs/public/execution/environments.mdx` + - Create `docs/public/changelog/2026-05-23.mdx` because current main's latest public changelog is `2026-05-22.mdx`. + +## Migration Contract + +Only migrate when all of these are true: + +- The file is valid TOML as a document. +- `[run.sandbox]` exists. +- `[run.environment]` does not exist. +- `[environments.default]` does not exist. +- Every legacy sandbox key is in the supported mapping below. +- The migrated content parses successfully as `SettingsLayer`. + +This is intentionally a file-load migration only. Current in-memory parsing behavior, including `legacy_run_sandbox_is_rejected` in `lib/crates/fabro-config/src/tests/resolve_run.rs`, should remain strict and unchanged. + +Supported mappings: + +| Legacy key | New key | +|---|---| +| `run.sandbox.provider` | `run.environment.id = "default"` and `environments.default.provider` | +| `run.sandbox.preserve` | `environments.default.lifecycle.preserve` | +| `run.sandbox.env` | `environments.default.env` | +| `run.sandbox.daytona.skip_clone = true` | `run.clone.enabled = false` | +| `run.sandbox.docker.skip_clone = true` | `run.clone.enabled = false` | +| `run.sandbox.daytona.auto_stop_interval = N` | `environments.default.lifecycle.auto_stop = "{N}m"` | +| `run.sandbox.daytona.labels` | `environments.default.labels` | +| `run.sandbox.daytona.snapshot.name` | `environments.default.image.ref` | +| `run.sandbox.daytona.snapshot.cpu` | `environments.default.resources.cpu` | +| `run.sandbox.daytona.snapshot.memory` | `environments.default.resources.memory` | +| `run.sandbox.daytona.snapshot.disk` | `environments.default.resources.disk` | +| `run.sandbox.daytona.snapshot.dockerfile` | `environments.default.image.dockerfile` | +| `run.sandbox.daytona.volumes[].volume_id` | `environments.default.volumes[].id` | +| `run.sandbox.daytona.volumes[].mount_path` | `environments.default.volumes[].mount_path` | +| `run.sandbox.daytona.volumes[].subpath` | `environments.default.volumes[].subpath` | +| `run.sandbox.docker.image` | `environments.default.image.ref` | +| `run.sandbox.docker.memory_limit` | `environments.default.resources.memory` | +| `run.sandbox.docker.cpu_quota` | `environments.default.resources.cpu` when divisible by `100000` | + +Unsupported or ambiguous cases fail with a message shaped like: + +```text +Legacy [run.sandbox] settings in 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: 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/006-simplify_opus@1/provider_used.json b/stages/006-simplify_opus@1/provider_used.json new file mode 100644 index 000000000..672cc50e5 --- /dev/null +++ b/stages/006-simplify_opus@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/response.md b/stages/006-simplify_opus@1/response.md new file mode 100644 index 000000000..752fd525d --- /dev/null +++ b/stages/006-simplify_opus@1/response.md @@ -0,0 +1,20 @@ +Cut 60 lines off the migration module while fixing a real bug. Summary of fixes from the review: + +**Fixes applied:** +1. **Correctness bug** (Agent 2 #1): `skip_clone = false` (the default) was wrongly rejected as unsupported. Now only non-bool values fail. Added test `explicit_skip_clone_false_is_accepted_as_default`. +2. **Code reuse** (Agent 1 #1, #2): Removed custom `is_table_like` / `copy_table` branching; now uses toml_edit's built-in `Item::is_table_like()` and `Item::as_table_like()` + `TableLike` trait. +3. **Duplicate helpers** (Agent 1 #3): Collapsed `path_table` and `path_table_in_table` into a single `ensure_table` that operates on `&mut Table`; callers use `doc.as_table_mut()`. +4. **Stringly-typed** (Agent 2 #3): Replaced raw `"daytona"` / `"docker"` matching with `EnvironmentProvider::from_str` + `IntoStaticStr`; eliminated the awkward `migrate_skip_clone` + `reject_provider_table` mirror-dispatch. +5. **Trivial wrappers** (Agent 1 #9, Agent 2 smaller): Dropped `set_value` and `set_item`; callers use `table[key] = …` directly. +6. **Efficiency** (Agent 3 main finding): `migrate_settings_path` now returns the already-parsed `SettingsLayer` instead of returning a string for the caller to re-parse — eliminates a redundant serde pass on the migration path. +7. **Double-report bug** (Agent 2 smaller): `item_path_keys` now stops after reporting an empty array-of-tables instead of re-pushing per element. +8. **Dead-code cleanup** (Agent 1 #4): Trimmed `unwrap_or("settings.toml")` dead fallback and computed `file_name` once before the loop in `next_backup_path`. +9. **Dead-code warning**: Made `backup_path` a `#[cfg(test)]` field since it's only used in tests, avoiding a `dead_code` warning in non-test builds without sprinkling `#[allow]`. + +**Skipped (deliberate):** +- Adding a dedicated `Error::WriteFile` variant and converting `MigrationFailure` into an Error variant (Agent 1 #5, #6) — out of scope for a soon-to-be-removed module. +- Promoting `next_backup_path` to `fabro-util` (Agent 1 #4, #7) — this file is going away pre-v1.0. +- `user.rs` test fix concerns (Agent 2 #5) — works correctly, leave alone. +- Test fixture builder consolidation — legacy TOML shape is the point of the tests. + +**Verification:** `cargo nextest run -p fabro-config` (216 pass), `cargo +nightly-2026-04-14 fmt --check --all` (clean), `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` (clean). \ No newline at end of file