commit 32c38ac7bf0ac8367374db8745a8d9b217c751d6 Author: Fabro Date: Sat May 23 15:56:22 2026 -0400 init run ⚒️ Generated with [Fabro](https://fabro.sh) diff --git a/graph.fabro b/graph.fabro new file mode 100644 index 000000000..bfa79d6c2 --- /dev/null +++ b/graph.fabro @@ -0,0 +1,35 @@ +digraph ImplementPlan { + graph [ + goal="Implement and simplify", + model_stylesheet=" + * { model: claude-opus-4-7; } + " + ] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + toolchain [label="Toolchain", shape=parallelogram, 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", max_retries=0] + preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] + preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] + fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] + implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] + simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] + simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] + verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] + fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] + + start -> toolchain + toolchain -> preflight_compile [condition="outcome=succeeded"] + toolchain -> exit + preflight_compile -> preflight_lint [condition="outcome=succeeded"] + preflight_compile -> exit + preflight_lint -> implement [condition="outcome=succeeded"] + preflight_lint -> fix_lints + fix_lints -> preflight_lint + implement -> simplify_opus -> simplify_gpt -> verify + verify -> exit [condition="outcome=succeeded"] + verify -> fixup + fixup -> verify +} diff --git a/run.json b/run.json new file mode 100644 index 000000000..366577ff6 --- /dev/null +++ b/run.json @@ -0,0 +1,514 @@ +{ + "title": "Legacy Sandbox Config Auto-Migration Implementation Plan", + "spec": { + "run_id": "01KSB6HFNMJ802AXGBAV5JP7ZS", + "settings": { + "project": { + "name": null, + "description": null, + "metadata": {} + }, + "workflow": { + "name": null, + "description": null, + "graph": "workflow.fabro", + "metadata": {} + }, + "run": { + "goal": { + "type": "inline", + "value": "# 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" + }, + "working_dir": null, + "metadata": {}, + "inputs": {}, + "model": { + "provider": "anthropic", + "name": "claude-sonnet-4-6", + "fallbacks": [], + "controls": { + "reasoning_effort": null, + "speed": null + } + }, + "git": { + "author": null + }, + "prepare": { + "commands": [], + "timeout_ms": 300000 + }, + "execution": { + "mode": "normal", + "approval": "prompt" + }, + "checkpoint": { + "exclude_globs": [], + "skip_git_hooks": false + }, + "clone": { + "enabled": true + }, + "run_branch": { + "enabled": true, + "push": true + }, + "meta_branch": { + "enabled": true, + "push": true + }, + "environment": { + "id": "fabro-dev", + "provider": "daytona", + "image": { + "ref": "fabro-v11", + "dockerfile": { + "type": "inline", + "value": "FROM ubuntu:24.04\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 \\\n xvfb xfce4 xfce4-terminal x11vnc novnc dbus-x11 \\\n libx11-6 libxrandr2 libxext6 libxrender1 libxfixes3 libxss1 libxtst6 libxi6 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Install real Chromium (not the snap stub) via xtradeb PPA\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n software-properties-common curl gnupg \\\n && add-apt-repository -y ppa:xtradeb/apps \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends chromium \\\n && rm -rf /var/lib/apt/lists/*\n\n# Wrapper: Chromium needs --no-sandbox when running as root in a container,\n# and --disable-dev-shm-usage avoids crashes from small /dev/shm\nRUN printf '#!/bin/bash\\nexec /usr/bin/chromium --no-sandbox --disable-dev-shm-usage \"$@\"\\n' \\\n > /usr/local/bin/chromium-wrapper \\\n && chmod +x /usr/local/bin/chromium-wrapper\n\n# Make the wrapper the default in the system .desktop file and via alternatives\nRUN sed -i 's|^Exec=.*|Exec=/usr/local/bin/chromium-wrapper %U|' \\\n /usr/share/applications/chromium.desktop \\\n && update-alternatives --install /usr/bin/x-www-browser x-www-browser \\\n /usr/local/bin/chromium-wrapper 100\n\n# Tell XFCE's exo-open that Chromium is the WebBrowser helper (system-wide)\nRUN mkdir -p /etc/xdg/xfce4 /usr/share/xfce4/helpers \\\n && printf 'WebBrowser=custom-WebBrowser\\n' > /etc/xdg/xfce4/helpers.rc \\\n && printf '[Desktop Entry]\\n\\\nVersion=1.0\\n\\\nType=X-XFCE-Helper\\n\\\nName=Chromium\\n\\\nIcon=chromium\\n\\\nX-XFCE-Category=WebBrowser\\n\\\nX-XFCE-CommandsWithParameter=/usr/local/bin/chromium-wrapper \"%%s\"\\n\\\nX-XFCE-Commands=/usr/local/bin/chromium-wrapper\\n' \\\n > /usr/share/xfce4/helpers/custom-WebBrowser.desktop\n\n# GitHub CLI\nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\\n | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \\\n && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" \\\n | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \\\n && apt-get update && apt-get install -y --no-install-recommends gh \\\n && rm -rf /var/lib/apt/lists/*\n\n# Rust\nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nENV PATH=\"/root/.cargo/bin:${PATH}\"\nRUN rustup toolchain install nightly-2026-04-14 --profile minimal --component clippy,rustfmt\nRUN cargo install cargo-nextest --locked\nENV CARGO_INCREMENTAL=0\n\n# Bun\nRUN curl -fsSL https://bun.sh/install | bash\nENV PATH=\"/root/.bun/bin:${PATH}\"\n\nWORKDIR /root\n" + } + }, + "resources": { + "cpu": 8, + "memory": "16GB", + "disk": "20GB" + }, + "network": { + "mode": "allow_all", + "allow": [] + }, + "lifecycle": { + "preserve": false, + "stop_on_terminal": true, + "auto_stop": "30m" + }, + "labels": { + "repo": "fabro-sh/fabro" + }, + "volumes": [], + "env": {} + }, + "notifications": {}, + "interviews": { + "provider": null, + "slack": null + }, + "agent": { + "fabro_tools": false, + "permissions": null, + "mcps": {} + }, + "hooks": [], + "scm": { + "provider": null, + "owner": null, + "repository": null, + "github": null + }, + "pull_request": { + "enabled": true, + "draft": false, + "auto_merge": false, + "merge_strategy": "squash" + }, + "artifacts": { + "include": [] + }, + "integrations": { + "github": { + "permissions": {} + } + } + } + }, + "graph": { + "name": "ImplementPlan", + "nodes": { + "preflight_compile": { + "id": "preflight_compile", + "attrs": { + "model": { + "String": "claude-opus-4-7" + }, + "script": { + "String": "cargo check -q --workspace 2>&1" + }, + "provider": { + "String": "anthropic" + }, + "label": { + "String": "Preflight Compile" + }, + "shape": { + "String": "parallelogram" + }, + "max_retries": { + "Integer": 0 + } + } + }, + "fix_lints": { + "id": "fix_lints", + "attrs": { + "model": { + "String": "claude-opus-4-7" + }, + "label": { + "String": "Fix Lints" + }, + "prompt": { + "String": "The preflight lint step failed. Read the build output from context and fix all clippy lint warnings." + }, + "provider": { + "String": "anthropic" + }, + "max_visits": { + "Integer": 3 + } + } + }, + "implement": { + "id": "implement", + "attrs": { + "provider": { + "String": "openai" + }, + "label": { + "String": "Implement" + }, + "reasoning_effort": { + "String": "xhigh" + }, + "prompt": { + "String": "Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD." + }, + "model": { + "String": "gpt-5.5" + } + } + }, + "verify": { + "id": "verify", + "attrs": { + "model": { + "String": "claude-opus-4-7" + }, + "provider": { + "String": "anthropic" + }, + "shape": { + "String": "parallelogram" + }, + "retry_target": { + "String": "fixup" + }, + "goal_gate": { + "Boolean": true + }, + "label": { + "String": "Verify" + }, + "script": { + "String": "git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1" + } + } + }, + "simplify_gpt": { + "id": "simplify_gpt", + "attrs": { + "provider": { + "String": "openai" + }, + "prompt": { + "String": "# Simplify: Code Review and Cleanup\n\nReview changes vs. origin for reuse, quality, and efficiency. Fix any issues found.\n\n## Phase 1: Identify Changes\n\nRun 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.\n\n## Phase 2: Launch Three Review Agents in Parallel\n\nUse 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.\n\n### Agent 1: Code Reuse Review\n\nFor each change:\n\n1. 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.\n2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.\n3. 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.\n\nNote: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.\n\n### Agent 2: Code Quality Review\n\nReview the same changes for hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls\n2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction\n4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries\n5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase\n\nNote: This is a greenfield app, so be aggressive in optimizing quality.\n\n### Agent 3: Efficiency Review\n\nReview the same changes for efficiency:\n\n1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns\n2. Missed concurrency: independent operations run sequentially when they could run in parallel\n3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths\n4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error\n5. Memory: unbounded data structures, missing cleanup, event listener leaks\n6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one\n\n## Phase 3: Fix Issues\n\nWait 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.\n\nWhen done, briefly summarize what was fixed (or confirm the code was already clean)." + }, + "model": { + "String": "gpt-5.5" + }, + "label": { + "String": "Simplify (GPT-55)" + } + } + }, + "fixup": { + "id": "fixup", + "attrs": { + "model": { + "String": "claude-opus-4-7" + }, + "max_visits": { + "Integer": 3 + }, + "label": { + "String": "Fixup" + }, + "provider": { + "String": "anthropic" + }, + "prompt": { + "String": "The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures." + } + } + }, + "simplify_opus": { + "id": "simplify_opus", + "attrs": { + "label": { + "String": "Simplify (Opus)" + }, + "prompt": { + "String": "# Simplify: Code Review and Cleanup\n\nReview changes vs. origin for reuse, quality, and efficiency. Fix any issues found.\n\n## Phase 1: Identify Changes\n\nRun 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.\n\n## Phase 2: Launch Three Review Agents in Parallel\n\nUse 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.\n\n### Agent 1: Code Reuse Review\n\nFor each change:\n\n1. 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.\n2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.\n3. 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.\n\nNote: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.\n\n### Agent 2: Code Quality Review\n\nReview the same changes for hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls\n2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction\n4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries\n5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase\n\nNote: This is a greenfield app, so be aggressive in optimizing quality.\n\n### Agent 3: Efficiency Review\n\nReview the same changes for efficiency:\n\n1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns\n2. Missed concurrency: independent operations run sequentially when they could run in parallel\n3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths\n4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error\n5. Memory: unbounded data structures, missing cleanup, event listener leaks\n6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one\n\n## Phase 3: Fix Issues\n\nWait 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.\n\nWhen done, briefly summarize what was fixed (or confirm the code was already clean)." + }, + "model": { + "String": "claude-opus-4-7" + }, + "provider": { + "String": "anthropic" + } + } + }, + "exit": { + "id": "exit", + "attrs": { + "shape": { + "String": "Msquare" + }, + "label": { + "String": "Exit" + }, + "provider": { + "String": "anthropic" + }, + "model": { + "String": "claude-opus-4-7" + } + } + }, + "preflight_lint": { + "id": "preflight_lint", + "attrs": { + "script": { + "String": "cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1" + }, + "model": { + "String": "claude-opus-4-7" + }, + "provider": { + "String": "anthropic" + }, + "shape": { + "String": "parallelogram" + }, + "max_retries": { + "Integer": 0 + }, + "label": { + "String": "Preflight Lint" + } + } + }, + "start": { + "id": "start", + "attrs": { + "label": { + "String": "Start" + }, + "model": { + "String": "claude-opus-4-7" + }, + "provider": { + "String": "anthropic" + }, + "shape": { + "String": "Mdiamond" + } + } + }, + "toolchain": { + "id": "toolchain", + "attrs": { + "provider": { + "String": "anthropic" + }, + "shape": { + "String": "parallelogram" + }, + "label": { + "String": "Toolchain" + }, + "model": { + "String": "claude-opus-4-7" + }, + "script": { + "String": "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" + }, + "max_retries": { + "Integer": 0 + } + } + } + }, + "edges": [ + { + "from": "start", + "to": "toolchain", + "attrs": {} + }, + { + "from": "toolchain", + "to": "preflight_compile", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "toolchain", + "to": "exit", + "attrs": {} + }, + { + "from": "preflight_compile", + "to": "preflight_lint", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "preflight_compile", + "to": "exit", + "attrs": {} + }, + { + "from": "preflight_lint", + "to": "implement", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "preflight_lint", + "to": "fix_lints", + "attrs": {} + }, + { + "from": "fix_lints", + "to": "preflight_lint", + "attrs": {} + }, + { + "from": "implement", + "to": "simplify_opus", + "attrs": {} + }, + { + "from": "simplify_opus", + "to": "simplify_gpt", + "attrs": {} + }, + { + "from": "simplify_gpt", + "to": "verify", + "attrs": {} + }, + { + "from": "verify", + "to": "exit", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "verify", + "to": "fixup", + "attrs": {} + }, + { + "from": "fixup", + "to": "verify", + "attrs": {} + } + ], + "attrs": { + "goal": { + "String": "# 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" + }, + "rankdir": { + "String": "LR" + }, + "model_stylesheet": { + "String": "\n * { model: claude-opus-4-7; }\n " + } + } + }, + "graph_source": "digraph ImplementPlan {\n graph [\n goal=\"Implement and simplify\",\n model_stylesheet=\"\n * { model: claude-opus-4-7; }\n \"\n ]\n rankdir=LR\n\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n\n toolchain [label=\"Toolchain\", shape=parallelogram, 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\", max_retries=0]\n preflight_compile [label=\"Preflight Compile\", shape=parallelogram, script=\"cargo check -q --workspace 2>&1\", max_retries=0]\n preflight_lint [label=\"Preflight Lint\", shape=parallelogram, script=\"cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1\", max_retries=0]\n fix_lints [label=\"Fix Lints\", prompt=\"The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.\", max_visits=3]\n implement [label=\"Implement\", prompt=\"Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.\", model=\"gpt-55\", reasoning_effort=\"xhigh\"]\n simplify_opus [label=\"Simplify (Opus)\", prompt=\"@prompts/simplify.md\"]\n simplify_gpt [label=\"Simplify (GPT-55)\", prompt=\"@prompts/simplify.md\", model=\"gpt-55\"]\n verify [label=\"Verify\", shape=parallelogram, script=\"git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\\bActorRef\\b|\\bActorKind\\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\\s*==\\s*\\\"disabled\\\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1\", goal_gate=true, retry_target=\"fixup\"]\n fixup [label=\"Fixup\", prompt=\"The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.\", max_visits=3]\n\n start -> toolchain\n toolchain -> preflight_compile [condition=\"outcome=succeeded\"]\n toolchain -> exit\n preflight_compile -> preflight_lint [condition=\"outcome=succeeded\"]\n preflight_compile -> exit\n preflight_lint -> implement [condition=\"outcome=succeeded\"]\n preflight_lint -> fix_lints\n fix_lints -> preflight_lint\n implement -> simplify_opus -> simplify_gpt -> verify\n verify -> exit [condition=\"outcome=succeeded\"]\n verify -> fixup\n fixup -> verify\n}\n", + "workflow_slug": "implement-plan", + "source_directory": "/Users/bhelmkamp/p/fabro-sh/fabro", + "provenance": { + "server": { + "version": "0.242.0-nightly.1" + }, + "client": { + "user_agent": "fabro-cli/0.242.0-nightly.1", + "name": "fabro-cli", + "version": "0.242.0-nightly.1" + }, + "subject": { + "kind": "user", + "identity": { + "issuer": "https://github.com", + "subject": "19" + }, + "login": "brynary", + "auth_method": "github", + "avatar_url": "https://avatars.githubusercontent.com/u/19?v=4" + } + }, + "manifest_blob": "2e610986f1415ebeebabbba8afd17489a99cf49202cea8bf06521dae025777df", + "definition_blob": "706cd3c883ee5cdedd79e85fe355771be58138e6a8308af811dce4901631b0de", + "git": { + "origin_url": "https://github.com/fabro-sh/fabro", + "branch": "main", + "sha": "dbe3e3966d1eec7a7d235e7db34a46919336cbd9", + "dirty": "dirty", + "push_outcome": { + "type": "not_attempted" + } + } + }, + "web_url": "http://127.0.0.1:32276/runs/01KSB6HFNMJ802AXGBAV5JP7ZS", + "start": null, + "status": { + "kind": "starting" + }, + "status_updated_at": "2026-05-23T19:56:05.735239Z", + "last_event_at": "2026-05-23T19:56:21.347433Z", + "pending_control": null, + "checkpoints": [], + "conclusion": null, + "sandbox": { + "provider": "daytona", + "snapshot": "fabro-v11", + "runtime": { + "id": "fabro-01KSB6HFNMJ802AXGBAV5JP7ZS", + "working_directory": "/home/daytona/workspace/fabro", + "repo_cloned": true, + "clone_origin_url": "https://github.com/fabro-sh/fabro", + "clone_branch": "main", + "workspace_root": "/home/daytona/workspace", + "repos_root": "/home/daytona/repos", + "primary_repo_path": "/home/daytona/repos/fabro-sh/fabro", + "primary_repo_link": "/home/daytona/workspace/fabro" + } + }, + "pull_request": null, + "superseded_by": null, + "pending_interviews": {}, + "stages": {} +} \ No newline at end of file