From 95d887b025794d9103fda9727a74b39ea0149991 Mon Sep 17 00:00:00 2001 From: Fabro Date: Fri, 29 May 2026 14:44:29 -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 | 529 +++- stages/005-implement@1/diff.patch | 2574 +++++++++++++++++ stages/005-implement@1/status.json | 6 + stages/006-simplify_opus@1/prompt.md | 207 ++ stages/006-simplify_opus@1/provider_used.json | 5 + stages/006-simplify_opus@1/response.md | 28 + 6 files changed, 3325 insertions(+), 24 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 0a1b8cab9..40c9f2a20 100644 --- a/run.json +++ b/run.json @@ -505,7 +505,7 @@ "kind": "running" }, "status_updated_at": "2026-05-29T17:59:12.457328Z", - "last_event_at": "2026-05-29T18:29:49.144989Z", + "last_event_at": "2026-05-29T18:44:28.861798Z", "pending_control": null, "checkpoints": [ { @@ -789,9 +789,9 @@ } }, { - "seq": 0, + "seq": 519, "checkpoint": { - "timestamp": "2026-05-29T18:29:49.348864Z", + "timestamp": "2026-05-29T18:29:54.117484Z", "current_node": "implement", "completed_nodes": [ "start", @@ -803,30 +803,30 @@ "node_retries": {}, "context_values": { "internal.work_dir": "/home/daytona/workspace/fabro", - "internal.retry_count.start": 0, - "internal.thread_id": "preflight_lint", - "current_node": "implement", - "thread.start.current_node": "toolchain", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.retry_count.preflight_compile": 0, + "last_stage": "implement", + "graph.rankdir": "LR", + "response.implement": "Implemented Environment REST CRUD API end-to-end.\n\nSummary:\n- Added OpenAPI `Environments` tag, `/api/v1/environments` CRUD paths, `EnvironmentId` parameter, list envelope, and REST-safe inline-only Dockerfile schemas.\n- Added server handler module: `lib/crates/fabro-server/src/server/handler/environments.rs`.\n- Mounted real API routes only; no demo routes added.\n- Added API DTO boundary that rejects REST Dockerfile `path` sources with `422` before calling `EnvironmentStore`.\n- Added ETag / `If-Match` behavior matching Automations.\n- Added `EnvironmentStoreError` → `ApiError` mapping with stale/duplicate/protected as `409`.\n- Refreshes cached manifest run settings after create/replace/delete so `/system/info` reflects catalog mutations.\n- Tightened `fabro-environment` create/replace behavior to validate dense settings before persistence.\n- Regenerated Rust API types and TypeScript Axios client; TS now exposes `EnvironmentsApi`.\n- Added server integration tests, OpenAPI/SDK conformance test, and Rust API JSON/type-boundary tests.\n\nValidation run:\n- Red TDD check: new environment API tests initially failed with `404` before routes existed.\n- `cargo nextest run -p fabro-server --features test-support --test it api::environments`\n- `cargo nextest run -p fabro-server --features test-support --test it openapi_conformance`\n- `cargo test -p fabro-api --test environment_round_trip`\n- `cargo nextest run -p fabro-environment`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n\nNote: I installed `openjdk-21-jre-headless` in the container because `openapi-generator-cli` requires `java` for TypeScript client generation.", + "thread.preflight_lint.current_node": "implement", + "thread.toolchain.current_node": "preflight_compile", + "internal.fidelity": "compact", + "internal.retry_count.toolchain": 0, + "last_response": "Implemented Environment REST CRUD API end-to-end.\n\nSummary:\n- Added OpenAPI `Environments` tag, `/api/v1/environments` CRUD paths, `EnvironmentId` parameter, list envelope, and REST-safe inline-only D", + "thread.preflight_compile.current_node": "preflight_lint", "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "graph.goal": "---\ntitle: \"feat: Add Environment REST CRUD API\"\ntype: feat\nstatus: active\ndate: 2026-05-28\n---\n\n# feat: Add Environment REST CRUD API\n\n## Summary\n\nAdd server-owned Environment CRUD under `/api/v1/environments`, modeled after\nAutomations and backed by the existing `EnvironmentStore`. The API manages only\nthe server-side environment catalog in `environments/*.toml`; client-side\nenvironment definitions in `workflow.toml`, `.fabro/project.toml`, or run inputs\ncontinue to work and are not managed by this API.\n\n## API Contract\n\n- Add OpenAPI paths:\n - `GET /api/v1/environments`\n - `POST /api/v1/environments`\n - `GET /api/v1/environments/{id}`\n - `PUT /api/v1/environments/{id}`\n - `DELETE /api/v1/environments/{id}`\n- Mirror Automations semantics:\n - List returns `{ data: Environment[], meta: { total } }`, sorted by id.\n - Create body includes `id`; replace body omits `id`; path id is authoritative.\n - `GET` and `PUT` return `ETag: \"\"`.\n - `PUT` and `DELETE` require `If-Match`.\n - Use existing Automation-style statuses: `400`, `404`, `409`, `422`, `428`, `500`.\n - Stale revisions return `409` to match Automations.\n- Add API-specific Environment request/response schemas so REST `image.dockerfile`\n accepts only inline content or `null`.\n - Existing workflow/settings schemas keep supporting Dockerfile `path`.\n - REST requests with Dockerfile `path` return `422` and must not read\n server-local files.\n- Do not add `PATCH` in v1.\n\n## Implementation Changes\n\n- OpenAPI and generated clients:\n - Update `docs/public/api-reference/fabro-api.yaml` with an `Environments` tag,\n an `EnvironmentId` parameter, CRUD paths, list envelope, and inline-only API\n image schema.\n - Regenerate Rust API types and the TypeScript Axios client.\n - Keep the existing `EnvironmentSettings` schema intact for workflow settings.\n- Server:\n - Add `lib/crates/fabro-server/src/server/handler/environments.rs`, following\n `automations.rs` for routes, auth, ETag parsing, and error mapping.\n - Merge the routes into real API routes; do not add demo routes unless an\n existing convention requires it.\n - Convert API request DTOs into `EnvironmentDraft` / `EnvironmentSettings` only\n after rejecting Dockerfile path sources.\n - Map `EnvironmentStoreError` similarly to Automations: duplicate, protected,\n and stale as `409`; missing as `404`; validation as `422`; internal\n storage/parse/io as curated `500`.\n - After successful create, replace, or delete, refresh cached manifest run\n settings from the current `EnvironmentStore` catalog so `/system/info` and\n default run settings reflect the updated catalog.\n- Domain and API types:\n - Use a meaningful API DTO boundary rather than treating REST and TOML as\n identical Dockerfile-source surfaces.\n - Reuse `fabro-environment::Environment` for persisted domain behavior where\n the wire shape matches; keep API-only request schemas distinct where\n inline-only Dockerfile behavior differs.\n\n## Implementation Units\n\n- [ ] **Unit 1: Define the OpenAPI contract**\n - Add the environment CRUD paths, schemas, and path parameter.\n - Ensure the spec distinguishes REST-safe inline Dockerfile sources from the\n existing workflow/settings Dockerfile source schema.\n - Verification: OpenAPI route conformance can see the new paths and generated\n clients expose an `EnvironmentsApi`.\n\n- [ ] **Unit 2: Add server environment handlers**\n - Implement a new handler module mirroring the Automation CRUD handler shape.\n - Enforce authentication, id parsing, ETag/If-Match behavior, and error mapping.\n - Reject REST Dockerfile path sources before calling `EnvironmentStore`.\n - Verification: server API tests prove CRUD behavior and failure responses.\n\n- [ ] **Unit 3: Refresh derived server state after mutations**\n - Ensure successful environment create, replace, and delete refresh any cached\n manifest run settings derived from `EnvironmentStore::catalog_layer()`.\n - Preserve existing client-side environment precedence and behavior.\n - Verification: a test proves newly created server environments affect the\n resolved server default run environment where applicable.\n\n- [ ] **Unit 4: Regenerate clients and add contract tests**\n - Regenerate `fabro-api` and `lib/packages/fabro-api-client`.\n - Add Rust server integration tests and keep OpenAPI conformance passing.\n - Verification: generated Rust and TypeScript surfaces compile and expose the\n new environment operations.\n\n## Test Plan\n\n- Add server API tests in\n `lib/crates/fabro-server/tests/it/api/environments.rs` and register the module.\n- Cover:\n - List returns seeded environments and correct total.\n - Create persists `environments/{id}.toml`, returns `201`, and is visible via\n list/get.\n - Get returns current `ETag` matching `revision`.\n - Replace with valid `If-Match` updates the file, returns a new revision, and\n updates the `ETag`.\n - Replace/delete without `If-Match` return `428`.\n - Stale replace/delete return `409`.\n - Duplicate create returns `409`.\n - Invalid id/header returns `400`.\n - Domain validation failures return `422`.\n - Dockerfile `path` over REST returns `422` and does not persist or expose file\n contents.\n - Delete removes a non-default environment; deleting `default` returns a\n protected conflict.\n - Unauthenticated environment routes return `401`.\n - Creating an environment referenced by server default run settings refreshes\n cached manifest run settings.\n\n## Assumptions\n\n- This API manages server-owned environments only; client-defined catalogs remain\n file/request scoped.\n- Built-in seed behavior follows the current store: seeded environments are\n listed, create conflicts with existing ids, and `default` is protected from\n delete.\n- Create responses match Automations and do not need an `ETag`; clients can use\n the returned `revision` or call `GET`.\n- Inline-only Dockerfile policy applies only to REST CRUD, not local TOML\n configuration.\n\n## Sources\n\n- `docs/public/api-reference/fabro-api.yaml`\n- `lib/crates/fabro-server/src/server/handler/automations.rs`\n- `lib/crates/fabro-environment/src/store.rs`\n- `lib/crates/fabro-environment/src/model.rs`\n- `docs/public/execution/environments.mdx`\n", - "failure_class": "", - "failure_signature": "", - "graph.rankdir": "LR", - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", "outcome": "succeeded", + "failure_signature": "", + "thread.start.current_node": "toolchain", + "failure_class": "", + "internal.thread_id": "preflight_lint", + "current_node": "implement", "internal.node_visit_count": 1, - "internal.retry_count.toolchain": 0, - "thread.toolchain.current_node": "preflight_compile", - "thread.preflight_compile.current_node": "preflight_lint", - "internal.retry_count.preflight_lint": 0, - "last_stage": "implement", - "response.implement": "Implemented Environment REST CRUD API end-to-end.\n\nSummary:\n- Added OpenAPI `Environments` tag, `/api/v1/environments` CRUD paths, `EnvironmentId` parameter, list envelope, and REST-safe inline-only Dockerfile schemas.\n- Added server handler module: `lib/crates/fabro-server/src/server/handler/environments.rs`.\n- Mounted real API routes only; no demo routes added.\n- Added API DTO boundary that rejects REST Dockerfile `path` sources with `422` before calling `EnvironmentStore`.\n- Added ETag / `If-Match` behavior matching Automations.\n- Added `EnvironmentStoreError` → `ApiError` mapping with stale/duplicate/protected as `409`.\n- Refreshes cached manifest run settings after create/replace/delete so `/system/info` reflects catalog mutations.\n- Tightened `fabro-environment` create/replace behavior to validate dense settings before persistence.\n- Regenerated Rust API types and TypeScript Axios client; TS now exposes `EnvironmentsApi`.\n- Added server integration tests, OpenAPI/SDK conformance test, and Rust API JSON/type-boundary tests.\n\nValidation run:\n- Red TDD check: new environment API tests initially failed with `404` before routes existed.\n- `cargo nextest run -p fabro-server --features test-support --test it api::environments`\n- `cargo nextest run -p fabro-server --features test-support --test it openapi_conformance`\n- `cargo test -p fabro-api --test environment_round_trip`\n- `cargo nextest run -p fabro-environment`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n\nNote: I installed `openjdk-21-jre-headless` in the container because `openapi-generator-cli` requires `java` for TypeScript client generation.", - "internal.run_id": "01KSTE7AK905MJ5YJR2Z2X5MJS", - "internal.fidelity": "compact", - "thread.preflight_lint.current_node": "implement", - "last_response": "Implemented Environment REST CRUD API end-to-end.\n\nSummary:\n- Added OpenAPI `Environments` tag, `/api/v1/environments` CRUD paths, `EnvironmentId` parameter, list envelope, and REST-safe inline-only D", "internal.retry_count.implement": 0, - "internal.retry_count.preflight_compile": 0 + "internal.retry_count.preflight_lint": 0, + "internal.retry_count.start": 0, + "internal.run_id": "01KSTE7AK905MJ5YJR2Z2X5MJS" }, "node_outcomes": { "preflight_lint": { @@ -847,6 +847,194 @@ "status": "succeeded", "usage": null }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 129969, + "active_time_ms": 129969 + } + }, + "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, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 1407, + "active_time_ms": 1407 + } + }, + "implement": { + "status": "succeeded", + "context_updates": { + "last_stage": "implement", + "response.implement": "Implemented Environment REST CRUD API end-to-end.\n\nSummary:\n- Added OpenAPI `Environments` tag, `/api/v1/environments` CRUD paths, `EnvironmentId` parameter, list envelope, and REST-safe inline-only Dockerfile schemas.\n- Added server handler module: `lib/crates/fabro-server/src/server/handler/environments.rs`.\n- Mounted real API routes only; no demo routes added.\n- Added API DTO boundary that rejects REST Dockerfile `path` sources with `422` before calling `EnvironmentStore`.\n- Added ETag / `If-Match` behavior matching Automations.\n- Added `EnvironmentStoreError` → `ApiError` mapping with stale/duplicate/protected as `409`.\n- Refreshes cached manifest run settings after create/replace/delete so `/system/info` reflects catalog mutations.\n- Tightened `fabro-environment` create/replace behavior to validate dense settings before persistence.\n- Regenerated Rust API types and TypeScript Axios client; TS now exposes `EnvironmentsApi`.\n- Added server integration tests, OpenAPI/SDK conformance test, and Rust API JSON/type-boundary tests.\n\nValidation run:\n- Red TDD check: new environment API tests initially failed with `404` before routes existed.\n- `cargo nextest run -p fabro-server --features test-support --test it api::environments`\n- `cargo nextest run -p fabro-server --features test-support --test it openapi_conformance`\n- `cargo test -p fabro-api --test environment_round_trip`\n- `cargo nextest run -p fabro-environment`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n\nNote: I installed `openjdk-21-jre-headless` in the container because `openapi-generator-cli` requires `java` for TypeScript client generation.", + "last_response": "Implemented Environment REST CRUD API end-to-end.\n\nSummary:\n- Added OpenAPI `Environments` tag, `/api/v1/environments` CRUD paths, `EnvironmentId` parameter, list envelope, and REST-safe inline-only D" + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openai", + "model_id": "gpt-5.5" + }, + "tokens": { + "input_tokens": 2226903, + "output_tokens": 23362, + "reasoning_tokens": 14691, + "cache_read_tokens": 15023104, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + }, + "total_usd_micros": 19787657 + }, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 1030880, + "tool_time_ms": 509884, + "active_time_ms": 1540764 + } + } + }, + "next_node_id": "simplify_opus", + "git_commit_sha": "dac81857db04a22146cf7f6391dd25ea0ccb2824", + "node_visits": { + "preflight_lint": 1, + "toolchain": 1, + "start": 1, + "implement": 1, + "preflight_compile": 1 + } + }, + "diff": { + "patch": "diff --git a/Cargo.lock b/Cargo.lock\nindex 1c0b593e9..dc705e2cc 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -1726,6 +1726,7 @@ dependencies = [\n \"chrono\",\n \"fabro-automation\",\n \"fabro-config\",\n+ \"fabro-environment\",\n \"fabro-model\",\n \"fabro-types\",\n \"openapiv3\",\ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex 103ab4cad..cb2659bb5 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -17,6 +17,8 @@ tags:\n description: Run management operations\n - name: Automations\n description: Server-managed automation definitions and automation-triggered runs\n+ - name: Environments\n+ description: Server-managed execution environment catalog\n - name: Sandboxes\n description: Provider-backed sandbox inventory\n - name: Sessions\n@@ -4224,6 +4226,272 @@ paths:\n schema:\n $ref: \"#/components/schemas/ErrorResponse\"\n \n+ # ── Environments ─────────────────────────────────────────────────────\n+\n+ /api/v1/environments:\n+ get:\n+ operationId: listEnvironments\n+ tags: [Environments]\n+ summary: List environments\n+ description: Returns all server-managed environment definitions, sorted by id.\n+ responses:\n+ \"200\":\n+ description: Environment definitions\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/EnvironmentListResponse\"\n+ \"500\":\n+ description: Environment store operation failed\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ post:\n+ operationId: createEnvironment\n+ tags: [Environments]\n+ summary: Create environment\n+ description: |\n+ Creates a server-owned environment definition in the environment catalog.\n+ REST environment requests only accept inline Dockerfile content; local\n+ Dockerfile paths are supported by workflow/settings files but rejected\n+ by this API.\n+ requestBody:\n+ required: true\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/CreateEnvironmentRequest\"\n+ responses:\n+ \"201\":\n+ description: Environment created\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Environment\"\n+ \"400\":\n+ description: Malformed JSON request body\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Environment id already exists\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"422\":\n+ description: Environment failed domain validation\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"500\":\n+ description: Environment store operation failed\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n+ /api/v1/environments/{id}:\n+ get:\n+ operationId: retrieveEnvironment\n+ tags: [Environments]\n+ summary: Retrieve environment\n+ description: Returns one server-managed environment definition by id.\n+ parameters:\n+ - $ref: \"#/components/parameters/EnvironmentId\"\n+ responses:\n+ \"200\":\n+ description: Environment definition\n+ headers:\n+ ETag:\n+ $ref: \"#/components/headers/ETag\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Environment\"\n+ \"400\":\n+ description: Invalid environment id\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Environment not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"500\":\n+ description: Environment store operation failed\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ put:\n+ operationId: replaceEnvironment\n+ tags: [Environments]\n+ summary: Replace environment\n+ description: |\n+ Replaces an environment definition when `If-Match` matches the current\n+ environment revision. The path id is authoritative; the request body\n+ omits `id`.\n+ parameters:\n+ - $ref: \"#/components/parameters/EnvironmentId\"\n+ - $ref: \"#/components/parameters/IfMatch\"\n+ requestBody:\n+ required: true\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ReplaceEnvironmentRequest\"\n+ responses:\n+ \"200\":\n+ description: Environment replaced\n+ headers:\n+ ETag:\n+ $ref: \"#/components/headers/ETag\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Environment\"\n+ \"400\":\n+ description: Malformed JSON request body, invalid environment id, or invalid revision header\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Environment not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Environment revision mismatch or protected environment conflict\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"422\":\n+ description: Environment failed domain validation\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"428\":\n+ description: Missing required `If-Match` header\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"500\":\n+ description: Environment store operation failed\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ delete:\n+ operationId: deleteEnvironment\n+ tags: [Environments]\n+ summary: Delete environment\n+ description: Deletes a non-default environment definition when `If-Match` matches the current environment revision.\n+ parameters:\n+ - $ref: \"#/components/parameters/EnvironmentId\"\n+ - $ref: \"#/components/parameters/IfMatch\"\n+ responses:\n+ \"204\":\n+ description: Environment deleted\n+ \"400\":\n+ description: Invalid environment id or revision header\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Environment not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Environment revision mismatch or protected environment conflict\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"428\":\n+ description: Missing required `If-Match` header\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"500\":\n+ description: Environment store operation failed\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n # ── Workflows ────────────────────────────────────────────────────────\n \n /api/v1/workflows:\n@@ -5104,6 +5372,16 @@ components:\n pattern: \"^[a-z0-9][a-z0-9-]{0,62}$\"\n example: nightly-deps\n \n+ EnvironmentId:\n+ name: id\n+ in: path\n+ required: true\n+ description: Unique environment identifier.\n+ schema:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9-]{0,62}$\"\n+ example: docker\n+\n IfMatch:\n name: If-Match\n in: header\n@@ -6033,6 +6311,181 @@ components:\n minimum: 0\n description: Total number of configured automation definitions.\n \n+ # ── Environments ─────────────────────────────────────────────────────\n+\n+ Environment:\n+ description: Public server-managed environment definition.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - id\n+ - revision\n+ - provider\n+ - image\n+ - resources\n+ - network\n+ - lifecycle\n+ - labels\n+ - volumes\n+ - env\n+ properties:\n+ id:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9-]{0,62}$\"\n+ example: docker\n+ revision:\n+ type: string\n+ pattern: \"^[0-9a-f]{64}$\"\n+ description: Stable revision used with `If-Match` for optimistic concurrency.\n+ example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n+ provider:\n+ $ref: \"#/components/schemas/EnvironmentProvider\"\n+ image:\n+ $ref: \"#/components/schemas/EnvironmentApiImageSettings\"\n+ resources:\n+ $ref: \"#/components/schemas/EnvironmentResourcesSettings\"\n+ network:\n+ $ref: \"#/components/schemas/EnvironmentNetworkSettings\"\n+ lifecycle:\n+ $ref: \"#/components/schemas/EnvironmentLifecycleSettings\"\n+ labels:\n+ $ref: \"#/components/schemas/StringMap\"\n+ volumes:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/EnvironmentVolumeSettings\"\n+ env:\n+ type: object\n+ additionalProperties:\n+ $ref: \"#/components/schemas/InterpString\"\n+\n+ CreateEnvironmentRequest:\n+ description: Request body for creating a server-managed environment.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - id\n+ - provider\n+ - image\n+ - resources\n+ - network\n+ - lifecycle\n+ - labels\n+ - volumes\n+ - env\n+ properties:\n+ id:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9-]{0,62}$\"\n+ example: docker\n+ provider:\n+ $ref: \"#/components/schemas/EnvironmentProvider\"\n+ image:\n+ $ref: \"#/components/schemas/EnvironmentApiImageSettings\"\n+ resources:\n+ $ref: \"#/components/schemas/EnvironmentResourcesSettings\"\n+ network:\n+ $ref: \"#/components/schemas/EnvironmentNetworkSettings\"\n+ lifecycle:\n+ $ref: \"#/components/schemas/EnvironmentLifecycleSettings\"\n+ labels:\n+ $ref: \"#/components/schemas/StringMap\"\n+ volumes:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/EnvironmentVolumeSettings\"\n+ env:\n+ type: object\n+ additionalProperties:\n+ $ref: \"#/components/schemas/InterpString\"\n+\n+ ReplaceEnvironmentRequest:\n+ description: Request body for replacing a server-managed environment. The path id is authoritative.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - provider\n+ - image\n+ - resources\n+ - network\n+ - lifecycle\n+ - labels\n+ - volumes\n+ - env\n+ properties:\n+ provider:\n+ $ref: \"#/components/schemas/EnvironmentProvider\"\n+ image:\n+ $ref: \"#/components/schemas/EnvironmentApiImageSettings\"\n+ resources:\n+ $ref: \"#/components/schemas/EnvironmentResourcesSettings\"\n+ network:\n+ $ref: \"#/components/schemas/EnvironmentNetworkSettings\"\n+ lifecycle:\n+ $ref: \"#/components/schemas/EnvironmentLifecycleSettings\"\n+ labels:\n+ $ref: \"#/components/schemas/StringMap\"\n+ volumes:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/EnvironmentVolumeSettings\"\n+ env:\n+ type: object\n+ additionalProperties:\n+ $ref: \"#/components/schemas/InterpString\"\n+\n+ EnvironmentApiImageSettings:\n+ description: REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.\n+ type: object\n+ additionalProperties: false\n+ required: [docker, dockerfile]\n+ properties:\n+ docker:\n+ type: [\"string\", \"null\"]\n+ dockerfile:\n+ oneOf:\n+ - $ref: \"#/components/schemas/EnvironmentApiDockerfileSourceInline\"\n+ - type: \"null\"\n+\n+ EnvironmentApiDockerfileSourceInline:\n+ type: object\n+ additionalProperties: false\n+ required: [type, value]\n+ properties:\n+ type:\n+ type: string\n+ enum: [inline]\n+ value:\n+ type: string\n+\n+ EnvironmentListResponse:\n+ description: List envelope for environment definitions.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - data\n+ - meta\n+ properties:\n+ data:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/Environment\"\n+ meta:\n+ $ref: \"#/components/schemas/EnvironmentListMeta\"\n+\n+ EnvironmentListMeta:\n+ description: Metadata for environment list responses.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - total\n+ properties:\n+ total:\n+ type: integer\n+ format: int64\n+ minimum: 0\n+ description: Total number of server-managed environment definitions.\n+\n # ── Pagination ───────────────────────────────────────────────────────\n \n PaginationMeta:\ndiff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml\nindex ce21c0986..284a1956f 100644\n--- a/lib/crates/fabro-api/Cargo.toml\n+++ b/lib/crates/fabro-api/Cargo.toml\n@@ -17,6 +17,7 @@ wildcard_imports = \"warn\"\n chrono = { workspace = true, features = [\"serde\"] }\n fabro-automation = { path = \"../fabro-automation\" }\n fabro-config = { path = \"../fabro-config\" }\n+fabro-environment.workspace = true\n fabro-model = { path = \"../fabro-model\" }\n fabro-types = { path = \"../fabro-types\" }\n progenitor-client = \"0.13\"\ndiff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs\nindex 3ef344399..c87635923 100644\n--- a/lib/crates/fabro-api/build.rs\n+++ b/lib/crates/fabro-api/build.rs\n@@ -635,6 +635,7 @@ fn main() {\n \"fabro_automation::AutomationReplace\",\n &[],\n ),\n+ (\"Environment\", \"fabro_environment::Environment\", &[]),\n (\"SessionId\", \"fabro_types::SessionId\", &[]),\n (\"TurnId\", \"fabro_types::TurnId\", &[]),\n (\"SessionStatus\", \"fabro_types::SessionStatus\", &[]),\ndiff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs\nindex 28903a67d..815883853 100644\n--- a/lib/crates/fabro-api/src/lib.rs\n+++ b/lib/crates/fabro-api/src/lib.rs\n@@ -18,6 +18,7 @@ pub mod types {\n Automation, AutomationDraft as CreateAutomationRequest,\n AutomationReplace as ReplaceAutomationRequest, AutomationTarget, AutomationTrigger,\n };\n+ pub use fabro_environment::Environment;\n pub use fabro_model::{\n Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,\n Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed,\ndiff --git a/lib/crates/fabro-api/tests/environment_round_trip.rs b/lib/crates/fabro-api/tests/environment_round_trip.rs\nnew file mode 100644\nindex 000000000..362b78bbc\n--- /dev/null\n+++ b/lib/crates/fabro-api/tests/environment_round_trip.rs\n@@ -0,0 +1,88 @@\n+use fabro_api::types::{\n+ CreateEnvironmentRequest as ApiCreateEnvironmentRequest, Environment as ApiEnvironment,\n+ ReplaceEnvironmentRequest as ApiReplaceEnvironmentRequest,\n+};\n+use fabro_environment::Environment;\n+use serde_json::json;\n+\n+// Compile-time witness that the generated API response type resolves to the\n+// same type as the `fabro-environment` domain type via `with_replacement(...)`.\n+// Request types intentionally stay API-specific so REST Dockerfile sources can\n+// remain inline-only without changing workflow/settings schemas.\n+const _: fn(ApiEnvironment) -> Environment = |value| value;\n+\n+fn environment_settings_json() -> serde_json::Value {\n+ json!({\n+ \"provider\": \"docker\",\n+ \"image\": {\n+ \"docker\": null,\n+ \"dockerfile\": {\n+ \"type\": \"inline\",\n+ \"value\": \"FROM alpine\\n\"\n+ }\n+ },\n+ \"resources\": {\n+ \"cpu\": null,\n+ \"memory\": null,\n+ \"disk\": null\n+ },\n+ \"network\": {\n+ \"mode\": \"allow_all\",\n+ \"allow\": []\n+ },\n+ \"lifecycle\": {\n+ \"preserve\": false,\n+ \"stop_on_terminal\": true,\n+ \"auto_stop\": null\n+ },\n+ \"labels\": {},\n+ \"volumes\": [],\n+ \"env\": {}\n+ })\n+}\n+\n+#[test]\n+fn environment_response_round_trips_public_json_shape() {\n+ let mut value = environment_settings_json();\n+ value[\"id\"] = json!(\"docker-inline\");\n+ value[\"revision\"] = json!(\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\");\n+\n+ let api: ApiEnvironment = serde_json::from_value(value.clone()).unwrap();\n+ assert_eq!(serde_json::to_value(api).unwrap(), value);\n+}\n+\n+#[test]\n+fn create_environment_request_round_trips_inline_dockerfile_json_shape() {\n+ let mut value = environment_settings_json();\n+ value[\"id\"] = json!(\"docker-inline\");\n+\n+ let api: ApiCreateEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();\n+ assert_eq!(serde_json::to_value(api).unwrap(), value);\n+}\n+\n+#[test]\n+fn replace_environment_request_round_trips_inline_dockerfile_json_shape() {\n+ let value = environment_settings_json();\n+\n+ let api: ApiReplaceEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();\n+ assert_eq!(serde_json::to_value(api).unwrap(), value);\n+}\n+\n+#[test]\n+fn environment_request_schema_rejects_dockerfile_path_sources() {\n+ let mut value = environment_settings_json();\n+ value[\"id\"] = json!(\"docker-path\");\n+ value[\"image\"][\"dockerfile\"] = json!({\n+ \"type\": \"path\",\n+ \"path\": \"Dockerfile\"\n+ });\n+\n+ let err = serde_json::from_value::(value)\n+ .expect_err(\"generated REST request type should reject Dockerfile path sources\");\n+ assert!(\n+ err.to_string().contains(\"dockerfile\")\n+ || err.to_string().contains(\"type\")\n+ || err.to_string().contains(\"path\"),\n+ \"unexpected error: {err}\"\n+ );\n+}\ndiff --git a/lib/crates/fabro-environment/src/model.rs b/lib/crates/fabro-environment/src/model.rs\nindex 90e0f934a..2943ac538 100644\n--- a/lib/crates/fabro-environment/src/model.rs\n+++ b/lib/crates/fabro-environment/src/model.rs\n@@ -52,6 +52,7 @@ impl Environment {\n ) -> Result<(Self, Vec), EnvironmentStoreError> {\n let settings = inline_dense_dockerfile(settings, dockerfile_base_dir).await?;\n let persisted = environment_settings_to_layer(&settings);\n+ let settings = resolve_environment(&persisted)?;\n let bytes = canonical_bytes(&persisted).into_bytes();\n let revision = EnvironmentRevision::from_bytes(&bytes);\n Ok((\ndiff --git a/lib/crates/fabro-environment/src/store.rs b/lib/crates/fabro-environment/src/store.rs\nindex 172c518b1..b6265d7ed 100644\n--- a/lib/crates/fabro-environment/src/store.rs\n+++ b/lib/crates/fabro-environment/src/store.rs\n@@ -404,8 +404,8 @@ mod tests {\n use fabro_types::settings::InterpString;\n use fabro_types::settings::run::{\n DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,\n- EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,\n- EnvironmentSettings,\n+ EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider,\n+ EnvironmentResourcesSettings, EnvironmentSettings,\n };\n use tokio::fs;\n \n@@ -572,6 +572,28 @@ path = \"Dockerfile\"\n assert!(matches!(err, EnvironmentStoreError::AlreadyExists { .. }));\n }\n \n+ #[tokio::test]\n+ async fn create_invalid_settings_is_rejected() {\n+ let dir = tempfile::tempdir().unwrap();\n+ let store = EnvironmentStore::load_or_seed(dir.path().join(\"environments\")).unwrap();\n+ let mut settings = settings(EnvironmentProvider::Local);\n+ settings.network.mode = EnvironmentNetworkMode::Block;\n+\n+ let err = store\n+ .create(EnvironmentDraft {\n+ id: EnvironmentId::new(\"invalid\").unwrap(),\n+ settings,\n+ })\n+ .await\n+ .unwrap_err();\n+\n+ assert!(matches!(err, EnvironmentStoreError::Validation { .. }));\n+ assert!(\n+ err.to_string()\n+ .contains(\"local environments cannot enforce\")\n+ );\n+ }\n+\n #[tokio::test]\n async fn replace_stale_revision_is_rejected() {\n let dir = tempfile::tempdir().unwrap();\ndiff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs\nindex 8fafb6678..46a62c864 100644\n--- a/lib/crates/fabro-server/src/server.rs\n+++ b/lib/crates/fabro-server/src/server.rs\n@@ -1317,6 +1317,18 @@ impl AppState {\n .clone()\n }\n \n+ pub(crate) fn refresh_manifest_run_settings_from_environment_catalog(&self) {\n+ let manifest_run_defaults = self.manifest_run_defaults();\n+ let manifest_run_settings = resolve_manifest_run_settings_with_catalog(\n+ manifest_run_defaults.as_ref(),\n+ &self.environment_store,\n+ );\n+ *self\n+ .manifest_run_settings\n+ .write()\n+ .expect(\"manifest run settings lock poisoned\") = manifest_run_settings;\n+ }\n+\n fn http_client(&self) -> Result {\n match &self.http_client {\n Some(client) => Ok(client.clone()),\ndiff --git a/lib/crates/fabro-server/src/server/handler/environments.rs b/lib/crates/fabro-server/src/server/handler/environments.rs\nnew file mode 100644\nindex 000000000..147425800\n--- /dev/null\n+++ b/lib/crates/fabro-server/src/server/handler/environments.rs\n@@ -0,0 +1,315 @@\n+use std::collections::HashMap;\n+use std::sync::Arc;\n+\n+use axum::http::{HeaderMap, HeaderValue, header};\n+use fabro_environment::{\n+ Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError,\n+};\n+use fabro_types::settings::InterpString;\n+use fabro_types::settings::run::{\n+ DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,\n+ EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,\n+ EnvironmentSettings, EnvironmentVolumeSettings,\n+};\n+use serde::{Deserialize, Serialize};\n+\n+use super::super::{\n+ ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State,\n+ StatusCode, get,\n+};\n+\n+#[derive(Serialize)]\n+struct EnvironmentListResponse {\n+ data: Vec,\n+ meta: EnvironmentListMeta,\n+}\n+\n+#[derive(Serialize)]\n+struct EnvironmentListMeta {\n+ total: usize,\n+}\n+\n+#[derive(Deserialize)]\n+#[serde(deny_unknown_fields)]\n+struct CreateEnvironmentRequest {\n+ id: EnvironmentId,\n+ provider: EnvironmentProvider,\n+ image: ApiEnvironmentImageSettings,\n+ resources: EnvironmentResourcesSettings,\n+ network: EnvironmentNetworkSettings,\n+ lifecycle: EnvironmentLifecycleSettings,\n+ labels: HashMap,\n+ volumes: Vec,\n+ env: HashMap,\n+}\n+\n+#[derive(Deserialize)]\n+#[serde(deny_unknown_fields)]\n+struct ReplaceEnvironmentRequest {\n+ provider: EnvironmentProvider,\n+ image: ApiEnvironmentImageSettings,\n+ resources: EnvironmentResourcesSettings,\n+ network: EnvironmentNetworkSettings,\n+ lifecycle: EnvironmentLifecycleSettings,\n+ labels: HashMap,\n+ volumes: Vec,\n+ env: HashMap,\n+}\n+\n+struct ApiEnvironmentSettings {\n+ provider: EnvironmentProvider,\n+ image: ApiEnvironmentImageSettings,\n+ resources: EnvironmentResourcesSettings,\n+ network: EnvironmentNetworkSettings,\n+ lifecycle: EnvironmentLifecycleSettings,\n+ labels: HashMap,\n+ volumes: Vec,\n+ env: HashMap,\n+}\n+\n+#[derive(Deserialize)]\n+#[serde(deny_unknown_fields)]\n+struct ApiEnvironmentImageSettings {\n+ docker: Option,\n+ dockerfile: Option,\n+}\n+\n+#[derive(Deserialize)]\n+#[serde(tag = \"type\", rename_all = \"snake_case\", deny_unknown_fields)]\n+enum ApiDockerfileSource {\n+ Inline {\n+ value: String,\n+ },\n+ Path {\n+ #[serde(rename = \"path\")]\n+ _path: String,\n+ },\n+}\n+\n+impl CreateEnvironmentRequest {\n+ fn into_draft(self) -> Result {\n+ let settings = ApiEnvironmentSettings {\n+ provider: self.provider,\n+ image: self.image,\n+ resources: self.resources,\n+ network: self.network,\n+ lifecycle: self.lifecycle,\n+ labels: self.labels,\n+ volumes: self.volumes,\n+ env: self.env,\n+ };\n+ Ok(EnvironmentDraft {\n+ id: self.id,\n+ settings: settings.into_settings()?,\n+ })\n+ }\n+}\n+\n+impl ReplaceEnvironmentRequest {\n+ fn into_settings(self) -> Result {\n+ ApiEnvironmentSettings {\n+ provider: self.provider,\n+ image: self.image,\n+ resources: self.resources,\n+ network: self.network,\n+ lifecycle: self.lifecycle,\n+ labels: self.labels,\n+ volumes: self.volumes,\n+ env: self.env,\n+ }\n+ .into_settings()\n+ }\n+}\n+\n+impl ApiEnvironmentSettings {\n+ fn into_settings(self) -> Result {\n+ Ok(EnvironmentSettings {\n+ provider: self.provider,\n+ image: self.image.into_settings()?,\n+ resources: self.resources,\n+ network: self.network,\n+ lifecycle: self.lifecycle,\n+ labels: self.labels,\n+ volumes: self.volumes,\n+ env: self.env,\n+ })\n+ }\n+}\n+\n+impl ApiEnvironmentImageSettings {\n+ fn into_settings(self) -> Result {\n+ Ok(EnvironmentImageSettings {\n+ docker: self.docker,\n+ dockerfile: self\n+ .dockerfile\n+ .map(ApiDockerfileSource::into_settings)\n+ .transpose()?,\n+ })\n+ }\n+}\n+\n+impl ApiDockerfileSource {\n+ fn into_settings(self) -> Result {\n+ match self {\n+ Self::Inline { value } => Ok(DockerfileSource::Inline(value)),\n+ Self::Path { .. } => Err(ApiError::new(\n+ StatusCode::UNPROCESSABLE_ENTITY,\n+ \"Dockerfile path sources are not supported by the environments REST API; use inline Dockerfile content\",\n+ )),\n+ }\n+ }\n+}\n+\n+pub(super) fn routes() -> Router> {\n+ Router::new()\n+ .route(\n+ \"/environments\",\n+ get(list_environments).post(create_environment),\n+ )\n+ .route(\n+ \"/environments/{id}\",\n+ get(get_environment)\n+ .put(replace_environment)\n+ .delete(delete_environment),\n+ )\n+}\n+\n+async fn list_environments(_auth: RequiredUser, State(state): State>) -> Response {\n+ let data = state.environment_store().list();\n+ let total = data.len();\n+ (\n+ StatusCode::OK,\n+ Json(EnvironmentListResponse {\n+ data,\n+ meta: EnvironmentListMeta { total },\n+ }),\n+ )\n+ .into_response()\n+}\n+\n+async fn create_environment(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ Json(request): Json,\n+) -> Result {\n+ let environment = state\n+ .environment_store()\n+ .create(request.into_draft()?)\n+ .await?;\n+ state.refresh_manifest_run_settings_from_environment_catalog();\n+ Ok((StatusCode::CREATED, Json(environment)).into_response())\n+}\n+\n+async fn get_environment(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ Path(id): Path,\n+) -> Result {\n+ let id = parse_path_id(id)?;\n+ match state.environment_store().get(&id) {\n+ Some(environment) => Ok(environment_with_etag_response(StatusCode::OK, environment)),\n+ None => Err(ApiError::not_found(format!(\"environment not found: {id}\"))),\n+ }\n+}\n+\n+async fn replace_environment(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ headers: HeaderMap,\n+ Path(id): Path,\n+ Json(request): Json,\n+) -> Result {\n+ let id = parse_path_id(id)?;\n+ let expected = parse_required_if_match(&headers, &id)?;\n+ let environment = state\n+ .environment_store()\n+ .replace(&id, &expected, request.into_settings()?)\n+ .await?;\n+ state.refresh_manifest_run_settings_from_environment_catalog();\n+ Ok(environment_with_etag_response(StatusCode::OK, environment))\n+}\n+\n+async fn delete_environment(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ headers: HeaderMap,\n+ Path(id): Path,\n+) -> Result {\n+ let id = parse_path_id(id)?;\n+ let expected = parse_required_if_match(&headers, &id)?;\n+ state.environment_store().delete(&id, &expected).await?;\n+ state.refresh_manifest_run_settings_from_environment_catalog();\n+ Ok(StatusCode::NO_CONTENT.into_response())\n+}\n+\n+fn parse_path_id(id: String) -> Result {\n+ EnvironmentId::new(id)\n+ .map_err(|err| ApiError::bad_request(format!(\"invalid environment id: {err}\")))\n+}\n+\n+fn parse_required_if_match(\n+ headers: &HeaderMap,\n+ id: &EnvironmentId,\n+) -> Result {\n+ let Some(value) = headers.get(header::IF_MATCH) else {\n+ return Err(ApiError::new(\n+ StatusCode::PRECONDITION_REQUIRED,\n+ format!(\"If-Match header is required for environment: {id}\"),\n+ ));\n+ };\n+ let value = value\n+ .to_str()\n+ .map_err(|_| ApiError::bad_request(\"If-Match header must be visible ASCII\"))?;\n+ let value = unquote_etag(value.trim());\n+ value.parse::().map_err(|err| {\n+ ApiError::bad_request(format!(\"invalid If-Match environment revision: {err}\"))\n+ })\n+}\n+\n+fn unquote_etag(value: &str) -> &str {\n+ value\n+ .strip_prefix('\"')\n+ .and_then(|unquoted| unquoted.strip_suffix('\"'))\n+ .unwrap_or(value)\n+}\n+\n+fn environment_with_etag_response(status: StatusCode, environment: Environment) -> Response {\n+ let etag = HeaderValue::from_str(&format!(\"\\\"{}\\\"\", environment.revision))\n+ .expect(\"environment revisions are valid ETag header values\");\n+ let mut response = (status, Json(environment)).into_response();\n+ response.headers_mut().insert(header::ETAG, etag);\n+ response\n+}\n+\n+impl From for ApiError {\n+ fn from(err: EnvironmentStoreError) -> Self {\n+ match err {\n+ EnvironmentStoreError::NotFound { id } => {\n+ Self::not_found(format!(\"environment not found: {id}\"))\n+ }\n+ EnvironmentStoreError::AlreadyExists { id } => Self::new(\n+ StatusCode::CONFLICT,\n+ format!(\"environment already exists: {id}\"),\n+ ),\n+ EnvironmentStoreError::StaleRevision { id, .. } => Self::new(\n+ StatusCode::CONFLICT,\n+ format!(\"environment revision is stale: {id}\"),\n+ ),\n+ EnvironmentStoreError::Protected { id } => Self::new(\n+ StatusCode::CONFLICT,\n+ format!(\"environment is protected and cannot be deleted: {id}\"),\n+ ),\n+ EnvironmentStoreError::Validation { source } => {\n+ Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())\n+ }\n+ EnvironmentStoreError::InvalidFilename { .. }\n+ | EnvironmentStoreError::Parse { .. }\n+ | EnvironmentStoreError::InvalidUtf8 { .. }\n+ | EnvironmentStoreError::Serialize { .. }\n+ | EnvironmentStoreError::Io { .. } => Self::new(\n+ StatusCode::INTERNAL_SERVER_ERROR,\n+ \"environment store operation failed\",\n+ ),\n+ }\n+ }\n+}\ndiff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs\nindex 33c6aa8eb..ff3c3648a 100644\n--- a/lib/crates/fabro-server/src/server/handler/mod.rs\n+++ b/lib/crates/fabro-server/src/server/handler/mod.rs\n@@ -9,6 +9,7 @@ mod artifacts;\n mod automations;\n mod billing;\n mod completions;\n+mod environments;\n pub(in crate::server) mod events;\n pub(in crate::server) mod graph;\n mod lifecycle;\n@@ -158,6 +159,7 @@ pub(super) fn real_routes() -> Router> {\n .merge(pull_requests::routes())\n .merge(artifacts::routes())\n .merge(automations::routes())\n+ .merge(environments::routes())\n .merge(sandbox::routes())\n .merge(sandboxes::routes())\n .merge(lifecycle::routes())\ndiff --git a/lib/crates/fabro-server/tests/it/api/environments.rs b/lib/crates/fabro-server/tests/it/api/environments.rs\nnew file mode 100644\nindex 000000000..fb0c9fbfa\n--- /dev/null\n+++ b/lib/crates/fabro-server/tests/it/api/environments.rs\n@@ -0,0 +1,602 @@\n+use std::path::{Path, PathBuf};\n+\n+use axum::body::Body;\n+use axum::http::{Method, Request, StatusCode, header};\n+use fabro_config::{RunEnvironmentLayer, RunLayer};\n+use fabro_server::server::build_router;\n+use fabro_server::test_support::{\n+ TestAppStateBuilder, build_test_router, default_test_server_settings, test_auth_mode,\n+};\n+use serde_json::{Value, json};\n+use tower::ServiceExt;\n+\n+use crate::helpers::{api, checked_response, response_json, response_status};\n+\n+fn environment_settings(provider: &str) -> Value {\n+ json!({\n+ \"provider\": provider,\n+ \"image\": {\n+ \"docker\": if provider == \"docker\" { json!(\"alpine:3.20\") } else { Value::Null },\n+ \"dockerfile\": null\n+ },\n+ \"resources\": {\n+ \"cpu\": null,\n+ \"memory\": null,\n+ \"disk\": null\n+ },\n+ \"network\": {\n+ \"mode\": \"allow_all\",\n+ \"allow\": []\n+ },\n+ \"lifecycle\": {\n+ \"preserve\": false,\n+ \"stop_on_terminal\": true,\n+ \"auto_stop\": null\n+ },\n+ \"labels\": {},\n+ \"volumes\": [],\n+ \"env\": {}\n+ })\n+}\n+\n+fn environment_body(id: &str, provider: &str) -> Value {\n+ let mut body = environment_settings(provider);\n+ body[\"id\"] = json!(id);\n+ body\n+}\n+\n+fn environment_app() -> (axum::Router, tempfile::TempDir, PathBuf) {\n+ let temp_dir = tempfile::tempdir().expect(\"environment test tempdir should be created\");\n+ let active_config_path = temp_dir.path().join(\"settings.toml\");\n+ let environment_dir = temp_dir.path().join(\"environments\");\n+ let state = TestAppStateBuilder::new()\n+ .active_config_path(active_config_path)\n+ .build();\n+ (build_test_router(state), temp_dir, environment_dir)\n+}\n+\n+fn environment_app_with_default_environment(\n+ environment_id: &str,\n+) -> (axum::Router, tempfile::TempDir) {\n+ let temp_dir = tempfile::tempdir().expect(\"environment test tempdir should be created\");\n+ let active_config_path = temp_dir.path().join(\"settings.toml\");\n+ let manifest_run_defaults = RunLayer {\n+ environment: Some(RunEnvironmentLayer {\n+ id: Some(environment_id.to_string()),\n+ ..RunEnvironmentLayer::default()\n+ }),\n+ ..RunLayer::default()\n+ };\n+ let state = TestAppStateBuilder::new()\n+ .runtime_settings(default_test_server_settings(), manifest_run_defaults)\n+ .active_config_path(active_config_path)\n+ .build();\n+ (build_test_router(state), temp_dir)\n+}\n+\n+fn json_request(method: Method, path: &str, body: &Value) -> Request {\n+ Request::builder()\n+ .method(method)\n+ .uri(api(path))\n+ .header(header::CONTENT_TYPE, \"application/json\")\n+ .body(Body::from(\n+ serde_json::to_vec(body).expect(\"environment fixture should serialize\"),\n+ ))\n+ .expect(\"environment JSON request should build\")\n+}\n+\n+fn empty_request(method: Method, path: &str) -> Request {\n+ Request::builder()\n+ .method(method)\n+ .uri(api(path))\n+ .body(Body::empty())\n+ .expect(\"environment request should build\")\n+}\n+\n+fn request_with_if_match(\n+ method: Method,\n+ path: &str,\n+ revision: &str,\n+ body: Option,\n+) -> Request {\n+ let mut builder = Request::builder()\n+ .method(method)\n+ .uri(api(path))\n+ .header(header::IF_MATCH, revision);\n+ let body = match body {\n+ Some(value) => {\n+ builder = builder.header(header::CONTENT_TYPE, \"application/json\");\n+ Body::from(serde_json::to_vec(&value).expect(\"environment fixture should serialize\"))\n+ }\n+ None => Body::empty(),\n+ };\n+ builder\n+ .body(body)\n+ .expect(\"environment If-Match request should build\")\n+}\n+\n+async fn create_environment(app: &axum::Router, id: &str, provider: &str) -> Value {\n+ create_environment_with_body(app, &environment_body(id, provider)).await\n+}\n+\n+async fn create_environment_with_body(app: &axum::Router, body: &Value) -> Value {\n+ let response = app\n+ .clone()\n+ .oneshot(json_request(Method::POST, \"/environments\", body))\n+ .await\n+ .expect(\"create environment should respond\");\n+ response_json(response, StatusCode::CREATED, \"POST /api/v1/environments\").await\n+}\n+\n+fn revision_from(body: &Value) -> &str {\n+ body[\"revision\"]\n+ .as_str()\n+ .expect(\"environment response should include a revision\")\n+}\n+\n+async fn persisted_environment_toml(environment_dir: &Path, id: &str) -> toml::Value {\n+ let persisted = tokio::fs::read_to_string(environment_dir.join(format!(\"{id}.toml\")))\n+ .await\n+ .expect(\"persisted environment TOML should be readable\");\n+ toml::from_str(&persisted).expect(\"persisted environment TOML should parse\")\n+}\n+\n+async fn system_info(app: &axum::Router) -> Value {\n+ let response = app\n+ .clone()\n+ .oneshot(empty_request(Method::GET, \"/system/info\"))\n+ .await\n+ .expect(\"system info should respond\");\n+ response_json(response, StatusCode::OK, \"GET /api/v1/system/info\").await\n+}\n+\n+#[tokio::test]\n+async fn list_environments_returns_seeded_catalog_sorted_by_id() {\n+ let (app, _temp_dir, _environment_dir) = environment_app();\n+\n+ let response = app\n+ .oneshot(empty_request(Method::GET, \"/environments\"))\n+ .await\n+ .expect(\"list environments should respond\");\n+ let body = response_json(response, StatusCode::OK, \"GET /api/v1/environments\").await;\n+\n+ assert_eq!(body[\"meta\"][\"total\"], 4);\n+ assert_eq!(\n+ body[\"data\"]\n+ .as_array()\n+ .expect(\"environment list data should be an array\")\n+ .iter()\n+ .map(|environment| environment[\"id\"]\n+ .as_str()\n+ .expect(\"environment should have id\"))\n+ .collect::>(),\n+ vec![\"daytona\", \"default\", \"docker\", \"local\"]\n+ );\n+}\n+\n+#[tokio::test]\n+async fn create_environment_persists_sibling_toml_and_is_visible() {\n+ let (app, _temp_dir, environment_dir) = environment_app();\n+\n+ let created = create_environment(&app, \"custom-env\", \"docker\").await;\n+\n+ assert_eq!(created[\"id\"], \"custom-env\");\n+ assert_eq!(created[\"provider\"], \"docker\");\n+ assert!(environment_dir.join(\"custom-env.toml\").exists());\n+\n+ let retrieved = app\n+ .clone()\n+ .oneshot(empty_request(Method::GET, \"/environments/custom-env\"))\n+ .await\n+ .expect(\"get environment should respond\");\n+ let retrieved = response_json(\n+ retrieved,\n+ StatusCode::OK,\n+ \"GET /api/v1/environments/custom-env\",\n+ )\n+ .await;\n+ assert_eq!(retrieved[\"id\"], \"custom-env\");\n+\n+ let list = app\n+ .oneshot(empty_request(Method::GET, \"/environments\"))\n+ .await\n+ .expect(\"list environments should respond\");\n+ let list = response_json(list, StatusCode::OK, \"GET /api/v1/environments\").await;\n+ assert_eq!(list[\"meta\"][\"total\"], 5);\n+ assert!(\n+ list[\"data\"]\n+ .as_array()\n+ .expect(\"environment list data should be an array\")\n+ .iter()\n+ .any(|environment| environment[\"id\"] == \"custom-env\")\n+ );\n+\n+ let persisted = persisted_environment_toml(&environment_dir, \"custom-env\").await;\n+ assert_eq!(\n+ persisted.get(\"provider\").and_then(toml::Value::as_str),\n+ Some(\"docker\")\n+ );\n+ assert!(persisted.get(\"id\").is_none());\n+ assert!(persisted.get(\"revision\").is_none());\n+}\n+\n+#[tokio::test]\n+async fn get_environment_returns_current_etag() {\n+ let (app, _temp_dir, _environment_dir) = environment_app();\n+ let created = create_environment(&app, \"etag-env\", \"local\").await;\n+ let revision = revision_from(&created);\n+\n+ let response = app\n+ .oneshot(empty_request(Method::GET, \"/environments/etag-env\"))\n+ .await\n+ .expect(\"get environment should respond\");\n+ let response = checked_response(\n+ response,\n+ StatusCode::OK,\n+ \"GET /api/v1/environments/etag-env\",\n+ )\n+ .await;\n+\n+ assert_eq!(\n+ response\n+ .headers()\n+ .get(header::ETAG)\n+ .expect(\"GET environment should include ETag\"),\n+ &format!(\"\\\"{revision}\\\"\")\n+ );\n+ let body = crate::helpers::body_json(response.into_body()).await;\n+ assert_eq!(body[\"revision\"], revision);\n+}\n+\n+#[tokio::test]\n+async fn replace_environment_updates_file_and_returns_new_etag() {\n+ let (app, _temp_dir, environment_dir) = environment_app();\n+ let created = create_environment(&app, \"replace-env\", \"docker\").await;\n+ let revision = revision_from(&created);\n+ let mut replacement = environment_settings(\"local\");\n+ replacement[\"labels\"] = json!({ \"tier\": \"dev\" });\n+\n+ let response = app\n+ .oneshot(request_with_if_match(\n+ Method::PUT,\n+ \"/environments/replace-env\",\n+ revision,\n+ Some(replacement),\n+ ))\n+ .await\n+ .expect(\"replace environment should respond\");\n+ let response = checked_response(\n+ response,\n+ StatusCode::OK,\n+ \"PUT /api/v1/environments/replace-env\",\n+ )\n+ .await;\n+ let etag = response\n+ .headers()\n+ .get(header::ETAG)\n+ .expect(\"PUT environment should include ETag\")\n+ .to_str()\n+ .expect(\"ETag should be ASCII\")\n+ .to_string();\n+ let body = crate::helpers::body_json(response.into_body()).await;\n+\n+ assert_eq!(body[\"provider\"], \"local\");\n+ assert_eq!(body[\"labels\"][\"tier\"], \"dev\");\n+ assert_ne!(body[\"revision\"], revision);\n+ assert_eq!(etag, format!(\"\\\"{}\\\"\", revision_from(&body)));\n+ let persisted = persisted_environment_toml(&environment_dir, \"replace-env\").await;\n+ assert_eq!(\n+ persisted\n+ .get(\"labels\")\n+ .and_then(toml::Value::as_table)\n+ .and_then(|labels| labels.get(\"tier\"))\n+ .and_then(toml::Value::as_str),\n+ Some(\"dev\")\n+ );\n+}\n+\n+#[tokio::test]\n+async fn replace_and_delete_environment_require_if_match() {\n+ let (app, _temp_dir, _environment_dir) = environment_app();\n+ create_environment(&app, \"match-env\", \"local\").await;\n+\n+ let replace_response = app\n+ .clone()\n+ .oneshot(json_request(\n+ Method::PUT,\n+ \"/environments/match-env\",\n+ &environment_settings(\"docker\"),\n+ ))\n+ .await\n+ .expect(\"replace without If-Match should respond\");\n+ response_status(\n+ replace_response,\n+ StatusCode::PRECONDITION_REQUIRED,\n+ \"PUT /api/v1/environments/match-env without If-Match\",\n+ )\n+ .await;\n+\n+ let delete_response = app\n+ .oneshot(empty_request(Method::DELETE, \"/environments/match-env\"))\n+ .await\n+ .expect(\"delete without If-Match should respond\");\n+ response_status(\n+ delete_response,\n+ StatusCode::PRECONDITION_REQUIRED,\n+ \"DELETE /api/v1/environments/match-env without If-Match\",\n+ )\n+ .await;\n+}\n+\n+#[tokio::test]\n+async fn stale_environment_replace_and_delete_return_conflict() {\n+ let (app, _temp_dir, _environment_dir) = environment_app();\n+ let created = create_environment(&app, \"stale-env\", \"docker\").await;\n+ let stale_revision = revision_from(&created).to_string();\n+\n+ let replaced = app\n+ .clone()\n+ .oneshot(request_with_if_match(\n+ Method::PUT,\n+ \"/environments/stale-env\",\n+ &stale_revision,\n+ Some(environment_settings(\"local\")),\n+ ))\n+ .await\n+ .expect(\"first replace should respond\");\n+ response_status(\n+ replaced,\n+ StatusCode::OK,\n+ \"PUT /api/v1/environments/stale-env first replace\",\n+ )\n+ .await;\n+\n+ let stale_replace = app\n+ .clone()\n+ .oneshot(request_with_if_match(\n+ Method::PUT,\n+ \"/environments/stale-env\",\n+ &stale_revision,\n+ Some(environment_settings(\"docker\")),\n+ ))\n+ .await\n+ .expect(\"stale replace should respond\");\n+ response_status(\n+ stale_replace,\n+ StatusCode::CONFLICT,\n+ \"PUT /api/v1/environments/stale-env stale\",\n+ )\n+ .await;\n+\n+ let stale_delete = app\n+ .oneshot(request_with_if_match(\n+ Method::DELETE,\n+ \"/environments/stale-env\",\n+ &stale_revision,\n+ None,\n+ ))\n+ .await\n+ .expect(\"stale delete should respond\");\n+ response_status(\n+ stale_delete,\n+ StatusCode::CONFLICT,\n+ \"DELETE /api/v1/environments/stale-env stale\",\n+ )\n+ .await;\n+}\n+\n+#[tokio::test]\n+async fn duplicate_environment_create_returns_conflict() {\n+ let (app, _temp_dir, _environment_dir) = environment_app();\n+ create_environment(&app, \"duplicate-env\", \"local\").await;\n+\n+ let response = app\n+ .oneshot(json_request(\n+ Method::POST,\n+ \"/environments\",\n+ &environment_body(\"duplicate-env\", \"docker\"),\n+ ))\n+ .await\n+ .expect(\"duplicate create should respond\");\n+\n+ response_status(\n+ response,\n+ StatusCode::CONFLICT,\n+ \"POST /api/v1/environments duplicate\",\n+ )\n+ .await;\n+}\n+\n+#[tokio::test]\n+async fn invalid_environment_id_and_if_match_return_bad_request() {\n+ let (app, _temp_dir, _environment_dir) = environment_app();\n+\n+ let invalid_id = app\n+ .clone()\n+ .oneshot(empty_request(Method::GET, \"/environments/Bad!\"))\n+ .await\n+ .expect(\"invalid id request should respond\");\n+ response_status(\n+ invalid_id,\n+ StatusCode::BAD_REQUEST,\n+ \"GET /api/v1/environments/Bad!\",\n+ )\n+ .await;\n+\n+ create_environment(&app, \"header-env\", \"local\").await;\n+ let invalid_header = app\n+ .oneshot(request_with_if_match(\n+ Method::PUT,\n+ \"/environments/header-env\",\n+ \"not-a-revision\",\n+ Some(environment_settings(\"docker\")),\n+ ))\n+ .await\n+ .expect(\"invalid If-Match request should respond\");\n+ response_status(\n+ invalid_header,\n+ StatusCode::BAD_REQUEST,\n+ \"PUT /api/v1/environments/header-env invalid If-Match\",\n+ )\n+ .await;\n+}\n+\n+#[tokio::test]\n+async fn invalid_environment_settings_return_unprocessable_entity() {\n+ let (app, _temp_dir, _environment_dir) = environment_app();\n+ let mut body = environment_body(\"invalid-env\", \"local\");\n+ body[\"network\"][\"mode\"] = json!(\"block\");\n+\n+ let response = app\n+ .oneshot(json_request(Method::POST, \"/environments\", &body))\n+ .await\n+ .expect(\"invalid environment create should respond\");\n+\n+ response_status(\n+ response,\n+ StatusCode::UNPROCESSABLE_ENTITY,\n+ \"POST /api/v1/environments invalid settings\",\n+ )\n+ .await;\n+}\n+\n+#[tokio::test]\n+async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_contents() {\n+ let (app, temp_dir, environment_dir) = environment_app();\n+ tokio::fs::write(\n+ temp_dir.path().join(\"Dockerfile\"),\n+ \"FROM private.example/secret\\n\",\n+ )\n+ .await\n+ .expect(\"secret Dockerfile fixture should be written\");\n+ let mut body = environment_body(\"path-env\", \"docker\");\n+ body[\"image\"][\"docker\"] = Value::Null;\n+ body[\"image\"][\"dockerfile\"] = json!({\n+ \"type\": \"path\",\n+ \"path\": \"Dockerfile\"\n+ });\n+\n+ let response = app\n+ .clone()\n+ .oneshot(json_request(Method::POST, \"/environments\", &body))\n+ .await\n+ .expect(\"path Dockerfile create should respond\");\n+ let error = response_json(\n+ response,\n+ StatusCode::UNPROCESSABLE_ENTITY,\n+ \"POST /api/v1/environments Dockerfile path\",\n+ )\n+ .await;\n+\n+ assert!(!environment_dir.join(\"path-env.toml\").exists());\n+ assert!(\n+ !serde_json::to_string(&error)\n+ .expect(\"error body should serialize\")\n+ .contains(\"private.example/secret\")\n+ );\n+ let list = app\n+ .oneshot(empty_request(Method::GET, \"/environments\"))\n+ .await\n+ .expect(\"list environments should respond\");\n+ let list = response_json(list, StatusCode::OK, \"GET /api/v1/environments\").await;\n+ assert!(\n+ !list[\"data\"]\n+ .as_array()\n+ .expect(\"environment list data should be an array\")\n+ .iter()\n+ .any(|environment| environment[\"id\"] == \"path-env\")\n+ );\n+}\n+\n+#[tokio::test]\n+async fn delete_environment_removes_non_default_and_default_is_protected() {\n+ let (app, _temp_dir, environment_dir) = environment_app();\n+ let created = create_environment(&app, \"delete-env\", \"local\").await;\n+ let revision = revision_from(&created);\n+\n+ let response = app\n+ .clone()\n+ .oneshot(request_with_if_match(\n+ Method::DELETE,\n+ \"/environments/delete-env\",\n+ &format!(\"\\\"{revision}\\\"\"),\n+ None,\n+ ))\n+ .await\n+ .expect(\"delete environment should respond\");\n+ response_status(\n+ response,\n+ StatusCode::NO_CONTENT,\n+ \"DELETE /api/v1/environments/delete-env\",\n+ )\n+ .await;\n+\n+ assert!(!environment_dir.join(\"delete-env.toml\").exists());\n+ let missing = app\n+ .clone()\n+ .oneshot(empty_request(Method::GET, \"/environments/delete-env\"))\n+ .await\n+ .expect(\"get deleted environment should respond\");\n+ response_status(\n+ missing,\n+ StatusCode::NOT_FOUND,\n+ \"GET /api/v1/environments/delete-env after delete\",\n+ )\n+ .await;\n+\n+ let default = app\n+ .clone()\n+ .oneshot(empty_request(Method::GET, \"/environments/default\"))\n+ .await\n+ .expect(\"get default environment should respond\");\n+ let default = response_json(default, StatusCode::OK, \"GET /api/v1/environments/default\").await;\n+ let protected = app\n+ .oneshot(request_with_if_match(\n+ Method::DELETE,\n+ \"/environments/default\",\n+ revision_from(&default),\n+ None,\n+ ))\n+ .await\n+ .expect(\"delete default environment should respond\");\n+ response_status(\n+ protected,\n+ StatusCode::CONFLICT,\n+ \"DELETE /api/v1/environments/default\",\n+ )\n+ .await;\n+}\n+\n+#[tokio::test]\n+async fn environment_routes_require_authenticated_user() {\n+ let temp_dir = tempfile::tempdir().expect(\"environment test tempdir should be created\");\n+ let state = TestAppStateBuilder::new()\n+ .active_config_path(temp_dir.path().join(\"settings.toml\"))\n+ .build();\n+ let app = build_router(state, test_auth_mode());\n+\n+ let response = app\n+ .oneshot(empty_request(Method::GET, \"/environments\"))\n+ .await\n+ .expect(\"unauthenticated environment list should respond\");\n+\n+ response_status(\n+ response,\n+ StatusCode::UNAUTHORIZED,\n+ \"GET /api/v1/environments without auth\",\n+ )\n+ .await;\n+}\n+\n+#[tokio::test]\n+async fn create_environment_refreshes_cached_manifest_run_settings() {\n+ let (app, _temp_dir) = environment_app_with_default_environment(\"api-default\");\n+\n+ let before = system_info(&app).await;\n+ assert_eq!(before[\"sandbox_provider\"], \"local\");\n+\n+ create_environment(&app, \"api-default\", \"daytona\").await;\n+\n+ let after = system_info(&app).await;\n+ assert_eq!(after[\"sandbox_provider\"], \"daytona\");\n+}\ndiff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs\nindex 843501072..98bf2a4f8 100644\n--- a/lib/crates/fabro-server/tests/it/api/mod.rs\n+++ b/lib/crates/fabro-server/tests/it/api/mod.rs\n@@ -2,6 +2,7 @@ mod auth_sessions;\n mod automations;\n mod cli_auth_token;\n mod docs;\n+mod environments;\n mod events;\n mod install;\n mod install_openai_compatible;\ndiff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs\nindex fdfa8d894..c586b8b6e 100644\n--- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs\n+++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs\n@@ -141,6 +141,54 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() {\n );\n }\n \n+#[test]\n+fn environment_spec_and_sdk_expose_crud_without_dockerfile_paths() {\n+ let spec = load_spec();\n+ let paths = spec\n+ .get(\"paths\")\n+ .and_then(Value::as_mapping)\n+ .expect(\"spec is missing `paths`\");\n+ for path in [\"/api/v1/environments\", \"/api/v1/environments/{id}\"] {\n+ assert!(\n+ paths.contains_key(Value::String(path.to_string())),\n+ \"OpenAPI spec should expose {path}\"\n+ );\n+ }\n+\n+ let generated_api = read_repo_file(\"lib/packages/fabro-api-client/src/api/environments-api.ts\");\n+ assert!(\n+ generated_api.contains(\"export class EnvironmentsApi\"),\n+ \"generated TypeScript client should expose EnvironmentsApi\"\n+ );\n+ for operation in [\n+ \"createEnvironment\",\n+ \"deleteEnvironment\",\n+ \"listEnvironments\",\n+ \"replaceEnvironment\",\n+ \"retrieveEnvironment\",\n+ ] {\n+ assert!(\n+ generated_api.contains(operation),\n+ \"generated EnvironmentsApi should expose {operation}\"\n+ );\n+ }\n+\n+ let generated_image = read_repo_file(\n+ \"lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts\",\n+ );\n+ assert!(\n+ !generated_image.contains(\"DockerfileSourcePath\") && !generated_image.contains(\"'path'\"),\n+ \"generated REST environment image model should not expose Dockerfile path sources\"\n+ );\n+\n+ let workflow_dockerfile =\n+ read_repo_file(\"lib/packages/fabro-api-client/src/models/dockerfile-source.ts\");\n+ assert!(\n+ workflow_dockerfile.contains(\"DockerfileSourcePath\"),\n+ \"workflow/settings Dockerfile schema should keep exposing path sources\"\n+ );\n+}\n+\n #[tokio::test]\n async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() {\n let secret = \"test-webhook-secret\";\ndiff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\nindex 513fea0b3..5e97116d9 100644\n--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n+++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n@@ -4,6 +4,7 @@ api/automations-api.ts\n api/billing-api.ts\n api/completions-api.ts\n api/discovery-api.ts\n+api/environments-api.ts\n api/human-in-the-loop-api.ts\n api/insights-api.ts\n api/install-api.ts\n@@ -94,6 +95,7 @@ models/completion-usage.ts\n models/conclusion.ts\n models/create-automation-request.ts\n models/create-completion-request.ts\n+models/create-environment-request.ts\n models/create-run-pull-request-request.ts\n models/create-run-session-request.ts\n models/create-secret-request.ts\n@@ -118,14 +120,19 @@ models/disk-usage-summary-row.ts\n models/dockerfile-source-inline.ts\n models/dockerfile-source-path.ts\n models/dockerfile-source.ts\n+models/environment-api-dockerfile-source-inline.ts\n+models/environment-api-image-settings.ts\n models/environment-image-settings.ts\n models/environment-lifecycle-settings.ts\n+models/environment-list-meta.ts\n+models/environment-list-response.ts\n models/environment-network-mode.ts\n models/environment-network-settings.ts\n models/environment-provider.ts\n models/environment-resources-settings.ts\n models/environment-settings.ts\n models/environment-volume-settings.ts\n+models/environment.ts\n models/error-response-entry.ts\n models/error-response.ts\n models/event-envelope.ts\n@@ -300,6 +307,7 @@ models/render-workflow-graph-direction.ts\n models/render-workflow-graph-format.ts\n models/render-workflow-graph-request.ts\n models/replace-automation-request.ts\n+models/replace-environment-request.ts\n models/repo-check-response-permissions.ts\n models/repo-check-response.ts\n models/repository-ref.ts\ndiff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts\nindex 608a29c63..3dd9c3391 100644\n--- a/lib/packages/fabro-api-client/src/api.ts\n+++ b/lib/packages/fabro-api-client/src/api.ts\n@@ -19,6 +19,7 @@ export * from './api/automations-api';\n export * from './api/billing-api';\n export * from './api/completions-api';\n export * from './api/discovery-api';\n+export * from './api/environments-api';\n export * from './api/human-in-the-loop-api';\n export * from './api/insights-api';\n export * from './api/install-api';\ndiff --git a/lib/packages/fabro-api-client/src/api/environments-api.ts b/lib/packages/fabro-api-client/src/api/environments-api.ts\nnew file mode 100644\nindex 000000000..6fbcd086e\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/api/environments-api.ts\n@@ -0,0 +1,453 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+import type { Configuration } from '../configuration';\n+import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';\n+import globalAxios from 'axios';\n+// Some imports not used depending on template conditions\n+// @ts-ignore\n+import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';\n+// @ts-ignore\n+import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';\n+// @ts-ignore\n+import type { CreateEnvironmentRequest } from '../models';\n+// @ts-ignore\n+import type { Environment } from '../models';\n+// @ts-ignore\n+import type { EnvironmentListResponse } from '../models';\n+// @ts-ignore\n+import type { ErrorResponse } from '../models';\n+// @ts-ignore\n+import type { ReplaceEnvironmentRequest } from '../models';\n+/**\n+ * EnvironmentsApi - axios parameter creator\n+ */\n+export const EnvironmentsApiAxiosParamCreator = function (configuration?: Configuration) {\n+ return {\n+ /**\n+ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.\n+ * @summary Create environment\n+ * @param {CreateEnvironmentRequest} createEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ createEnvironment: async (createEnvironmentRequest: CreateEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'createEnvironmentRequest' is not null or undefined\n+ assertParamExists('createEnvironment', 'createEnvironmentRequest', createEnvironmentRequest)\n+ const localVarPath = `/api/v1/environments`;\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Content-Type'] = 'application/json';\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+ localVarRequestOptions.data = serializeDataIfNeeded(createEnvironmentRequest, localVarRequestOptions, configuration)\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n+ /**\n+ * Deletes a non-default environment definition when `If-Match` matches the current environment revision.\n+ * @summary Delete environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ deleteEnvironment: async (id: string, ifMatch: string, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'id' is not null or undefined\n+ assertParamExists('deleteEnvironment', 'id', id)\n+ // verify required parameter 'ifMatch' is not null or undefined\n+ assertParamExists('deleteEnvironment', 'ifMatch', ifMatch)\n+ const localVarPath = `/api/v1/environments/{id}`\n+ .replace(`{${\"id\"}}`, encodeURIComponent(String(id)));\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ if (ifMatch != null) {\n+ localVarHeaderParameter['If-Match'] = String(ifMatch);\n+ }\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n+ /**\n+ * Returns all server-managed environment definitions, sorted by id.\n+ * @summary List environments\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ listEnvironments: async (options: RawAxiosRequestConfig = {}): Promise => {\n+ const localVarPath = `/api/v1/environments`;\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n+ /**\n+ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.\n+ * @summary Replace environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ replaceEnvironment: async (id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'id' is not null or undefined\n+ assertParamExists('replaceEnvironment', 'id', id)\n+ // verify required parameter 'ifMatch' is not null or undefined\n+ assertParamExists('replaceEnvironment', 'ifMatch', ifMatch)\n+ // verify required parameter 'replaceEnvironmentRequest' is not null or undefined\n+ assertParamExists('replaceEnvironment', 'replaceEnvironmentRequest', replaceEnvironmentRequest)\n+ const localVarPath = `/api/v1/environments/{id}`\n+ .replace(`{${\"id\"}}`, encodeURIComponent(String(id)));\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Content-Type'] = 'application/json';\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ if (ifMatch != null) {\n+ localVarHeaderParameter['If-Match'] = String(ifMatch);\n+ }\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+ localVarRequestOptions.data = serializeDataIfNeeded(replaceEnvironmentRequest, localVarRequestOptions, configuration)\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n+ /**\n+ * Returns one server-managed environment definition by id.\n+ * @summary Retrieve environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ retrieveEnvironment: async (id: string, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'id' is not null or undefined\n+ assertParamExists('retrieveEnvironment', 'id', id)\n+ const localVarPath = `/api/v1/environments/{id}`\n+ .replace(`{${\"id\"}}`, encodeURIComponent(String(id)));\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n+ }\n+};\n+\n+/**\n+ * EnvironmentsApi - functional programming interface\n+ */\n+export const EnvironmentsApiFp = function(configuration?: Configuration) {\n+ const localVarAxiosParamCreator = EnvironmentsApiAxiosParamCreator(configuration)\n+ return {\n+ /**\n+ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.\n+ * @summary Create environment\n+ * @param {CreateEnvironmentRequest} createEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.createEnvironment(createEnvironmentRequest, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.createEnvironment']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n+ /**\n+ * Deletes a non-default environment definition when `If-Match` matches the current environment revision.\n+ * @summary Delete environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.deleteEnvironment(id, ifMatch, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.deleteEnvironment']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n+ /**\n+ * Returns all server-managed environment definitions, sorted by id.\n+ * @summary List environments\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async listEnvironments(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.listEnvironments(options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.listEnvironments']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n+ /**\n+ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.\n+ * @summary Replace environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.replaceEnvironment']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n+ /**\n+ * Returns one server-managed environment definition by id.\n+ * @summary Retrieve environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveEnvironment(id, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.retrieveEnvironment']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n+ }\n+};\n+\n+/**\n+ * EnvironmentsApi - factory interface\n+ */\n+export const EnvironmentsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {\n+ const localVarFp = EnvironmentsApiFp(configuration)\n+ return {\n+ /**\n+ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.\n+ * @summary Create environment\n+ * @param {CreateEnvironmentRequest} createEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.createEnvironment(createEnvironmentRequest, options).then((request) => request(axios, basePath));\n+ },\n+ /**\n+ * Deletes a non-default environment definition when `If-Match` matches the current environment revision.\n+ * @summary Delete environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.deleteEnvironment(id, ifMatch, options).then((request) => request(axios, basePath));\n+ },\n+ /**\n+ * Returns all server-managed environment definitions, sorted by id.\n+ * @summary List environments\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ listEnvironments(options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.listEnvironments(options).then((request) => request(axios, basePath));\n+ },\n+ /**\n+ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.\n+ * @summary Replace environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(axios, basePath));\n+ },\n+ /**\n+ * Returns one server-managed environment definition by id.\n+ * @summary Retrieve environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.retrieveEnvironment(id, options).then((request) => request(axios, basePath));\n+ },\n+ };\n+};\n+\n+/**\n+ * EnvironmentsApi - object-oriented interface\n+ */\n+export class EnvironmentsApi extends BaseAPI {\n+ /**\n+ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.\n+ * @summary Create environment\n+ * @param {CreateEnvironmentRequest} createEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig) {\n+ return EnvironmentsApiFp(this.configuration).createEnvironment(createEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n+ /**\n+ * Deletes a non-default environment definition when `If-Match` matches the current environment revision.\n+ * @summary Delete environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig) {\n+ return EnvironmentsApiFp(this.configuration).deleteEnvironment(id, ifMatch, options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n+ /**\n+ * Returns all server-managed environment definitions, sorted by id.\n+ * @summary List environments\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public listEnvironments(options?: RawAxiosRequestConfig) {\n+ return EnvironmentsApiFp(this.configuration).listEnvironments(options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n+ /**\n+ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.\n+ * @summary Replace environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header.\n+ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig) {\n+ return EnvironmentsApiFp(this.configuration).replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n+ /**\n+ * Returns one server-managed environment definition by id.\n+ * @summary Retrieve environment\n+ * @param {string} id Unique environment identifier.\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public retrieveEnvironment(id: string, options?: RawAxiosRequestConfig) {\n+ return EnvironmentsApiFp(this.configuration).retrieveEnvironment(id, options).then((request) => request(this.axios, this.basePath));\n+ }\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/create-environment-request.ts b/lib/packages/fabro-api-client/src/models/create-environment-request.ts\nnew file mode 100644\nindex 000000000..0ad9dc556\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/create-environment-request.ts\n@@ -0,0 +1,48 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentApiImageSettings } from './environment-api-image-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentNetworkSettings } from './environment-network-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentProvider } from './environment-provider';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentResourcesSettings } from './environment-resources-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentVolumeSettings } from './environment-volume-settings';\n+\n+/**\n+ * Request body for creating a server-managed environment.\n+ */\n+export interface CreateEnvironmentRequest {\n+ 'id': string;\n+ 'provider': EnvironmentProvider;\n+ 'image': EnvironmentApiImageSettings;\n+ 'resources': EnvironmentResourcesSettings;\n+ 'network': EnvironmentNetworkSettings;\n+ 'lifecycle': EnvironmentLifecycleSettings;\n+ 'labels': { [key: string]: string; };\n+ 'volumes': Array;\n+ 'env': { [key: string]: string; };\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts b/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts\nnew file mode 100644\nindex 000000000..35180b10a\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts\n@@ -0,0 +1,26 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+export interface EnvironmentApiDockerfileSourceInline {\n+ 'type': EnvironmentApiDockerfileSourceInlineTypeEnum;\n+ 'value': string;\n+}\n+\n+export const EnvironmentApiDockerfileSourceInlineTypeEnum = {\n+ INLINE: 'inline'\n+} as const;\n+\n+export type EnvironmentApiDockerfileSourceInlineTypeEnum = typeof EnvironmentApiDockerfileSourceInlineTypeEnum[keyof typeof EnvironmentApiDockerfileSourceInlineTypeEnum];\ndiff --git a/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts b/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts\nnew file mode 100644\nindex 000000000..54578b0cd\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts\n@@ -0,0 +1,26 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentApiDockerfileSourceInline } from './environment-api-dockerfile-source-inline';\n+\n+/**\n+ * REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.\n+ */\n+export interface EnvironmentApiImageSettings {\n+ 'docker': string | null;\n+ 'dockerfile': EnvironmentApiDockerfileSourceInline | null;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/environment-list-meta.ts b/lib/packages/fabro-api-client/src/models/environment-list-meta.ts\nnew file mode 100644\nindex 000000000..aa92bad87\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/environment-list-meta.ts\n@@ -0,0 +1,25 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+/**\n+ * Metadata for environment list responses.\n+ */\n+export interface EnvironmentListMeta {\n+ /**\n+ * Total number of server-managed environment definitions.\n+ */\n+ 'total': number;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/environment-list-response.ts b/lib/packages/fabro-api-client/src/models/environment-list-response.ts\nnew file mode 100644\nindex 000000000..cf25725b1\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/environment-list-response.ts\n@@ -0,0 +1,29 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { Environment } from './environment';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentListMeta } from './environment-list-meta';\n+\n+/**\n+ * List envelope for environment definitions.\n+ */\n+export interface EnvironmentListResponse {\n+ 'data': Array;\n+ 'meta': EnvironmentListMeta;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/environment.ts b/lib/packages/fabro-api-client/src/models/environment.ts\nnew file mode 100644\nindex 000000000..6451f9d57\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/environment.ts\n@@ -0,0 +1,52 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentApiImageSettings } from './environment-api-image-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentNetworkSettings } from './environment-network-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentProvider } from './environment-provider';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentResourcesSettings } from './environment-resources-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentVolumeSettings } from './environment-volume-settings';\n+\n+/**\n+ * Public server-managed environment definition.\n+ */\n+export interface Environment {\n+ 'id': string;\n+ /**\n+ * Stable revision used with `If-Match` for optimistic concurrency.\n+ */\n+ 'revision': string;\n+ 'provider': EnvironmentProvider;\n+ 'image': EnvironmentApiImageSettings;\n+ 'resources': EnvironmentResourcesSettings;\n+ 'network': EnvironmentNetworkSettings;\n+ 'lifecycle': EnvironmentLifecycleSettings;\n+ 'labels': { [key: string]: string; };\n+ 'volumes': Array;\n+ 'env': { [key: string]: string; };\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts\nindex e66b048ca..87b9c189c 100644\n--- a/lib/packages/fabro-api-client/src/models/index.ts\n+++ b/lib/packages/fabro-api-client/src/models/index.ts\n@@ -68,6 +68,7 @@ export * from './completion-usage';\n export * from './conclusion';\n export * from './create-automation-request';\n export * from './create-completion-request';\n+export * from './create-environment-request';\n export * from './create-run-pull-request-request';\n export * from './create-run-session-request';\n export * from './create-secret-request';\n@@ -92,8 +93,13 @@ export * from './disk-usage-summary-row';\n export * from './dockerfile-source';\n export * from './dockerfile-source-inline';\n export * from './dockerfile-source-path';\n+export * from './environment';\n+export * from './environment-api-dockerfile-source-inline';\n+export * from './environment-api-image-settings';\n export * from './environment-image-settings';\n export * from './environment-lifecycle-settings';\n+export * from './environment-list-meta';\n+export * from './environment-list-response';\n export * from './environment-network-mode';\n export * from './environment-network-settings';\n export * from './environment-provider';\n@@ -273,6 +279,7 @@ export * from './render-workflow-graph-direction';\n export * from './render-workflow-graph-format';\n export * from './render-workflow-graph-request';\n export * from './replace-automation-request';\n+export * from './replace-environment-request';\n export * from './repo-check-response';\n export * from './repo-check-response-permissions';\n export * from './repository-ref';\ndiff --git a/lib/packages/fabro-api-client/src/models/replace-environment-request.ts b/lib/packages/fabro-api-client/src/models/replace-environment-request.ts\nnew file mode 100644\nindex 000000000..56bf4913e\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/replace-environment-request.ts\n@@ -0,0 +1,47 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentApiImageSettings } from './environment-api-image-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentNetworkSettings } from './environment-network-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentProvider } from './environment-provider';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentResourcesSettings } from './environment-resources-settings';\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { EnvironmentVolumeSettings } from './environment-volume-settings';\n+\n+/**\n+ * Request body for replacing a server-managed environment. The path id is authoritative.\n+ */\n+export interface ReplaceEnvironmentRequest {\n+ 'provider': EnvironmentProvider;\n+ 'image': EnvironmentApiImageSettings;\n+ 'resources': EnvironmentResourcesSettings;\n+ 'network': EnvironmentNetworkSettings;\n+ 'lifecycle': EnvironmentLifecycleSettings;\n+ 'labels': { [key: string]: string; };\n+ 'volumes': Array;\n+ 'env': { [key: string]: string; };\n+}\n", + "summary": { + "files_changed": 25, + "additions": 2272, + "deletions": 2 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-29T18:44:28.966098Z", + "current_node": "simplify_opus", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_opus" + ], + "node_retries": {}, + "context_values": { + "internal.work_dir": "/home/daytona/workspace/fabro", + "internal.thread_id": "implement", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "graph.goal": "---\ntitle: \"feat: Add Environment REST CRUD API\"\ntype: feat\nstatus: active\ndate: 2026-05-28\n---\n\n# feat: Add Environment REST CRUD API\n\n## Summary\n\nAdd server-owned Environment CRUD under `/api/v1/environments`, modeled after\nAutomations and backed by the existing `EnvironmentStore`. The API manages only\nthe server-side environment catalog in `environments/*.toml`; client-side\nenvironment definitions in `workflow.toml`, `.fabro/project.toml`, or run inputs\ncontinue to work and are not managed by this API.\n\n## API Contract\n\n- Add OpenAPI paths:\n - `GET /api/v1/environments`\n - `POST /api/v1/environments`\n - `GET /api/v1/environments/{id}`\n - `PUT /api/v1/environments/{id}`\n - `DELETE /api/v1/environments/{id}`\n- Mirror Automations semantics:\n - List returns `{ data: Environment[], meta: { total } }`, sorted by id.\n - Create body includes `id`; replace body omits `id`; path id is authoritative.\n - `GET` and `PUT` return `ETag: \"\"`.\n - `PUT` and `DELETE` require `If-Match`.\n - Use existing Automation-style statuses: `400`, `404`, `409`, `422`, `428`, `500`.\n - Stale revisions return `409` to match Automations.\n- Add API-specific Environment request/response schemas so REST `image.dockerfile`\n accepts only inline content or `null`.\n - Existing workflow/settings schemas keep supporting Dockerfile `path`.\n - REST requests with Dockerfile `path` return `422` and must not read\n server-local files.\n- Do not add `PATCH` in v1.\n\n## Implementation Changes\n\n- OpenAPI and generated clients:\n - Update `docs/public/api-reference/fabro-api.yaml` with an `Environments` tag,\n an `EnvironmentId` parameter, CRUD paths, list envelope, and inline-only API\n image schema.\n - Regenerate Rust API types and the TypeScript Axios client.\n - Keep the existing `EnvironmentSettings` schema intact for workflow settings.\n- Server:\n - Add `lib/crates/fabro-server/src/server/handler/environments.rs`, following\n `automations.rs` for routes, auth, ETag parsing, and error mapping.\n - Merge the routes into real API routes; do not add demo routes unless an\n existing convention requires it.\n - Convert API request DTOs into `EnvironmentDraft` / `EnvironmentSettings` only\n after rejecting Dockerfile path sources.\n - Map `EnvironmentStoreError` similarly to Automations: duplicate, protected,\n and stale as `409`; missing as `404`; validation as `422`; internal\n storage/parse/io as curated `500`.\n - After successful create, replace, or delete, refresh cached manifest run\n settings from the current `EnvironmentStore` catalog so `/system/info` and\n default run settings reflect the updated catalog.\n- Domain and API types:\n - Use a meaningful API DTO boundary rather than treating REST and TOML as\n identical Dockerfile-source surfaces.\n - Reuse `fabro-environment::Environment` for persisted domain behavior where\n the wire shape matches; keep API-only request schemas distinct where\n inline-only Dockerfile behavior differs.\n\n## Implementation Units\n\n- [ ] **Unit 1: Define the OpenAPI contract**\n - Add the environment CRUD paths, schemas, and path parameter.\n - Ensure the spec distinguishes REST-safe inline Dockerfile sources from the\n existing workflow/settings Dockerfile source schema.\n - Verification: OpenAPI route conformance can see the new paths and generated\n clients expose an `EnvironmentsApi`.\n\n- [ ] **Unit 2: Add server environment handlers**\n - Implement a new handler module mirroring the Automation CRUD handler shape.\n - Enforce authentication, id parsing, ETag/If-Match behavior, and error mapping.\n - Reject REST Dockerfile path sources before calling `EnvironmentStore`.\n - Verification: server API tests prove CRUD behavior and failure responses.\n\n- [ ] **Unit 3: Refresh derived server state after mutations**\n - Ensure successful environment create, replace, and delete refresh any cached\n manifest run settings derived from `EnvironmentStore::catalog_layer()`.\n - Preserve existing client-side environment precedence and behavior.\n - Verification: a test proves newly created server environments affect the\n resolved server default run environment where applicable.\n\n- [ ] **Unit 4: Regenerate clients and add contract tests**\n - Regenerate `fabro-api` and `lib/packages/fabro-api-client`.\n - Add Rust server integration tests and keep OpenAPI conformance passing.\n - Verification: generated Rust and TypeScript surfaces compile and expose the\n new environment operations.\n\n## Test Plan\n\n- Add server API tests in\n `lib/crates/fabro-server/tests/it/api/environments.rs` and register the module.\n- Cover:\n - List returns seeded environments and correct total.\n - Create persists `environments/{id}.toml`, returns `201`, and is visible via\n list/get.\n - Get returns current `ETag` matching `revision`.\n - Replace with valid `If-Match` updates the file, returns a new revision, and\n updates the `ETag`.\n - Replace/delete without `If-Match` return `428`.\n - Stale replace/delete return `409`.\n - Duplicate create returns `409`.\n - Invalid id/header returns `400`.\n - Domain validation failures return `422`.\n - Dockerfile `path` over REST returns `422` and does not persist or expose file\n contents.\n - Delete removes a non-default environment; deleting `default` returns a\n protected conflict.\n - Unauthenticated environment routes return `401`.\n - Creating an environment referenced by server default run settings refreshes\n cached manifest run settings.\n\n## Assumptions\n\n- This API manages server-owned environments only; client-defined catalogs remain\n file/request scoped.\n- Built-in seed behavior follows the current store: seeded environments are\n listed, create conflicts with existing ids, and `default` is protected from\n delete.\n- Create responses match Automations and do not need an `ETag`; clients can use\n the returned `revision` or call `GET`.\n- Inline-only Dockerfile policy applies only to REST CRUD, not local TOML\n configuration.\n\n## Sources\n\n- `docs/public/api-reference/fabro-api.yaml`\n- `lib/crates/fabro-server/src/server/handler/automations.rs`\n- `lib/crates/fabro-environment/src/store.rs`\n- `lib/crates/fabro-environment/src/model.rs`\n- `docs/public/execution/environments.mdx`\n", + "failure_class": "", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "last_stage": "simplify_opus", + "internal.retry_count.toolchain": 0, + "response.implement": "Implemented Environment REST CRUD API end-to-end.\n\nSummary:\n- Added OpenAPI `Environments` tag, `/api/v1/environments` CRUD paths, `EnvironmentId` parameter, list envelope, and REST-safe inline-only Dockerfile schemas.\n- Added server handler module: `lib/crates/fabro-server/src/server/handler/environments.rs`.\n- Mounted real API routes only; no demo routes added.\n- Added API DTO boundary that rejects REST Dockerfile `path` sources with `422` before calling `EnvironmentStore`.\n- Added ETag / `If-Match` behavior matching Automations.\n- Added `EnvironmentStoreError` → `ApiError` mapping with stale/duplicate/protected as `409`.\n- Refreshes cached manifest run settings after create/replace/delete so `/system/info` reflects catalog mutations.\n- Tightened `fabro-environment` create/replace behavior to validate dense settings before persistence.\n- Regenerated Rust API types and TypeScript Axios client; TS now exposes `EnvironmentsApi`.\n- Added server integration tests, OpenAPI/SDK conformance test, and Rust API JSON/type-boundary tests.\n\nValidation run:\n- Red TDD check: new environment API tests initially failed with `404` before routes existed.\n- `cargo nextest run -p fabro-server --features test-support --test it api::environments`\n- `cargo nextest run -p fabro-server --features test-support --test it openapi_conformance`\n- `cargo test -p fabro-api --test environment_round_trip`\n- `cargo nextest run -p fabro-environment`\n- `cargo check -q --workspace`\n- `cargo +nightly-2026-04-14 fmt --check --all`\n- `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings`\n- `cd lib/packages/fabro-api-client && bun run generate`\n- `cd lib/packages/fabro-api-client && bun run typecheck`\n\nNote: I installed `openjdk-21-jre-headless` in the container because `openapi-generator-cli` requires `java` for TypeScript client generation.", + "internal.retry_count.implement": 0, + "internal.fidelity": "compact", + "thread.preflight_lint.current_node": "implement", + "last_response": "## Summary\n\nReviewed the Environment REST CRUD API diff (2,574 lines) with three parallel agents covering code reuse, code quality, and efficiency.\n\n### Fixed\n\n**Collapsed API DTO duplication in `lib/", + "response.simplify_opus": "## Summary\n\nReviewed the Environment REST CRUD API diff (2,574 lines) with three parallel agents covering code reuse, code quality, and efficiency.\n\n### Fixed\n\n**Collapsed API DTO duplication in `lib/crates/fabro-server/src/server/handler/environments.rs`** (-27 net lines):\n- Removed the redundant `ApiEnvironmentSettings` middleman struct (an exact field-by-field clone of `CreateEnvironmentRequest`/`ReplaceEnvironmentRequest`).\n- Removed both `into_settings`/`into_draft` trampoline impls that constructed `ApiEnvironmentSettings` only to shell out to a shared converter.\n- `CreateEnvironmentRequest::into_draft` and `ReplaceEnvironmentRequest::into_settings` now build `EnvironmentSettings` directly.\n- Replaced `ApiDockerfileSource::Path { _path: String }` with `_path: IgnoredAny`, removing the per-request `String` allocation for a payload that's parsed and discarded.\n\n### Verified\n\n- `cargo +nightly clippy --workspace --all-targets -- -D warnings` ✅\n- 13/13 `api::environments` tests pass\n- 4/4 `openapi_conformance` tests pass\n- 170/170 `fabro-api` tests pass (including round-trip)\n- 16/16 `fabro-environment` tests pass\n\n### Reviewed but not fixed (with rationale)\n\n- **Shared etag/parse helpers across automations.rs and environments.rs** (High priority from reuse review): The five helpers (`parse_path_id`, `parse_required_if_match`, `unquote_etag`, `*_with_etag_response`, `From<*StoreError> for ApiError`) are byte-twins with the automations versions. Extracting them requires changes to both modules and adds trait machinery; better tackled when a third resource lands.\n- **Catalog deep clone in `resolve_manifest_run_settings_with_catalog`** (Medium efficiency): Would require restructuring `SettingsLayer.environments` from owned `MergeMap` to `Arc` across fabro-config. Environment writes are admin-only on small catalogs; deferred.\n- **`Environment::from_settings` extra resolve round-trip** (Medium efficiency): The added `resolve_environment(&persisted)` call is intentional — it's the validation step that powers the new `create_invalid_settings_is_rejected` test. Correct as-is.\n- **List pagination** (Medium efficiency): Out of scope; would require an OpenAPI spec change.\n- **`EnvironmentListResponse`/`EnvironmentListMeta` generalization** (Medium reuse): Same pattern as `AutomationListResponse`; cross-cutting refactor worth doing later.\n- **OpenAPI YAML repeats every 4xx/5xx response body** (Low quality): Pre-existing pattern across automations and other paths; systemic, not introduced by this PR.", + "internal.retry_count.preflight_compile": 0, + "internal.retry_count.start": 0, + "thread.start.current_node": "toolchain", + "current_node": "simplify_opus", + "thread.implement.current_node": "simplify_opus", + "internal.retry_count.simplify_opus": 0, + "failure_signature": "", + "graph.rankdir": "LR", + "outcome": "succeeded", + "internal.retry_count.preflight_lint": 0, + "internal.node_visit_count": 1, + "thread.preflight_compile.current_node": "preflight_lint", + "thread.toolchain.current_node": "preflight_compile", + "internal.run_id": "01KSTE7AK905MJ5YJR2Z2X5MJS" + }, + "node_outcomes": { + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 145385, + "active_time_ms": 145385 + } + }, + "start": { + "status": "succeeded", + "usage": null + }, + "simplify_opus": { + "status": "succeeded", + "context_updates": { + "response.simplify_opus": "## Summary\n\nReviewed the Environment REST CRUD API diff (2,574 lines) with three parallel agents covering code reuse, code quality, and efficiency.\n\n### Fixed\n\n**Collapsed API DTO duplication in `lib/crates/fabro-server/src/server/handler/environments.rs`** (-27 net lines):\n- Removed the redundant `ApiEnvironmentSettings` middleman struct (an exact field-by-field clone of `CreateEnvironmentRequest`/`ReplaceEnvironmentRequest`).\n- Removed both `into_settings`/`into_draft` trampoline impls that constructed `ApiEnvironmentSettings` only to shell out to a shared converter.\n- `CreateEnvironmentRequest::into_draft` and `ReplaceEnvironmentRequest::into_settings` now build `EnvironmentSettings` directly.\n- Replaced `ApiDockerfileSource::Path { _path: String }` with `_path: IgnoredAny`, removing the per-request `String` allocation for a payload that's parsed and discarded.\n\n### Verified\n\n- `cargo +nightly clippy --workspace --all-targets -- -D warnings` ✅\n- 13/13 `api::environments` tests pass\n- 4/4 `openapi_conformance` tests pass\n- 170/170 `fabro-api` tests pass (including round-trip)\n- 16/16 `fabro-environment` tests pass\n\n### Reviewed but not fixed (with rationale)\n\n- **Shared etag/parse helpers across automations.rs and environments.rs** (High priority from reuse review): The five helpers (`parse_path_id`, `parse_required_if_match`, `unquote_etag`, `*_with_etag_response`, `From<*StoreError> for ApiError`) are byte-twins with the automations versions. Extracting them requires changes to both modules and adds trait machinery; better tackled when a third resource lands.\n- **Catalog deep clone in `resolve_manifest_run_settings_with_catalog`** (Medium efficiency): Would require restructuring `SettingsLayer.environments` from owned `MergeMap` to `Arc` across fabro-config. Environment writes are admin-only on small catalogs; deferred.\n- **`Environment::from_settings` extra resolve round-trip** (Medium efficiency): The added `resolve_environment(&persisted)` call is intentional — it's the validation step that powers the new `create_invalid_settings_is_rejected` test. Correct as-is.\n- **List pagination** (Medium efficiency): Out of scope; would require an OpenAPI spec change.\n- **`EnvironmentListResponse`/`EnvironmentListMeta` generalization** (Medium reuse): Same pattern as `AutomationListResponse`; cross-cutting refactor worth doing later.\n- **OpenAPI YAML repeats every 4xx/5xx response body** (Low quality): Pre-existing pattern across automations and other paths; systemic, not introduced by this PR.", + "last_stage": "simplify_opus", + "last_response": "## Summary\n\nReviewed the Environment REST CRUD API diff (2,574 lines) with three parallel agents covering code reuse, code quality, and efficiency.\n\n### Fixed\n\n**Collapsed API DTO duplication in `lib/" + }, + "notes": "Stage completed: simplify_opus", + "usage": { + "input": { + "usage": { + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "tokens": { + "input_tokens": 116453, + "output_tokens": 32987, + "reasoning_tokens": 0, + "cache_read_tokens": 5421839, + "cache_write_tokens": 458669 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 458669, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 6984540 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-server/src/server/handler/environments.rs" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 466515, + "tool_time_ms": 402062, + "active_time_ms": 868577 + } + }, "toolchain": { "status": "succeeded", "context_updates": { @@ -912,8 +1100,9 @@ } } }, - "next_node_id": "simplify_opus", + "next_node_id": "simplify_gpt", "node_visits": { + "simplify_opus": 1, "start": 1, "toolchain": 1, "preflight_compile": 1, @@ -955,7 +1144,12 @@ "first_event_seq": 51, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-05-29T18:29:49.348261Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -969,6 +1163,12 @@ "output": null, "started_at": "2026-05-29T18:04:05.449854Z", "handler": "agent", + "timing": { + "wall_time_ms": 1543888, + "inference_time_ms": 1030880, + "tool_time_ms": 509884, + "active_time_ms": 1540764 + }, "usage": { "input_tokens": 2226903, "output_tokens": 23362, @@ -1186,6 +1386,287 @@ ], "warnings": [] }, + "state": "succeeded" + }, + "simplify_opus@1": { + "first_event_seq": 522, + "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-29T18:29:54.119107Z", + "handler": "agent", + "usage": { + "input_tokens": 116453, + "output_tokens": 32987, + "total_tokens": 6029948, + "reasoning_tokens": 0, + "cache_read_tokens": 5421839, + "cache_write_tokens": 458669, + "total_usd_micros": 6984540 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "todos": { + "kind": "anthropic_tasks", + "list_id": "anthropic_tasks:3039cf71-5de0-49e1-ad6c-dcf7d7fdd7ad", + "items": [ + { + "id": "1", + "status": "completed", + "order": 0, + "subject": "Collapse API DTOs in environments.rs handler", + "description": "Replace ApiEnvironmentSettings, ApiEnvironmentImageSettings, ApiDockerfileSource, CreateEnvironmentRequest, ReplaceEnvironmentRequest with direct use of EnvironmentDraft/EnvironmentSettings + runtime check that rejects DockerfileSource::Path with 422.", + "active_form": "Collapsing API DTOs in environments.rs handler" + }, + { + "id": "3", + "status": "completed", + "order": 2, + "subject": "Verify build, tests, and lint after changes", + "description": "Run cargo check, cargo nextest for fabro-server, fabro-environment, fabro-api, and clippy", + "active_form": "Verifying build and tests" + } + ] + }, + "subagents": [ + { + "agent_id": "dfc00786", + "depth": 1, + "task": "You are a Code Reuse Reviewer. Review the diff at /tmp/full_diff.txt (the full diff of branch vs origin/main).\n\nFocus exclusively on whether newly written code duplicates existing utilities, helpers, or types already in the codebase that should be reused instead.\n\nFor each finding:\n1. Cite file:line of the duplicate code in the diff\n2. Cite file:line of the existing utility/helper/type that should be reused\n3. Explain how to replace\n\nSpecific areas to investigate:\n- The handler at `lib/crates/fabro-server/src/server/handler/environments.rs` defines `parse_path_id`, `parse_required_if_match`, `unquote_etag`, `environment_with_etag_response`, and `From for ApiError`. Compare with `lib/crates/fabro-server/src/server/handler/automations.rs` which has equivalent helpers. Should they be unified into a shared helper module? Grep for similar patterns.\n- The handler also defines `ApiEnvironmentSettings`, `CreateEnvironmentRequest`, and `ReplaceEnvironmentRequest` with significant field-by-field duplication of `EnvironmentSettings` (in `lib/crates/fabro-types/src/settings/run.rs`). Can the API DTO reuse the domain `EnvironmentSettings` via `serde(flatten)`?\n- The OpenAPI spec adds an `EnvironmentListResponse` and `EnvironmentListMeta` (and similar `EnvironmentApiImageSettings`). Compare to `AutomationListResponse`/`AutomationListMeta` in the spec and the existing list response patterns. Is there an existing list envelope schema that should be reused?\n- `EnvironmentApiDockerfileSourceInline` schema in the OpenAPI spec — does the existing `DockerfileSource` schema already have similar shape that could be parameterized?\n- The handler's `ApiDockerfileSource::Path { _path: String }` — is there a way to use existing serde patterns?\n\nLook at the existing codebase. Use grep to find similar utilities. Be concrete about what already exists.\n\nOutput a markdown report with prioritized findings. Mark each as High/Medium/Low priority based on impact.\n\nWorking directory: /home/daytona/workspace/fabro", + "status": { + "kind": "completed", + "success": true, + "turns_used": 46 + } + }, + { + "agent_id": "0a607d4d", + "depth": 1, + "task": "You are a Code Quality Reviewer. Review the diff at /tmp/full_diff.txt (the full diff of branch vs origin/main).\n\nFocus on hacky patterns:\n1. Redundant state: state that duplicates existing state, cached values that could be derived\n2. Parameter sprawl: adding new parameters when functions should be restructured\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified\n4. Leaky abstractions: exposing internal details that should be encapsulated\n5. Stringly-typed code: using raw strings where constants/enums/branded types already exist\n\nSpecific areas to investigate carefully:\n- The handler at `lib/crates/fabro-server/src/server/handler/environments.rs` defines THREE structs (`CreateEnvironmentRequest`, `ReplaceEnvironmentRequest`, `ApiEnvironmentSettings`) with EXACT same 8 fields each. The two request types' impls each construct an `ApiEnvironmentSettings` from themselves and forward. This is severe copy-paste. Suggest a unified design using `serde(flatten)` or sharing one struct.\n- The `ApiDockerfileSource::Path { _path: String }` variant is an unused field that exists only to accept JSON shape and then return 422. Is there a cleaner pattern using `serde::de::IgnoredAny` or a marker without the named field?\n- The handler defines an entire parallel hierarchy (`ApiEnvironmentSettings`, `ApiEnvironmentImageSettings`, `ApiDockerfileSource`) that mirrors `EnvironmentSettings`, `EnvironmentImageSettings`, `DockerfileSource` exactly except for the dockerfile variant. Could it be slimmer (only the image part needs API override)?\n- `refresh_manifest_run_settings_from_environment_catalog` in `lib/crates/fabro-server/src/server.rs` — is the `.expect(...)` pattern aligned with existing lock conventions in this file?\n- In environments.rs, helpers `parse_path_id`, `parse_required_if_match`, `unquote_etag`, `environment_with_etag_response`, and `From for ApiError` are near-identical to the automations.rs versions. This is the highest-impact copy-paste — flag clearly and suggest extraction.\n- The OpenAPI YAML spec adds full duplicate response definitions for each path (the `400/404/409/422/428/500` blocks have full duplicate `headers/x-request-id` and `content/application/json/schema` blocks). Check the existing automations paths to see whether the pattern uses $refs or also repeats.\n\nOutput a markdown report with prioritized findings. Mark each as High/Medium/Low priority.\n\nWorking directory: /home/daytona/workspace/fabro", + "status": { + "kind": "completed", + "success": true, + "turns_used": 65 + } + }, + { + "agent_id": "3b15c81f", + "depth": 1, + "task": "You are an Efficiency Reviewer. Review the diff at /tmp/full_diff.txt (the full diff of branch vs origin/main).\n\nFocus on efficiency:\n1. Unnecessary work: redundant computations, repeated file reads, duplicate API calls, N+1 patterns\n2. Missed concurrency: independent operations run sequentially that could run in parallel\n3. Hot-path bloat: new blocking work added to startup or per-request hot paths\n4. Unnecessary existence checks: pre-checking before operating (TOCTOU)\n5. Memory: unbounded data structures, missing cleanup\n6. Overly broad operations: reading entire files when only a portion is needed\n\nSpecific areas to investigate:\n- `lib/crates/fabro-environment/src/model.rs` line ~55: the `Environment::from_settings` flow was changed. It now calls `resolve_environment(&persisted)` after `environment_settings_to_layer(&settings)`. Previously `settings` was used directly; now it's resolved from the layer. Is this an extra round-trip computation that wasn't there before? Is this necessary for validation? Read the full file to verify.\n- `lib/crates/fabro-server/src/server.rs` `refresh_manifest_run_settings_from_environment_catalog`: runs on every successful create/replace/delete. Is the underlying `resolve_manifest_run_settings_with_catalog` cheap? Does it hold the write lock for too long? Could it be done lazily on read?\n- `lib/crates/fabro-server/src/server/handler/environments.rs` `list_environments`: calls `state.environment_store().list()` then `data.len()`. Verify `list()` returns owned vector (not iterator) — is there a cheaper way to get total count without cloning all environments?\n- The `EnvironmentStore::list()` and `get()` methods — read `lib/crates/fabro-environment/src/store.rs` to verify they don't hold locks unnecessarily or perform unnecessary cloning.\n- Are environment routes adding work to startup that wasn't there before?\n\nOutput a markdown report with prioritized findings. Mark each as High/Medium/Low priority.\n\nWorking directory: /home/daytona/workspace/fabro", + "status": { + "kind": "completed", + "success": true, + "turns_used": 44 + } + } + ], + "permission_level": "full", + "agent_tools": [ + { + "name": "AskUserQuestion", + "description": "Ask the human one or more questions and wait for their answers before continuing this stage.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskCreate", + "description": "Create pending tasks in the current session. Use concise subjects, descriptions, optional activeForm text, and metadata. Check TaskList first to avoid duplicate tasks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": true + }, + { + "name": "TaskGet", + "description": "Get one task by taskId, including subject, status, description, owner, blockedBy, and blocks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskList", + "description": "List tasks for the current session, including status, owner, and blocking dependencies. Use TaskGet with a taskId for full description and dependency details.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskUpdate", + "description": "Update an existing task's status, text, owner, metadata, or dependencies. Valid statuses are pending, in_progress, completed, and deleted. After completing a task, call TaskList to find newly unblocked work.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": true + }, + { + "name": "close_agent", + "description": "Close a running subagent that is no longer needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "edit_file", + "description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": true + }, + { + "name": "glob", + "description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "grep", + "description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "read_file", + "description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "send_input", + "description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "shell", + "description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + "source": { + "kind": "native" + }, + "category": "shell", + "invoked": true + }, + { + "name": "spawn_agent", + "description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "wait", + "description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "web_fetch", + "description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "web_search", + "description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "write_file", + "description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + } + ], + "context_window": { + "provider": "anthropic", + "model": "claude-opus-4-7", + "context_window_tokens": 1000000, + "input_tokens": 128862, + "usage_percent": 12.8862, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-05-29T18:44:28.861249Z", + "event_seq": 966, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 2511, + "usage_percent": 0.2511 + }, + { + "category": "tools", + "tokens": 2842, + "usage_percent": 0.2842 + }, + { + "category": "memory", + "tokens": 6042, + "usage_percent": 0.6042 + }, + { + "category": "conversation", + "tokens": 117459, + "usage_percent": 11.7459 + }, + { + "category": "other", + "tokens": 8, + "usage_percent": 0.0008 + } + ], + "warnings": [] + }, "state": "running" }, "preflight_lint@1": { diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..d07e607da --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,2574 @@ +diff --git a/Cargo.lock b/Cargo.lock +index 1c0b593e9..dc705e2cc 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -1726,6 +1726,7 @@ dependencies = [ + "chrono", + "fabro-automation", + "fabro-config", ++ "fabro-environment", + "fabro-model", + "fabro-types", + "openapiv3", +diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml +index 103ab4cad..cb2659bb5 100644 +--- a/docs/public/api-reference/fabro-api.yaml ++++ b/docs/public/api-reference/fabro-api.yaml +@@ -17,6 +17,8 @@ tags: + description: Run management operations + - name: Automations + description: Server-managed automation definitions and automation-triggered runs ++ - name: Environments ++ description: Server-managed execution environment catalog + - name: Sandboxes + description: Provider-backed sandbox inventory + - name: Sessions +@@ -4224,6 +4226,272 @@ paths: + schema: + $ref: "#/components/schemas/ErrorResponse" + ++ # ── Environments ───────────────────────────────────────────────────── ++ ++ /api/v1/environments: ++ get: ++ operationId: listEnvironments ++ tags: [Environments] ++ summary: List environments ++ description: Returns all server-managed environment definitions, sorted by id. ++ responses: ++ "200": ++ description: Environment definitions ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/EnvironmentListResponse" ++ "500": ++ description: Environment store operation failed ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ post: ++ operationId: createEnvironment ++ tags: [Environments] ++ summary: Create environment ++ description: | ++ Creates a server-owned environment definition in the environment catalog. ++ REST environment requests only accept inline Dockerfile content; local ++ Dockerfile paths are supported by workflow/settings files but rejected ++ by this API. ++ requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/CreateEnvironmentRequest" ++ responses: ++ "201": ++ description: Environment created ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/Environment" ++ "400": ++ description: Malformed JSON request body ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "409": ++ description: Environment id already exists ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "422": ++ description: Environment failed domain validation ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "500": ++ description: Environment store operation failed ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ ++ /api/v1/environments/{id}: ++ get: ++ operationId: retrieveEnvironment ++ tags: [Environments] ++ summary: Retrieve environment ++ description: Returns one server-managed environment definition by id. ++ parameters: ++ - $ref: "#/components/parameters/EnvironmentId" ++ responses: ++ "200": ++ description: Environment definition ++ headers: ++ ETag: ++ $ref: "#/components/headers/ETag" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/Environment" ++ "400": ++ description: Invalid environment id ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "404": ++ description: Environment not found ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "500": ++ description: Environment store operation failed ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ put: ++ operationId: replaceEnvironment ++ tags: [Environments] ++ summary: Replace environment ++ description: | ++ Replaces an environment definition when `If-Match` matches the current ++ environment revision. The path id is authoritative; the request body ++ omits `id`. ++ parameters: ++ - $ref: "#/components/parameters/EnvironmentId" ++ - $ref: "#/components/parameters/IfMatch" ++ requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ReplaceEnvironmentRequest" ++ responses: ++ "200": ++ description: Environment replaced ++ headers: ++ ETag: ++ $ref: "#/components/headers/ETag" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/Environment" ++ "400": ++ description: Malformed JSON request body, invalid environment id, or invalid revision header ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "404": ++ description: Environment not found ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "409": ++ description: Environment revision mismatch or protected environment conflict ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "422": ++ description: Environment failed domain validation ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "428": ++ description: Missing required `If-Match` header ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "500": ++ description: Environment store operation failed ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ delete: ++ operationId: deleteEnvironment ++ tags: [Environments] ++ summary: Delete environment ++ description: Deletes a non-default environment definition when `If-Match` matches the current environment revision. ++ parameters: ++ - $ref: "#/components/parameters/EnvironmentId" ++ - $ref: "#/components/parameters/IfMatch" ++ responses: ++ "204": ++ description: Environment deleted ++ "400": ++ description: Invalid environment id or revision header ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "404": ++ description: Environment not found ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "409": ++ description: Environment revision mismatch or protected environment conflict ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "428": ++ description: Missing required `If-Match` header ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "500": ++ description: Environment store operation failed ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ + # ── Workflows ──────────────────────────────────────────────────────── + + /api/v1/workflows: +@@ -5104,6 +5372,16 @@ components: + pattern: "^[a-z0-9][a-z0-9-]{0,62}$" + example: nightly-deps + ++ EnvironmentId: ++ name: id ++ in: path ++ required: true ++ description: Unique environment identifier. ++ schema: ++ type: string ++ pattern: "^[a-z0-9][a-z0-9-]{0,62}$" ++ example: docker ++ + IfMatch: + name: If-Match + in: header +@@ -6033,6 +6311,181 @@ components: + minimum: 0 + description: Total number of configured automation definitions. + ++ # ── Environments ───────────────────────────────────────────────────── ++ ++ Environment: ++ description: Public server-managed environment definition. ++ type: object ++ additionalProperties: false ++ required: ++ - id ++ - revision ++ - provider ++ - image ++ - resources ++ - network ++ - lifecycle ++ - labels ++ - volumes ++ - env ++ properties: ++ id: ++ type: string ++ pattern: "^[a-z0-9][a-z0-9-]{0,62}$" ++ example: docker ++ revision: ++ type: string ++ pattern: "^[0-9a-f]{64}$" ++ description: Stable revision used with `If-Match` for optimistic concurrency. ++ example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef ++ provider: ++ $ref: "#/components/schemas/EnvironmentProvider" ++ image: ++ $ref: "#/components/schemas/EnvironmentApiImageSettings" ++ resources: ++ $ref: "#/components/schemas/EnvironmentResourcesSettings" ++ network: ++ $ref: "#/components/schemas/EnvironmentNetworkSettings" ++ lifecycle: ++ $ref: "#/components/schemas/EnvironmentLifecycleSettings" ++ labels: ++ $ref: "#/components/schemas/StringMap" ++ volumes: ++ type: array ++ items: ++ $ref: "#/components/schemas/EnvironmentVolumeSettings" ++ env: ++ type: object ++ additionalProperties: ++ $ref: "#/components/schemas/InterpString" ++ ++ CreateEnvironmentRequest: ++ description: Request body for creating a server-managed environment. ++ type: object ++ additionalProperties: false ++ required: ++ - id ++ - provider ++ - image ++ - resources ++ - network ++ - lifecycle ++ - labels ++ - volumes ++ - env ++ properties: ++ id: ++ type: string ++ pattern: "^[a-z0-9][a-z0-9-]{0,62}$" ++ example: docker ++ provider: ++ $ref: "#/components/schemas/EnvironmentProvider" ++ image: ++ $ref: "#/components/schemas/EnvironmentApiImageSettings" ++ resources: ++ $ref: "#/components/schemas/EnvironmentResourcesSettings" ++ network: ++ $ref: "#/components/schemas/EnvironmentNetworkSettings" ++ lifecycle: ++ $ref: "#/components/schemas/EnvironmentLifecycleSettings" ++ labels: ++ $ref: "#/components/schemas/StringMap" ++ volumes: ++ type: array ++ items: ++ $ref: "#/components/schemas/EnvironmentVolumeSettings" ++ env: ++ type: object ++ additionalProperties: ++ $ref: "#/components/schemas/InterpString" ++ ++ ReplaceEnvironmentRequest: ++ description: Request body for replacing a server-managed environment. The path id is authoritative. ++ type: object ++ additionalProperties: false ++ required: ++ - provider ++ - image ++ - resources ++ - network ++ - lifecycle ++ - labels ++ - volumes ++ - env ++ properties: ++ provider: ++ $ref: "#/components/schemas/EnvironmentProvider" ++ image: ++ $ref: "#/components/schemas/EnvironmentApiImageSettings" ++ resources: ++ $ref: "#/components/schemas/EnvironmentResourcesSettings" ++ network: ++ $ref: "#/components/schemas/EnvironmentNetworkSettings" ++ lifecycle: ++ $ref: "#/components/schemas/EnvironmentLifecycleSettings" ++ labels: ++ $ref: "#/components/schemas/StringMap" ++ volumes: ++ type: array ++ items: ++ $ref: "#/components/schemas/EnvironmentVolumeSettings" ++ env: ++ type: object ++ additionalProperties: ++ $ref: "#/components/schemas/InterpString" ++ ++ EnvironmentApiImageSettings: ++ description: REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API. ++ type: object ++ additionalProperties: false ++ required: [docker, dockerfile] ++ properties: ++ docker: ++ type: ["string", "null"] ++ dockerfile: ++ oneOf: ++ - $ref: "#/components/schemas/EnvironmentApiDockerfileSourceInline" ++ - type: "null" ++ ++ EnvironmentApiDockerfileSourceInline: ++ type: object ++ additionalProperties: false ++ required: [type, value] ++ properties: ++ type: ++ type: string ++ enum: [inline] ++ value: ++ type: string ++ ++ EnvironmentListResponse: ++ description: List envelope for environment definitions. ++ type: object ++ additionalProperties: false ++ required: ++ - data ++ - meta ++ properties: ++ data: ++ type: array ++ items: ++ $ref: "#/components/schemas/Environment" ++ meta: ++ $ref: "#/components/schemas/EnvironmentListMeta" ++ ++ EnvironmentListMeta: ++ description: Metadata for environment list responses. ++ type: object ++ additionalProperties: false ++ required: ++ - total ++ properties: ++ total: ++ type: integer ++ format: int64 ++ minimum: 0 ++ description: Total number of server-managed environment definitions. ++ + # ── Pagination ─────────────────────────────────────────────────────── + + PaginationMeta: +diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml +index ce21c0986..284a1956f 100644 +--- a/lib/crates/fabro-api/Cargo.toml ++++ b/lib/crates/fabro-api/Cargo.toml +@@ -17,6 +17,7 @@ wildcard_imports = "warn" + chrono = { workspace = true, features = ["serde"] } + fabro-automation = { path = "../fabro-automation" } + fabro-config = { path = "../fabro-config" } ++fabro-environment.workspace = true + fabro-model = { path = "../fabro-model" } + fabro-types = { path = "../fabro-types" } + progenitor-client = "0.13" +diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs +index 3ef344399..c87635923 100644 +--- a/lib/crates/fabro-api/build.rs ++++ b/lib/crates/fabro-api/build.rs +@@ -635,6 +635,7 @@ fn main() { + "fabro_automation::AutomationReplace", + &[], + ), ++ ("Environment", "fabro_environment::Environment", &[]), + ("SessionId", "fabro_types::SessionId", &[]), + ("TurnId", "fabro_types::TurnId", &[]), + ("SessionStatus", "fabro_types::SessionStatus", &[]), +diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs +index 28903a67d..815883853 100644 +--- a/lib/crates/fabro-api/src/lib.rs ++++ b/lib/crates/fabro-api/src/lib.rs +@@ -18,6 +18,7 @@ pub mod types { + Automation, AutomationDraft as CreateAutomationRequest, + AutomationReplace as ReplaceAutomationRequest, AutomationTarget, AutomationTrigger, + }; ++ pub use fabro_environment::Environment; + pub use fabro_model::{ + Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, + Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed, +diff --git a/lib/crates/fabro-api/tests/environment_round_trip.rs b/lib/crates/fabro-api/tests/environment_round_trip.rs +new file mode 100644 +index 000000000..362b78bbc +--- /dev/null ++++ b/lib/crates/fabro-api/tests/environment_round_trip.rs +@@ -0,0 +1,88 @@ ++use fabro_api::types::{ ++ CreateEnvironmentRequest as ApiCreateEnvironmentRequest, Environment as ApiEnvironment, ++ ReplaceEnvironmentRequest as ApiReplaceEnvironmentRequest, ++}; ++use fabro_environment::Environment; ++use serde_json::json; ++ ++// Compile-time witness that the generated API response type resolves to the ++// same type as the `fabro-environment` domain type via `with_replacement(...)`. ++// Request types intentionally stay API-specific so REST Dockerfile sources can ++// remain inline-only without changing workflow/settings schemas. ++const _: fn(ApiEnvironment) -> Environment = |value| value; ++ ++fn environment_settings_json() -> serde_json::Value { ++ json!({ ++ "provider": "docker", ++ "image": { ++ "docker": null, ++ "dockerfile": { ++ "type": "inline", ++ "value": "FROM alpine\n" ++ } ++ }, ++ "resources": { ++ "cpu": null, ++ "memory": null, ++ "disk": null ++ }, ++ "network": { ++ "mode": "allow_all", ++ "allow": [] ++ }, ++ "lifecycle": { ++ "preserve": false, ++ "stop_on_terminal": true, ++ "auto_stop": null ++ }, ++ "labels": {}, ++ "volumes": [], ++ "env": {} ++ }) ++} ++ ++#[test] ++fn environment_response_round_trips_public_json_shape() { ++ let mut value = environment_settings_json(); ++ value["id"] = json!("docker-inline"); ++ value["revision"] = json!("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); ++ ++ let api: ApiEnvironment = serde_json::from_value(value.clone()).unwrap(); ++ assert_eq!(serde_json::to_value(api).unwrap(), value); ++} ++ ++#[test] ++fn create_environment_request_round_trips_inline_dockerfile_json_shape() { ++ let mut value = environment_settings_json(); ++ value["id"] = json!("docker-inline"); ++ ++ let api: ApiCreateEnvironmentRequest = serde_json::from_value(value.clone()).unwrap(); ++ assert_eq!(serde_json::to_value(api).unwrap(), value); ++} ++ ++#[test] ++fn replace_environment_request_round_trips_inline_dockerfile_json_shape() { ++ let value = environment_settings_json(); ++ ++ let api: ApiReplaceEnvironmentRequest = serde_json::from_value(value.clone()).unwrap(); ++ assert_eq!(serde_json::to_value(api).unwrap(), value); ++} ++ ++#[test] ++fn environment_request_schema_rejects_dockerfile_path_sources() { ++ let mut value = environment_settings_json(); ++ value["id"] = json!("docker-path"); ++ value["image"]["dockerfile"] = json!({ ++ "type": "path", ++ "path": "Dockerfile" ++ }); ++ ++ let err = serde_json::from_value::(value) ++ .expect_err("generated REST request type should reject Dockerfile path sources"); ++ assert!( ++ err.to_string().contains("dockerfile") ++ || err.to_string().contains("type") ++ || err.to_string().contains("path"), ++ "unexpected error: {err}" ++ ); ++} +diff --git a/lib/crates/fabro-environment/src/model.rs b/lib/crates/fabro-environment/src/model.rs +index 90e0f934a..2943ac538 100644 +--- a/lib/crates/fabro-environment/src/model.rs ++++ b/lib/crates/fabro-environment/src/model.rs +@@ -52,6 +52,7 @@ impl Environment { + ) -> Result<(Self, Vec), EnvironmentStoreError> { + let settings = inline_dense_dockerfile(settings, dockerfile_base_dir).await?; + let persisted = environment_settings_to_layer(&settings); ++ let settings = resolve_environment(&persisted)?; + let bytes = canonical_bytes(&persisted).into_bytes(); + let revision = EnvironmentRevision::from_bytes(&bytes); + Ok(( +diff --git a/lib/crates/fabro-environment/src/store.rs b/lib/crates/fabro-environment/src/store.rs +index 172c518b1..b6265d7ed 100644 +--- a/lib/crates/fabro-environment/src/store.rs ++++ b/lib/crates/fabro-environment/src/store.rs +@@ -404,8 +404,8 @@ mod tests { + use fabro_types::settings::InterpString; + use fabro_types::settings::run::{ + DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings, +- EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings, +- EnvironmentSettings, ++ EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider, ++ EnvironmentResourcesSettings, EnvironmentSettings, + }; + use tokio::fs; + +@@ -572,6 +572,28 @@ path = "Dockerfile" + assert!(matches!(err, EnvironmentStoreError::AlreadyExists { .. })); + } + ++ #[tokio::test] ++ async fn create_invalid_settings_is_rejected() { ++ let dir = tempfile::tempdir().unwrap(); ++ let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap(); ++ let mut settings = settings(EnvironmentProvider::Local); ++ settings.network.mode = EnvironmentNetworkMode::Block; ++ ++ let err = store ++ .create(EnvironmentDraft { ++ id: EnvironmentId::new("invalid").unwrap(), ++ settings, ++ }) ++ .await ++ .unwrap_err(); ++ ++ assert!(matches!(err, EnvironmentStoreError::Validation { .. })); ++ assert!( ++ err.to_string() ++ .contains("local environments cannot enforce") ++ ); ++ } ++ + #[tokio::test] + async fn replace_stale_revision_is_rejected() { + let dir = tempfile::tempdir().unwrap(); +diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs +index 8fafb6678..46a62c864 100644 +--- a/lib/crates/fabro-server/src/server.rs ++++ b/lib/crates/fabro-server/src/server.rs +@@ -1317,6 +1317,18 @@ impl AppState { + .clone() + } + ++ pub(crate) fn refresh_manifest_run_settings_from_environment_catalog(&self) { ++ let manifest_run_defaults = self.manifest_run_defaults(); ++ let manifest_run_settings = resolve_manifest_run_settings_with_catalog( ++ manifest_run_defaults.as_ref(), ++ &self.environment_store, ++ ); ++ *self ++ .manifest_run_settings ++ .write() ++ .expect("manifest run settings lock poisoned") = manifest_run_settings; ++ } ++ + fn http_client(&self) -> Result { + match &self.http_client { + Some(client) => Ok(client.clone()), +diff --git a/lib/crates/fabro-server/src/server/handler/environments.rs b/lib/crates/fabro-server/src/server/handler/environments.rs +new file mode 100644 +index 000000000..147425800 +--- /dev/null ++++ b/lib/crates/fabro-server/src/server/handler/environments.rs +@@ -0,0 +1,315 @@ ++use std::collections::HashMap; ++use std::sync::Arc; ++ ++use axum::http::{HeaderMap, HeaderValue, header}; ++use fabro_environment::{ ++ Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError, ++}; ++use fabro_types::settings::InterpString; ++use fabro_types::settings::run::{ ++ DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings, ++ EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings, ++ EnvironmentSettings, EnvironmentVolumeSettings, ++}; ++use serde::{Deserialize, Serialize}; ++ ++use super::super::{ ++ ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State, ++ StatusCode, get, ++}; ++ ++#[derive(Serialize)] ++struct EnvironmentListResponse { ++ data: Vec, ++ meta: EnvironmentListMeta, ++} ++ ++#[derive(Serialize)] ++struct EnvironmentListMeta { ++ total: usize, ++} ++ ++#[derive(Deserialize)] ++#[serde(deny_unknown_fields)] ++struct CreateEnvironmentRequest { ++ id: EnvironmentId, ++ provider: EnvironmentProvider, ++ image: ApiEnvironmentImageSettings, ++ resources: EnvironmentResourcesSettings, ++ network: EnvironmentNetworkSettings, ++ lifecycle: EnvironmentLifecycleSettings, ++ labels: HashMap, ++ volumes: Vec, ++ env: HashMap, ++} ++ ++#[derive(Deserialize)] ++#[serde(deny_unknown_fields)] ++struct ReplaceEnvironmentRequest { ++ provider: EnvironmentProvider, ++ image: ApiEnvironmentImageSettings, ++ resources: EnvironmentResourcesSettings, ++ network: EnvironmentNetworkSettings, ++ lifecycle: EnvironmentLifecycleSettings, ++ labels: HashMap, ++ volumes: Vec, ++ env: HashMap, ++} ++ ++struct ApiEnvironmentSettings { ++ provider: EnvironmentProvider, ++ image: ApiEnvironmentImageSettings, ++ resources: EnvironmentResourcesSettings, ++ network: EnvironmentNetworkSettings, ++ lifecycle: EnvironmentLifecycleSettings, ++ labels: HashMap, ++ volumes: Vec, ++ env: HashMap, ++} ++ ++#[derive(Deserialize)] ++#[serde(deny_unknown_fields)] ++struct ApiEnvironmentImageSettings { ++ docker: Option, ++ dockerfile: Option, ++} ++ ++#[derive(Deserialize)] ++#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] ++enum ApiDockerfileSource { ++ Inline { ++ value: String, ++ }, ++ Path { ++ #[serde(rename = "path")] ++ _path: String, ++ }, ++} ++ ++impl CreateEnvironmentRequest { ++ fn into_draft(self) -> Result { ++ let settings = ApiEnvironmentSettings { ++ provider: self.provider, ++ image: self.image, ++ resources: self.resources, ++ network: self.network, ++ lifecycle: self.lifecycle, ++ labels: self.labels, ++ volumes: self.volumes, ++ env: self.env, ++ }; ++ Ok(EnvironmentDraft { ++ id: self.id, ++ settings: settings.into_settings()?, ++ }) ++ } ++} ++ ++impl ReplaceEnvironmentRequest { ++ fn into_settings(self) -> Result { ++ ApiEnvironmentSettings { ++ provider: self.provider, ++ image: self.image, ++ resources: self.resources, ++ network: self.network, ++ lifecycle: self.lifecycle, ++ labels: self.labels, ++ volumes: self.volumes, ++ env: self.env, ++ } ++ .into_settings() ++ } ++} ++ ++impl ApiEnvironmentSettings { ++ fn into_settings(self) -> Result { ++ Ok(EnvironmentSettings { ++ provider: self.provider, ++ image: self.image.into_settings()?, ++ resources: self.resources, ++ network: self.network, ++ lifecycle: self.lifecycle, ++ labels: self.labels, ++ volumes: self.volumes, ++ env: self.env, ++ }) ++ } ++} ++ ++impl ApiEnvironmentImageSettings { ++ fn into_settings(self) -> Result { ++ Ok(EnvironmentImageSettings { ++ docker: self.docker, ++ dockerfile: self ++ .dockerfile ++ .map(ApiDockerfileSource::into_settings) ++ .transpose()?, ++ }) ++ } ++} ++ ++impl ApiDockerfileSource { ++ fn into_settings(self) -> Result { ++ match self { ++ Self::Inline { value } => Ok(DockerfileSource::Inline(value)), ++ Self::Path { .. } => Err(ApiError::new( ++ StatusCode::UNPROCESSABLE_ENTITY, ++ "Dockerfile path sources are not supported by the environments REST API; use inline Dockerfile content", ++ )), ++ } ++ } ++} ++ ++pub(super) fn routes() -> Router> { ++ Router::new() ++ .route( ++ "/environments", ++ get(list_environments).post(create_environment), ++ ) ++ .route( ++ "/environments/{id}", ++ get(get_environment) ++ .put(replace_environment) ++ .delete(delete_environment), ++ ) ++} ++ ++async fn list_environments(_auth: RequiredUser, State(state): State>) -> Response { ++ let data = state.environment_store().list(); ++ let total = data.len(); ++ ( ++ StatusCode::OK, ++ Json(EnvironmentListResponse { ++ data, ++ meta: EnvironmentListMeta { total }, ++ }), ++ ) ++ .into_response() ++} ++ ++async fn create_environment( ++ _auth: RequiredUser, ++ State(state): State>, ++ Json(request): Json, ++) -> Result { ++ let environment = state ++ .environment_store() ++ .create(request.into_draft()?) ++ .await?; ++ state.refresh_manifest_run_settings_from_environment_catalog(); ++ Ok((StatusCode::CREATED, Json(environment)).into_response()) ++} ++ ++async fn get_environment( ++ _auth: RequiredUser, ++ State(state): State>, ++ Path(id): Path, ++) -> Result { ++ let id = parse_path_id(id)?; ++ match state.environment_store().get(&id) { ++ Some(environment) => Ok(environment_with_etag_response(StatusCode::OK, environment)), ++ None => Err(ApiError::not_found(format!("environment not found: {id}"))), ++ } ++} ++ ++async fn replace_environment( ++ _auth: RequiredUser, ++ State(state): State>, ++ headers: HeaderMap, ++ Path(id): Path, ++ Json(request): Json, ++) -> Result { ++ let id = parse_path_id(id)?; ++ let expected = parse_required_if_match(&headers, &id)?; ++ let environment = state ++ .environment_store() ++ .replace(&id, &expected, request.into_settings()?) ++ .await?; ++ state.refresh_manifest_run_settings_from_environment_catalog(); ++ Ok(environment_with_etag_response(StatusCode::OK, environment)) ++} ++ ++async fn delete_environment( ++ _auth: RequiredUser, ++ State(state): State>, ++ headers: HeaderMap, ++ Path(id): Path, ++) -> Result { ++ let id = parse_path_id(id)?; ++ let expected = parse_required_if_match(&headers, &id)?; ++ state.environment_store().delete(&id, &expected).await?; ++ state.refresh_manifest_run_settings_from_environment_catalog(); ++ Ok(StatusCode::NO_CONTENT.into_response()) ++} ++ ++fn parse_path_id(id: String) -> Result { ++ EnvironmentId::new(id) ++ .map_err(|err| ApiError::bad_request(format!("invalid environment id: {err}"))) ++} ++ ++fn parse_required_if_match( ++ headers: &HeaderMap, ++ id: &EnvironmentId, ++) -> Result { ++ let Some(value) = headers.get(header::IF_MATCH) else { ++ return Err(ApiError::new( ++ StatusCode::PRECONDITION_REQUIRED, ++ format!("If-Match header is required for environment: {id}"), ++ )); ++ }; ++ let value = value ++ .to_str() ++ .map_err(|_| ApiError::bad_request("If-Match header must be visible ASCII"))?; ++ let value = unquote_etag(value.trim()); ++ value.parse::().map_err(|err| { ++ ApiError::bad_request(format!("invalid If-Match environment revision: {err}")) ++ }) ++} ++ ++fn unquote_etag(value: &str) -> &str { ++ value ++ .strip_prefix('"') ++ .and_then(|unquoted| unquoted.strip_suffix('"')) ++ .unwrap_or(value) ++} ++ ++fn environment_with_etag_response(status: StatusCode, environment: Environment) -> Response { ++ let etag = HeaderValue::from_str(&format!("\"{}\"", environment.revision)) ++ .expect("environment revisions are valid ETag header values"); ++ let mut response = (status, Json(environment)).into_response(); ++ response.headers_mut().insert(header::ETAG, etag); ++ response ++} ++ ++impl From for ApiError { ++ fn from(err: EnvironmentStoreError) -> Self { ++ match err { ++ EnvironmentStoreError::NotFound { id } => { ++ Self::not_found(format!("environment not found: {id}")) ++ } ++ EnvironmentStoreError::AlreadyExists { id } => Self::new( ++ StatusCode::CONFLICT, ++ format!("environment already exists: {id}"), ++ ), ++ EnvironmentStoreError::StaleRevision { id, .. } => Self::new( ++ StatusCode::CONFLICT, ++ format!("environment revision is stale: {id}"), ++ ), ++ EnvironmentStoreError::Protected { id } => Self::new( ++ StatusCode::CONFLICT, ++ format!("environment is protected and cannot be deleted: {id}"), ++ ), ++ EnvironmentStoreError::Validation { source } => { ++ Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string()) ++ } ++ EnvironmentStoreError::InvalidFilename { .. } ++ | EnvironmentStoreError::Parse { .. } ++ | EnvironmentStoreError::InvalidUtf8 { .. } ++ | EnvironmentStoreError::Serialize { .. } ++ | EnvironmentStoreError::Io { .. } => Self::new( ++ StatusCode::INTERNAL_SERVER_ERROR, ++ "environment store operation failed", ++ ), ++ } ++ } ++} +diff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs +index 33c6aa8eb..ff3c3648a 100644 +--- a/lib/crates/fabro-server/src/server/handler/mod.rs ++++ b/lib/crates/fabro-server/src/server/handler/mod.rs +@@ -9,6 +9,7 @@ mod artifacts; + mod automations; + mod billing; + mod completions; ++mod environments; + pub(in crate::server) mod events; + pub(in crate::server) mod graph; + mod lifecycle; +@@ -158,6 +159,7 @@ pub(super) fn real_routes() -> Router> { + .merge(pull_requests::routes()) + .merge(artifacts::routes()) + .merge(automations::routes()) ++ .merge(environments::routes()) + .merge(sandbox::routes()) + .merge(sandboxes::routes()) + .merge(lifecycle::routes()) +diff --git a/lib/crates/fabro-server/tests/it/api/environments.rs b/lib/crates/fabro-server/tests/it/api/environments.rs +new file mode 100644 +index 000000000..fb0c9fbfa +--- /dev/null ++++ b/lib/crates/fabro-server/tests/it/api/environments.rs +@@ -0,0 +1,602 @@ ++use std::path::{Path, PathBuf}; ++ ++use axum::body::Body; ++use axum::http::{Method, Request, StatusCode, header}; ++use fabro_config::{RunEnvironmentLayer, RunLayer}; ++use fabro_server::server::build_router; ++use fabro_server::test_support::{ ++ TestAppStateBuilder, build_test_router, default_test_server_settings, test_auth_mode, ++}; ++use serde_json::{Value, json}; ++use tower::ServiceExt; ++ ++use crate::helpers::{api, checked_response, response_json, response_status}; ++ ++fn environment_settings(provider: &str) -> Value { ++ json!({ ++ "provider": provider, ++ "image": { ++ "docker": if provider == "docker" { json!("alpine:3.20") } else { Value::Null }, ++ "dockerfile": null ++ }, ++ "resources": { ++ "cpu": null, ++ "memory": null, ++ "disk": null ++ }, ++ "network": { ++ "mode": "allow_all", ++ "allow": [] ++ }, ++ "lifecycle": { ++ "preserve": false, ++ "stop_on_terminal": true, ++ "auto_stop": null ++ }, ++ "labels": {}, ++ "volumes": [], ++ "env": {} ++ }) ++} ++ ++fn environment_body(id: &str, provider: &str) -> Value { ++ let mut body = environment_settings(provider); ++ body["id"] = json!(id); ++ body ++} ++ ++fn environment_app() -> (axum::Router, tempfile::TempDir, PathBuf) { ++ let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created"); ++ let active_config_path = temp_dir.path().join("settings.toml"); ++ let environment_dir = temp_dir.path().join("environments"); ++ let state = TestAppStateBuilder::new() ++ .active_config_path(active_config_path) ++ .build(); ++ (build_test_router(state), temp_dir, environment_dir) ++} ++ ++fn environment_app_with_default_environment( ++ environment_id: &str, ++) -> (axum::Router, tempfile::TempDir) { ++ let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created"); ++ let active_config_path = temp_dir.path().join("settings.toml"); ++ let manifest_run_defaults = RunLayer { ++ environment: Some(RunEnvironmentLayer { ++ id: Some(environment_id.to_string()), ++ ..RunEnvironmentLayer::default() ++ }), ++ ..RunLayer::default() ++ }; ++ let state = TestAppStateBuilder::new() ++ .runtime_settings(default_test_server_settings(), manifest_run_defaults) ++ .active_config_path(active_config_path) ++ .build(); ++ (build_test_router(state), temp_dir) ++} ++ ++fn json_request(method: Method, path: &str, body: &Value) -> Request { ++ Request::builder() ++ .method(method) ++ .uri(api(path)) ++ .header(header::CONTENT_TYPE, "application/json") ++ .body(Body::from( ++ serde_json::to_vec(body).expect("environment fixture should serialize"), ++ )) ++ .expect("environment JSON request should build") ++} ++ ++fn empty_request(method: Method, path: &str) -> Request { ++ Request::builder() ++ .method(method) ++ .uri(api(path)) ++ .body(Body::empty()) ++ .expect("environment request should build") ++} ++ ++fn request_with_if_match( ++ method: Method, ++ path: &str, ++ revision: &str, ++ body: Option, ++) -> Request { ++ let mut builder = Request::builder() ++ .method(method) ++ .uri(api(path)) ++ .header(header::IF_MATCH, revision); ++ let body = match body { ++ Some(value) => { ++ builder = builder.header(header::CONTENT_TYPE, "application/json"); ++ Body::from(serde_json::to_vec(&value).expect("environment fixture should serialize")) ++ } ++ None => Body::empty(), ++ }; ++ builder ++ .body(body) ++ .expect("environment If-Match request should build") ++} ++ ++async fn create_environment(app: &axum::Router, id: &str, provider: &str) -> Value { ++ create_environment_with_body(app, &environment_body(id, provider)).await ++} ++ ++async fn create_environment_with_body(app: &axum::Router, body: &Value) -> Value { ++ let response = app ++ .clone() ++ .oneshot(json_request(Method::POST, "/environments", body)) ++ .await ++ .expect("create environment should respond"); ++ response_json(response, StatusCode::CREATED, "POST /api/v1/environments").await ++} ++ ++fn revision_from(body: &Value) -> &str { ++ body["revision"] ++ .as_str() ++ .expect("environment response should include a revision") ++} ++ ++async fn persisted_environment_toml(environment_dir: &Path, id: &str) -> toml::Value { ++ let persisted = tokio::fs::read_to_string(environment_dir.join(format!("{id}.toml"))) ++ .await ++ .expect("persisted environment TOML should be readable"); ++ toml::from_str(&persisted).expect("persisted environment TOML should parse") ++} ++ ++async fn system_info(app: &axum::Router) -> Value { ++ let response = app ++ .clone() ++ .oneshot(empty_request(Method::GET, "/system/info")) ++ .await ++ .expect("system info should respond"); ++ response_json(response, StatusCode::OK, "GET /api/v1/system/info").await ++} ++ ++#[tokio::test] ++async fn list_environments_returns_seeded_catalog_sorted_by_id() { ++ let (app, _temp_dir, _environment_dir) = environment_app(); ++ ++ let response = app ++ .oneshot(empty_request(Method::GET, "/environments")) ++ .await ++ .expect("list environments should respond"); ++ let body = response_json(response, StatusCode::OK, "GET /api/v1/environments").await; ++ ++ assert_eq!(body["meta"]["total"], 4); ++ assert_eq!( ++ body["data"] ++ .as_array() ++ .expect("environment list data should be an array") ++ .iter() ++ .map(|environment| environment["id"] ++ .as_str() ++ .expect("environment should have id")) ++ .collect::>(), ++ vec!["daytona", "default", "docker", "local"] ++ ); ++} ++ ++#[tokio::test] ++async fn create_environment_persists_sibling_toml_and_is_visible() { ++ let (app, _temp_dir, environment_dir) = environment_app(); ++ ++ let created = create_environment(&app, "custom-env", "docker").await; ++ ++ assert_eq!(created["id"], "custom-env"); ++ assert_eq!(created["provider"], "docker"); ++ assert!(environment_dir.join("custom-env.toml").exists()); ++ ++ let retrieved = app ++ .clone() ++ .oneshot(empty_request(Method::GET, "/environments/custom-env")) ++ .await ++ .expect("get environment should respond"); ++ let retrieved = response_json( ++ retrieved, ++ StatusCode::OK, ++ "GET /api/v1/environments/custom-env", ++ ) ++ .await; ++ assert_eq!(retrieved["id"], "custom-env"); ++ ++ let list = app ++ .oneshot(empty_request(Method::GET, "/environments")) ++ .await ++ .expect("list environments should respond"); ++ let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await; ++ assert_eq!(list["meta"]["total"], 5); ++ assert!( ++ list["data"] ++ .as_array() ++ .expect("environment list data should be an array") ++ .iter() ++ .any(|environment| environment["id"] == "custom-env") ++ ); ++ ++ let persisted = persisted_environment_toml(&environment_dir, "custom-env").await; ++ assert_eq!( ++ persisted.get("provider").and_then(toml::Value::as_str), ++ Some("docker") ++ ); ++ assert!(persisted.get("id").is_none()); ++ assert!(persisted.get("revision").is_none()); ++} ++ ++#[tokio::test] ++async fn get_environment_returns_current_etag() { ++ let (app, _temp_dir, _environment_dir) = environment_app(); ++ let created = create_environment(&app, "etag-env", "local").await; ++ let revision = revision_from(&created); ++ ++ let response = app ++ .oneshot(empty_request(Method::GET, "/environments/etag-env")) ++ .await ++ .expect("get environment should respond"); ++ let response = checked_response( ++ response, ++ StatusCode::OK, ++ "GET /api/v1/environments/etag-env", ++ ) ++ .await; ++ ++ assert_eq!( ++ response ++ .headers() ++ .get(header::ETAG) ++ .expect("GET environment should include ETag"), ++ &format!("\"{revision}\"") ++ ); ++ let body = crate::helpers::body_json(response.into_body()).await; ++ assert_eq!(body["revision"], revision); ++} ++ ++#[tokio::test] ++async fn replace_environment_updates_file_and_returns_new_etag() { ++ let (app, _temp_dir, environment_dir) = environment_app(); ++ let created = create_environment(&app, "replace-env", "docker").await; ++ let revision = revision_from(&created); ++ let mut replacement = environment_settings("local"); ++ replacement["labels"] = json!({ "tier": "dev" }); ++ ++ let response = app ++ .oneshot(request_with_if_match( ++ Method::PUT, ++ "/environments/replace-env", ++ revision, ++ Some(replacement), ++ )) ++ .await ++ .expect("replace environment should respond"); ++ let response = checked_response( ++ response, ++ StatusCode::OK, ++ "PUT /api/v1/environments/replace-env", ++ ) ++ .await; ++ let etag = response ++ .headers() ++ .get(header::ETAG) ++ .expect("PUT environment should include ETag") ++ .to_str() ++ .expect("ETag should be ASCII") ++ .to_string(); ++ let body = crate::helpers::body_json(response.into_body()).await; ++ ++ assert_eq!(body["provider"], "local"); ++ assert_eq!(body["labels"]["tier"], "dev"); ++ assert_ne!(body["revision"], revision); ++ assert_eq!(etag, format!("\"{}\"", revision_from(&body))); ++ let persisted = persisted_environment_toml(&environment_dir, "replace-env").await; ++ assert_eq!( ++ persisted ++ .get("labels") ++ .and_then(toml::Value::as_table) ++ .and_then(|labels| labels.get("tier")) ++ .and_then(toml::Value::as_str), ++ Some("dev") ++ ); ++} ++ ++#[tokio::test] ++async fn replace_and_delete_environment_require_if_match() { ++ let (app, _temp_dir, _environment_dir) = environment_app(); ++ create_environment(&app, "match-env", "local").await; ++ ++ let replace_response = app ++ .clone() ++ .oneshot(json_request( ++ Method::PUT, ++ "/environments/match-env", ++ &environment_settings("docker"), ++ )) ++ .await ++ .expect("replace without If-Match should respond"); ++ response_status( ++ replace_response, ++ StatusCode::PRECONDITION_REQUIRED, ++ "PUT /api/v1/environments/match-env without If-Match", ++ ) ++ .await; ++ ++ let delete_response = app ++ .oneshot(empty_request(Method::DELETE, "/environments/match-env")) ++ .await ++ .expect("delete without If-Match should respond"); ++ response_status( ++ delete_response, ++ StatusCode::PRECONDITION_REQUIRED, ++ "DELETE /api/v1/environments/match-env without If-Match", ++ ) ++ .await; ++} ++ ++#[tokio::test] ++async fn stale_environment_replace_and_delete_return_conflict() { ++ let (app, _temp_dir, _environment_dir) = environment_app(); ++ let created = create_environment(&app, "stale-env", "docker").await; ++ let stale_revision = revision_from(&created).to_string(); ++ ++ let replaced = app ++ .clone() ++ .oneshot(request_with_if_match( ++ Method::PUT, ++ "/environments/stale-env", ++ &stale_revision, ++ Some(environment_settings("local")), ++ )) ++ .await ++ .expect("first replace should respond"); ++ response_status( ++ replaced, ++ StatusCode::OK, ++ "PUT /api/v1/environments/stale-env first replace", ++ ) ++ .await; ++ ++ let stale_replace = app ++ .clone() ++ .oneshot(request_with_if_match( ++ Method::PUT, ++ "/environments/stale-env", ++ &stale_revision, ++ Some(environment_settings("docker")), ++ )) ++ .await ++ .expect("stale replace should respond"); ++ response_status( ++ stale_replace, ++ StatusCode::CONFLICT, ++ "PUT /api/v1/environments/stale-env stale", ++ ) ++ .await; ++ ++ let stale_delete = app ++ .oneshot(request_with_if_match( ++ Method::DELETE, ++ "/environments/stale-env", ++ &stale_revision, ++ None, ++ )) ++ .await ++ .expect("stale delete should respond"); ++ response_status( ++ stale_delete, ++ StatusCode::CONFLICT, ++ "DELETE /api/v1/environments/stale-env stale", ++ ) ++ .await; ++} ++ ++#[tokio::test] ++async fn duplicate_environment_create_returns_conflict() { ++ let (app, _temp_dir, _environment_dir) = environment_app(); ++ create_environment(&app, "duplicate-env", "local").await; ++ ++ let response = app ++ .oneshot(json_request( ++ Method::POST, ++ "/environments", ++ &environment_body("duplicate-env", "docker"), ++ )) ++ .await ++ .expect("duplicate create should respond"); ++ ++ response_status( ++ response, ++ StatusCode::CONFLICT, ++ "POST /api/v1/environments duplicate", ++ ) ++ .await; ++} ++ ++#[tokio::test] ++async fn invalid_environment_id_and_if_match_return_bad_request() { ++ let (app, _temp_dir, _environment_dir) = environment_app(); ++ ++ let invalid_id = app ++ .clone() ++ .oneshot(empty_request(Method::GET, "/environments/Bad!")) ++ .await ++ .expect("invalid id request should respond"); ++ response_status( ++ invalid_id, ++ StatusCode::BAD_REQUEST, ++ "GET /api/v1/environments/Bad!", ++ ) ++ .await; ++ ++ create_environment(&app, "header-env", "local").await; ++ let invalid_header = app ++ .oneshot(request_with_if_match( ++ Method::PUT, ++ "/environments/header-env", ++ "not-a-revision", ++ Some(environment_settings("docker")), ++ )) ++ .await ++ .expect("invalid If-Match request should respond"); ++ response_status( ++ invalid_header, ++ StatusCode::BAD_REQUEST, ++ "PUT /api/v1/environments/header-env invalid If-Match", ++ ) ++ .await; ++} ++ ++#[tokio::test] ++async fn invalid_environment_settings_return_unprocessable_entity() { ++ let (app, _temp_dir, _environment_dir) = environment_app(); ++ let mut body = environment_body("invalid-env", "local"); ++ body["network"]["mode"] = json!("block"); ++ ++ let response = app ++ .oneshot(json_request(Method::POST, "/environments", &body)) ++ .await ++ .expect("invalid environment create should respond"); ++ ++ response_status( ++ response, ++ StatusCode::UNPROCESSABLE_ENTITY, ++ "POST /api/v1/environments invalid settings", ++ ) ++ .await; ++} ++ ++#[tokio::test] ++async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_contents() { ++ let (app, temp_dir, environment_dir) = environment_app(); ++ tokio::fs::write( ++ temp_dir.path().join("Dockerfile"), ++ "FROM private.example/secret\n", ++ ) ++ .await ++ .expect("secret Dockerfile fixture should be written"); ++ let mut body = environment_body("path-env", "docker"); ++ body["image"]["docker"] = Value::Null; ++ body["image"]["dockerfile"] = json!({ ++ "type": "path", ++ "path": "Dockerfile" ++ }); ++ ++ let response = app ++ .clone() ++ .oneshot(json_request(Method::POST, "/environments", &body)) ++ .await ++ .expect("path Dockerfile create should respond"); ++ let error = response_json( ++ response, ++ StatusCode::UNPROCESSABLE_ENTITY, ++ "POST /api/v1/environments Dockerfile path", ++ ) ++ .await; ++ ++ assert!(!environment_dir.join("path-env.toml").exists()); ++ assert!( ++ !serde_json::to_string(&error) ++ .expect("error body should serialize") ++ .contains("private.example/secret") ++ ); ++ let list = app ++ .oneshot(empty_request(Method::GET, "/environments")) ++ .await ++ .expect("list environments should respond"); ++ let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await; ++ assert!( ++ !list["data"] ++ .as_array() ++ .expect("environment list data should be an array") ++ .iter() ++ .any(|environment| environment["id"] == "path-env") ++ ); ++} ++ ++#[tokio::test] ++async fn delete_environment_removes_non_default_and_default_is_protected() { ++ let (app, _temp_dir, environment_dir) = environment_app(); ++ let created = create_environment(&app, "delete-env", "local").await; ++ let revision = revision_from(&created); ++ ++ let response = app ++ .clone() ++ .oneshot(request_with_if_match( ++ Method::DELETE, ++ "/environments/delete-env", ++ &format!("\"{revision}\""), ++ None, ++ )) ++ .await ++ .expect("delete environment should respond"); ++ response_status( ++ response, ++ StatusCode::NO_CONTENT, ++ "DELETE /api/v1/environments/delete-env", ++ ) ++ .await; ++ ++ assert!(!environment_dir.join("delete-env.toml").exists()); ++ let missing = app ++ .clone() ++ .oneshot(empty_request(Method::GET, "/environments/delete-env")) ++ .await ++ .expect("get deleted environment should respond"); ++ response_status( ++ missing, ++ StatusCode::NOT_FOUND, ++ "GET /api/v1/environments/delete-env after delete", ++ ) ++ .await; ++ ++ let default = app ++ .clone() ++ .oneshot(empty_request(Method::GET, "/environments/default")) ++ .await ++ .expect("get default environment should respond"); ++ let default = response_json(default, StatusCode::OK, "GET /api/v1/environments/default").await; ++ let protected = app ++ .oneshot(request_with_if_match( ++ Method::DELETE, ++ "/environments/default", ++ revision_from(&default), ++ None, ++ )) ++ .await ++ .expect("delete default environment should respond"); ++ response_status( ++ protected, ++ StatusCode::CONFLICT, ++ "DELETE /api/v1/environments/default", ++ ) ++ .await; ++} ++ ++#[tokio::test] ++async fn environment_routes_require_authenticated_user() { ++ let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created"); ++ let state = TestAppStateBuilder::new() ++ .active_config_path(temp_dir.path().join("settings.toml")) ++ .build(); ++ let app = build_router(state, test_auth_mode()); ++ ++ let response = app ++ .oneshot(empty_request(Method::GET, "/environments")) ++ .await ++ .expect("unauthenticated environment list should respond"); ++ ++ response_status( ++ response, ++ StatusCode::UNAUTHORIZED, ++ "GET /api/v1/environments without auth", ++ ) ++ .await; ++} ++ ++#[tokio::test] ++async fn create_environment_refreshes_cached_manifest_run_settings() { ++ let (app, _temp_dir) = environment_app_with_default_environment("api-default"); ++ ++ let before = system_info(&app).await; ++ assert_eq!(before["sandbox_provider"], "local"); ++ ++ create_environment(&app, "api-default", "daytona").await; ++ ++ let after = system_info(&app).await; ++ assert_eq!(after["sandbox_provider"], "daytona"); ++} +diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs +index 843501072..98bf2a4f8 100644 +--- a/lib/crates/fabro-server/tests/it/api/mod.rs ++++ b/lib/crates/fabro-server/tests/it/api/mod.rs +@@ -2,6 +2,7 @@ mod auth_sessions; + mod automations; + mod cli_auth_token; + mod docs; ++mod environments; + mod events; + mod install; + mod install_openai_compatible; +diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs +index fdfa8d894..c586b8b6e 100644 +--- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs ++++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs +@@ -141,6 +141,54 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() { + ); + } + ++#[test] ++fn environment_spec_and_sdk_expose_crud_without_dockerfile_paths() { ++ let spec = load_spec(); ++ let paths = spec ++ .get("paths") ++ .and_then(Value::as_mapping) ++ .expect("spec is missing `paths`"); ++ for path in ["/api/v1/environments", "/api/v1/environments/{id}"] { ++ assert!( ++ paths.contains_key(Value::String(path.to_string())), ++ "OpenAPI spec should expose {path}" ++ ); ++ } ++ ++ let generated_api = read_repo_file("lib/packages/fabro-api-client/src/api/environments-api.ts"); ++ assert!( ++ generated_api.contains("export class EnvironmentsApi"), ++ "generated TypeScript client should expose EnvironmentsApi" ++ ); ++ for operation in [ ++ "createEnvironment", ++ "deleteEnvironment", ++ "listEnvironments", ++ "replaceEnvironment", ++ "retrieveEnvironment", ++ ] { ++ assert!( ++ generated_api.contains(operation), ++ "generated EnvironmentsApi should expose {operation}" ++ ); ++ } ++ ++ let generated_image = read_repo_file( ++ "lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts", ++ ); ++ assert!( ++ !generated_image.contains("DockerfileSourcePath") && !generated_image.contains("'path'"), ++ "generated REST environment image model should not expose Dockerfile path sources" ++ ); ++ ++ let workflow_dockerfile = ++ read_repo_file("lib/packages/fabro-api-client/src/models/dockerfile-source.ts"); ++ assert!( ++ workflow_dockerfile.contains("DockerfileSourcePath"), ++ "workflow/settings Dockerfile schema should keep exposing path sources" ++ ); ++} ++ + #[tokio::test] + async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() { + let secret = "test-webhook-secret"; +diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +index 513fea0b3..5e97116d9 100644 +--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES ++++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +@@ -4,6 +4,7 @@ api/automations-api.ts + api/billing-api.ts + api/completions-api.ts + api/discovery-api.ts ++api/environments-api.ts + api/human-in-the-loop-api.ts + api/insights-api.ts + api/install-api.ts +@@ -94,6 +95,7 @@ models/completion-usage.ts + models/conclusion.ts + models/create-automation-request.ts + models/create-completion-request.ts ++models/create-environment-request.ts + models/create-run-pull-request-request.ts + models/create-run-session-request.ts + models/create-secret-request.ts +@@ -118,14 +120,19 @@ models/disk-usage-summary-row.ts + models/dockerfile-source-inline.ts + models/dockerfile-source-path.ts + models/dockerfile-source.ts ++models/environment-api-dockerfile-source-inline.ts ++models/environment-api-image-settings.ts + models/environment-image-settings.ts + models/environment-lifecycle-settings.ts ++models/environment-list-meta.ts ++models/environment-list-response.ts + models/environment-network-mode.ts + models/environment-network-settings.ts + models/environment-provider.ts + models/environment-resources-settings.ts + models/environment-settings.ts + models/environment-volume-settings.ts ++models/environment.ts + models/error-response-entry.ts + models/error-response.ts + models/event-envelope.ts +@@ -300,6 +307,7 @@ models/render-workflow-graph-direction.ts + models/render-workflow-graph-format.ts + models/render-workflow-graph-request.ts + models/replace-automation-request.ts ++models/replace-environment-request.ts + models/repo-check-response-permissions.ts + models/repo-check-response.ts + models/repository-ref.ts +diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts +index 608a29c63..3dd9c3391 100644 +--- a/lib/packages/fabro-api-client/src/api.ts ++++ b/lib/packages/fabro-api-client/src/api.ts +@@ -19,6 +19,7 @@ export * from './api/automations-api'; + export * from './api/billing-api'; + export * from './api/completions-api'; + export * from './api/discovery-api'; ++export * from './api/environments-api'; + export * from './api/human-in-the-loop-api'; + export * from './api/insights-api'; + export * from './api/install-api'; +diff --git a/lib/packages/fabro-api-client/src/api/environments-api.ts b/lib/packages/fabro-api-client/src/api/environments-api.ts +new file mode 100644 +index 000000000..6fbcd086e +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/api/environments-api.ts +@@ -0,0 +1,453 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++import type { Configuration } from '../configuration'; ++import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; ++import globalAxios from 'axios'; ++// Some imports not used depending on template conditions ++// @ts-ignore ++import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; ++// @ts-ignore ++import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; ++// @ts-ignore ++import type { CreateEnvironmentRequest } from '../models'; ++// @ts-ignore ++import type { Environment } from '../models'; ++// @ts-ignore ++import type { EnvironmentListResponse } from '../models'; ++// @ts-ignore ++import type { ErrorResponse } from '../models'; ++// @ts-ignore ++import type { ReplaceEnvironmentRequest } from '../models'; ++/** ++ * EnvironmentsApi - axios parameter creator ++ */ ++export const EnvironmentsApiAxiosParamCreator = function (configuration?: Configuration) { ++ return { ++ /** ++ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API. ++ * @summary Create environment ++ * @param {CreateEnvironmentRequest} createEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ createEnvironment: async (createEnvironmentRequest: CreateEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise => { ++ // verify required parameter 'createEnvironmentRequest' is not null or undefined ++ assertParamExists('createEnvironment', 'createEnvironmentRequest', createEnvironmentRequest) ++ const localVarPath = `/api/v1/environments`; ++ // use dummy base URL string because the URL constructor only accepts absolute URLs. ++ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); ++ let baseOptions; ++ if (configuration) { ++ baseOptions = configuration.baseOptions; ++ } ++ ++ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; ++ const localVarHeaderParameter = {} as any; ++ const localVarQueryParameter = {} as any; ++ ++ // authentication SessionCookie required ++ ++ // authentication BearerAuth required ++ // http bearer authentication required ++ await setBearerAuthToObject(localVarHeaderParameter, configuration) ++ ++ localVarHeaderParameter['Content-Type'] = 'application/json'; ++ localVarHeaderParameter['Accept'] = 'application/json'; ++ ++ setSearchParams(localVarUrlObj, localVarQueryParameter); ++ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; ++ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; ++ localVarRequestOptions.data = serializeDataIfNeeded(createEnvironmentRequest, localVarRequestOptions, configuration) ++ ++ return { ++ url: toPathString(localVarUrlObj), ++ options: localVarRequestOptions, ++ }; ++ }, ++ /** ++ * Deletes a non-default environment definition when `If-Match` matches the current environment revision. ++ * @summary Delete environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ deleteEnvironment: async (id: string, ifMatch: string, options: RawAxiosRequestConfig = {}): Promise => { ++ // verify required parameter 'id' is not null or undefined ++ assertParamExists('deleteEnvironment', 'id', id) ++ // verify required parameter 'ifMatch' is not null or undefined ++ assertParamExists('deleteEnvironment', 'ifMatch', ifMatch) ++ const localVarPath = `/api/v1/environments/{id}` ++ .replace(`{${"id"}}`, encodeURIComponent(String(id))); ++ // use dummy base URL string because the URL constructor only accepts absolute URLs. ++ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); ++ let baseOptions; ++ if (configuration) { ++ baseOptions = configuration.baseOptions; ++ } ++ ++ const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; ++ const localVarHeaderParameter = {} as any; ++ const localVarQueryParameter = {} as any; ++ ++ // authentication SessionCookie required ++ ++ // authentication BearerAuth required ++ // http bearer authentication required ++ await setBearerAuthToObject(localVarHeaderParameter, configuration) ++ ++ localVarHeaderParameter['Accept'] = 'application/json'; ++ ++ if (ifMatch != null) { ++ localVarHeaderParameter['If-Match'] = String(ifMatch); ++ } ++ setSearchParams(localVarUrlObj, localVarQueryParameter); ++ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; ++ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; ++ ++ return { ++ url: toPathString(localVarUrlObj), ++ options: localVarRequestOptions, ++ }; ++ }, ++ /** ++ * Returns all server-managed environment definitions, sorted by id. ++ * @summary List environments ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ listEnvironments: async (options: RawAxiosRequestConfig = {}): Promise => { ++ const localVarPath = `/api/v1/environments`; ++ // use dummy base URL string because the URL constructor only accepts absolute URLs. ++ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); ++ let baseOptions; ++ if (configuration) { ++ baseOptions = configuration.baseOptions; ++ } ++ ++ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; ++ const localVarHeaderParameter = {} as any; ++ const localVarQueryParameter = {} as any; ++ ++ // authentication SessionCookie required ++ ++ // authentication BearerAuth required ++ // http bearer authentication required ++ await setBearerAuthToObject(localVarHeaderParameter, configuration) ++ ++ localVarHeaderParameter['Accept'] = 'application/json'; ++ ++ setSearchParams(localVarUrlObj, localVarQueryParameter); ++ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; ++ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; ++ ++ return { ++ url: toPathString(localVarUrlObj), ++ options: localVarRequestOptions, ++ }; ++ }, ++ /** ++ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`. ++ * @summary Replace environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ replaceEnvironment: async (id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise => { ++ // verify required parameter 'id' is not null or undefined ++ assertParamExists('replaceEnvironment', 'id', id) ++ // verify required parameter 'ifMatch' is not null or undefined ++ assertParamExists('replaceEnvironment', 'ifMatch', ifMatch) ++ // verify required parameter 'replaceEnvironmentRequest' is not null or undefined ++ assertParamExists('replaceEnvironment', 'replaceEnvironmentRequest', replaceEnvironmentRequest) ++ const localVarPath = `/api/v1/environments/{id}` ++ .replace(`{${"id"}}`, encodeURIComponent(String(id))); ++ // use dummy base URL string because the URL constructor only accepts absolute URLs. ++ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); ++ let baseOptions; ++ if (configuration) { ++ baseOptions = configuration.baseOptions; ++ } ++ ++ const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; ++ const localVarHeaderParameter = {} as any; ++ const localVarQueryParameter = {} as any; ++ ++ // authentication SessionCookie required ++ ++ // authentication BearerAuth required ++ // http bearer authentication required ++ await setBearerAuthToObject(localVarHeaderParameter, configuration) ++ ++ localVarHeaderParameter['Content-Type'] = 'application/json'; ++ localVarHeaderParameter['Accept'] = 'application/json'; ++ ++ if (ifMatch != null) { ++ localVarHeaderParameter['If-Match'] = String(ifMatch); ++ } ++ setSearchParams(localVarUrlObj, localVarQueryParameter); ++ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; ++ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; ++ localVarRequestOptions.data = serializeDataIfNeeded(replaceEnvironmentRequest, localVarRequestOptions, configuration) ++ ++ return { ++ url: toPathString(localVarUrlObj), ++ options: localVarRequestOptions, ++ }; ++ }, ++ /** ++ * Returns one server-managed environment definition by id. ++ * @summary Retrieve environment ++ * @param {string} id Unique environment identifier. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ retrieveEnvironment: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { ++ // verify required parameter 'id' is not null or undefined ++ assertParamExists('retrieveEnvironment', 'id', id) ++ const localVarPath = `/api/v1/environments/{id}` ++ .replace(`{${"id"}}`, encodeURIComponent(String(id))); ++ // use dummy base URL string because the URL constructor only accepts absolute URLs. ++ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); ++ let baseOptions; ++ if (configuration) { ++ baseOptions = configuration.baseOptions; ++ } ++ ++ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; ++ const localVarHeaderParameter = {} as any; ++ const localVarQueryParameter = {} as any; ++ ++ // authentication SessionCookie required ++ ++ // authentication BearerAuth required ++ // http bearer authentication required ++ await setBearerAuthToObject(localVarHeaderParameter, configuration) ++ ++ localVarHeaderParameter['Accept'] = 'application/json'; ++ ++ setSearchParams(localVarUrlObj, localVarQueryParameter); ++ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; ++ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; ++ ++ return { ++ url: toPathString(localVarUrlObj), ++ options: localVarRequestOptions, ++ }; ++ }, ++ } ++}; ++ ++/** ++ * EnvironmentsApi - functional programming interface ++ */ ++export const EnvironmentsApiFp = function(configuration?: Configuration) { ++ const localVarAxiosParamCreator = EnvironmentsApiAxiosParamCreator(configuration) ++ return { ++ /** ++ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API. ++ * @summary Create environment ++ * @param {CreateEnvironmentRequest} createEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ async createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { ++ const localVarAxiosArgs = await localVarAxiosParamCreator.createEnvironment(createEnvironmentRequest, options); ++ const localVarOperationServerIndex = configuration?.serverIndex ?? 0; ++ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.createEnvironment']?.[localVarOperationServerIndex]?.url; ++ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); ++ }, ++ /** ++ * Deletes a non-default environment definition when `If-Match` matches the current environment revision. ++ * @summary Delete environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ async deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { ++ const localVarAxiosArgs = await localVarAxiosParamCreator.deleteEnvironment(id, ifMatch, options); ++ const localVarOperationServerIndex = configuration?.serverIndex ?? 0; ++ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.deleteEnvironment']?.[localVarOperationServerIndex]?.url; ++ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); ++ }, ++ /** ++ * Returns all server-managed environment definitions, sorted by id. ++ * @summary List environments ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ async listEnvironments(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { ++ const localVarAxiosArgs = await localVarAxiosParamCreator.listEnvironments(options); ++ const localVarOperationServerIndex = configuration?.serverIndex ?? 0; ++ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.listEnvironments']?.[localVarOperationServerIndex]?.url; ++ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); ++ }, ++ /** ++ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`. ++ * @summary Replace environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ async replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { ++ const localVarAxiosArgs = await localVarAxiosParamCreator.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options); ++ const localVarOperationServerIndex = configuration?.serverIndex ?? 0; ++ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.replaceEnvironment']?.[localVarOperationServerIndex]?.url; ++ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); ++ }, ++ /** ++ * Returns one server-managed environment definition by id. ++ * @summary Retrieve environment ++ * @param {string} id Unique environment identifier. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ async retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { ++ const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveEnvironment(id, options); ++ const localVarOperationServerIndex = configuration?.serverIndex ?? 0; ++ const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.retrieveEnvironment']?.[localVarOperationServerIndex]?.url; ++ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); ++ }, ++ } ++}; ++ ++/** ++ * EnvironmentsApi - factory interface ++ */ ++export const EnvironmentsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { ++ const localVarFp = EnvironmentsApiFp(configuration) ++ return { ++ /** ++ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API. ++ * @summary Create environment ++ * @param {CreateEnvironmentRequest} createEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise { ++ return localVarFp.createEnvironment(createEnvironmentRequest, options).then((request) => request(axios, basePath)); ++ }, ++ /** ++ * Deletes a non-default environment definition when `If-Match` matches the current environment revision. ++ * @summary Delete environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): AxiosPromise { ++ return localVarFp.deleteEnvironment(id, ifMatch, options).then((request) => request(axios, basePath)); ++ }, ++ /** ++ * Returns all server-managed environment definitions, sorted by id. ++ * @summary List environments ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ listEnvironments(options?: RawAxiosRequestConfig): AxiosPromise { ++ return localVarFp.listEnvironments(options).then((request) => request(axios, basePath)); ++ }, ++ /** ++ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`. ++ * @summary Replace environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise { ++ return localVarFp.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(axios, basePath)); ++ }, ++ /** ++ * Returns one server-managed environment definition by id. ++ * @summary Retrieve environment ++ * @param {string} id Unique environment identifier. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): AxiosPromise { ++ return localVarFp.retrieveEnvironment(id, options).then((request) => request(axios, basePath)); ++ }, ++ }; ++}; ++ ++/** ++ * EnvironmentsApi - object-oriented interface ++ */ ++export class EnvironmentsApi extends BaseAPI { ++ /** ++ * Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API. ++ * @summary Create environment ++ * @param {CreateEnvironmentRequest} createEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ public createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig) { ++ return EnvironmentsApiFp(this.configuration).createEnvironment(createEnvironmentRequest, options).then((request) => request(this.axios, this.basePath)); ++ } ++ ++ /** ++ * Deletes a non-default environment definition when `If-Match` matches the current environment revision. ++ * @summary Delete environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ public deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig) { ++ return EnvironmentsApiFp(this.configuration).deleteEnvironment(id, ifMatch, options).then((request) => request(this.axios, this.basePath)); ++ } ++ ++ /** ++ * Returns all server-managed environment definitions, sorted by id. ++ * @summary List environments ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ public listEnvironments(options?: RawAxiosRequestConfig) { ++ return EnvironmentsApiFp(this.configuration).listEnvironments(options).then((request) => request(this.axios, this.basePath)); ++ } ++ ++ /** ++ * Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`. ++ * @summary Replace environment ++ * @param {string} id Unique environment identifier. ++ * @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the `ETag` response header. ++ * @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ public replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig) { ++ return EnvironmentsApiFp(this.configuration).replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(this.axios, this.basePath)); ++ } ++ ++ /** ++ * Returns one server-managed environment definition by id. ++ * @summary Retrieve environment ++ * @param {string} id Unique environment identifier. ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ public retrieveEnvironment(id: string, options?: RawAxiosRequestConfig) { ++ return EnvironmentsApiFp(this.configuration).retrieveEnvironment(id, options).then((request) => request(this.axios, this.basePath)); ++ } ++} +diff --git a/lib/packages/fabro-api-client/src/models/create-environment-request.ts b/lib/packages/fabro-api-client/src/models/create-environment-request.ts +new file mode 100644 +index 000000000..0ad9dc556 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/create-environment-request.ts +@@ -0,0 +1,48 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentApiImageSettings } from './environment-api-image-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentNetworkSettings } from './environment-network-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentProvider } from './environment-provider'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentResourcesSettings } from './environment-resources-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentVolumeSettings } from './environment-volume-settings'; ++ ++/** ++ * Request body for creating a server-managed environment. ++ */ ++export interface CreateEnvironmentRequest { ++ 'id': string; ++ 'provider': EnvironmentProvider; ++ 'image': EnvironmentApiImageSettings; ++ 'resources': EnvironmentResourcesSettings; ++ 'network': EnvironmentNetworkSettings; ++ 'lifecycle': EnvironmentLifecycleSettings; ++ 'labels': { [key: string]: string; }; ++ 'volumes': Array; ++ 'env': { [key: string]: string; }; ++} +diff --git a/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts b/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts +new file mode 100644 +index 000000000..35180b10a +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts +@@ -0,0 +1,26 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++ ++export interface EnvironmentApiDockerfileSourceInline { ++ 'type': EnvironmentApiDockerfileSourceInlineTypeEnum; ++ 'value': string; ++} ++ ++export const EnvironmentApiDockerfileSourceInlineTypeEnum = { ++ INLINE: 'inline' ++} as const; ++ ++export type EnvironmentApiDockerfileSourceInlineTypeEnum = typeof EnvironmentApiDockerfileSourceInlineTypeEnum[keyof typeof EnvironmentApiDockerfileSourceInlineTypeEnum]; +diff --git a/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts b/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts +new file mode 100644 +index 000000000..54578b0cd +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts +@@ -0,0 +1,26 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentApiDockerfileSourceInline } from './environment-api-dockerfile-source-inline'; ++ ++/** ++ * REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API. ++ */ ++export interface EnvironmentApiImageSettings { ++ 'docker': string | null; ++ 'dockerfile': EnvironmentApiDockerfileSourceInline | null; ++} +diff --git a/lib/packages/fabro-api-client/src/models/environment-list-meta.ts b/lib/packages/fabro-api-client/src/models/environment-list-meta.ts +new file mode 100644 +index 000000000..aa92bad87 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/environment-list-meta.ts +@@ -0,0 +1,25 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++ ++/** ++ * Metadata for environment list responses. ++ */ ++export interface EnvironmentListMeta { ++ /** ++ * Total number of server-managed environment definitions. ++ */ ++ 'total': number; ++} +diff --git a/lib/packages/fabro-api-client/src/models/environment-list-response.ts b/lib/packages/fabro-api-client/src/models/environment-list-response.ts +new file mode 100644 +index 000000000..cf25725b1 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/environment-list-response.ts +@@ -0,0 +1,29 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { Environment } from './environment'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentListMeta } from './environment-list-meta'; ++ ++/** ++ * List envelope for environment definitions. ++ */ ++export interface EnvironmentListResponse { ++ 'data': Array; ++ 'meta': EnvironmentListMeta; ++} +diff --git a/lib/packages/fabro-api-client/src/models/environment.ts b/lib/packages/fabro-api-client/src/models/environment.ts +new file mode 100644 +index 000000000..6451f9d57 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/environment.ts +@@ -0,0 +1,52 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentApiImageSettings } from './environment-api-image-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentNetworkSettings } from './environment-network-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentProvider } from './environment-provider'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentResourcesSettings } from './environment-resources-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentVolumeSettings } from './environment-volume-settings'; ++ ++/** ++ * Public server-managed environment definition. ++ */ ++export interface Environment { ++ 'id': string; ++ /** ++ * Stable revision used with `If-Match` for optimistic concurrency. ++ */ ++ 'revision': string; ++ 'provider': EnvironmentProvider; ++ 'image': EnvironmentApiImageSettings; ++ 'resources': EnvironmentResourcesSettings; ++ 'network': EnvironmentNetworkSettings; ++ 'lifecycle': EnvironmentLifecycleSettings; ++ 'labels': { [key: string]: string; }; ++ 'volumes': Array; ++ 'env': { [key: string]: string; }; ++} +diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts +index e66b048ca..87b9c189c 100644 +--- a/lib/packages/fabro-api-client/src/models/index.ts ++++ b/lib/packages/fabro-api-client/src/models/index.ts +@@ -68,6 +68,7 @@ export * from './completion-usage'; + export * from './conclusion'; + export * from './create-automation-request'; + export * from './create-completion-request'; ++export * from './create-environment-request'; + export * from './create-run-pull-request-request'; + export * from './create-run-session-request'; + export * from './create-secret-request'; +@@ -92,8 +93,13 @@ export * from './disk-usage-summary-row'; + export * from './dockerfile-source'; + export * from './dockerfile-source-inline'; + export * from './dockerfile-source-path'; ++export * from './environment'; ++export * from './environment-api-dockerfile-source-inline'; ++export * from './environment-api-image-settings'; + export * from './environment-image-settings'; + export * from './environment-lifecycle-settings'; ++export * from './environment-list-meta'; ++export * from './environment-list-response'; + export * from './environment-network-mode'; + export * from './environment-network-settings'; + export * from './environment-provider'; +@@ -273,6 +279,7 @@ export * from './render-workflow-graph-direction'; + export * from './render-workflow-graph-format'; + export * from './render-workflow-graph-request'; + export * from './replace-automation-request'; ++export * from './replace-environment-request'; + export * from './repo-check-response'; + export * from './repo-check-response-permissions'; + export * from './repository-ref'; +diff --git a/lib/packages/fabro-api-client/src/models/replace-environment-request.ts b/lib/packages/fabro-api-client/src/models/replace-environment-request.ts +new file mode 100644 +index 000000000..56bf4913e +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/replace-environment-request.ts +@@ -0,0 +1,47 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentApiImageSettings } from './environment-api-image-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentNetworkSettings } from './environment-network-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentProvider } from './environment-provider'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentResourcesSettings } from './environment-resources-settings'; ++// May contain unused imports in some cases ++// @ts-ignore ++import type { EnvironmentVolumeSettings } from './environment-volume-settings'; ++ ++/** ++ * Request body for replacing a server-managed environment. The path id is authoritative. ++ */ ++export interface ReplaceEnvironmentRequest { ++ 'provider': EnvironmentProvider; ++ 'image': EnvironmentApiImageSettings; ++ 'resources': EnvironmentResourcesSettings; ++ 'network': EnvironmentNetworkSettings; ++ 'lifecycle': EnvironmentLifecycleSettings; ++ 'labels': { [key: string]: string; }; ++ 'volumes': Array; ++ 'env': { [key: string]: string; }; ++} diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..34c7066f9 --- /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-29T18:29:49.348261Z" +} \ 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..ea61b8cff --- /dev/null +++ b/stages/006-simplify_opus@1/prompt.md @@ -0,0 +1,207 @@ +Goal: --- +title: "feat: Add Environment REST CRUD API" +type: feat +status: active +date: 2026-05-28 +--- + +# feat: Add Environment REST CRUD API + +## Summary + +Add server-owned Environment CRUD under `/api/v1/environments`, modeled after +Automations and backed by the existing `EnvironmentStore`. The API manages only +the server-side environment catalog in `environments/*.toml`; client-side +environment definitions in `workflow.toml`, `.fabro/project.toml`, or run inputs +continue to work and are not managed by this API. + +## API Contract + +- Add OpenAPI paths: + - `GET /api/v1/environments` + - `POST /api/v1/environments` + - `GET /api/v1/environments/{id}` + - `PUT /api/v1/environments/{id}` + - `DELETE /api/v1/environments/{id}` +- Mirror Automations semantics: + - List returns `{ data: Environment[], meta: { total } }`, sorted by id. + - Create body includes `id`; replace body omits `id`; path id is authoritative. + - `GET` and `PUT` return `ETag: ""`. + - `PUT` and `DELETE` require `If-Match`. + - Use existing Automation-style statuses: `400`, `404`, `409`, `422`, `428`, `500`. + - Stale revisions return `409` to match Automations. +- Add API-specific Environment request/response schemas so REST `image.dockerfile` + accepts only inline content or `null`. + - Existing workflow/settings schemas keep supporting Dockerfile `path`. + - REST requests with Dockerfile `path` return `422` and must not read + server-local files. +- Do not add `PATCH` in v1. + +## Implementation Changes + +- OpenAPI and generated clients: + - Update `docs/public/api-reference/fabro-api.yaml` with an `Environments` tag, + an `EnvironmentId` parameter, CRUD paths, list envelope, and inline-only API + image schema. + - Regenerate Rust API types and the TypeScript Axios client. + - Keep the existing `EnvironmentSettings` schema intact for workflow settings. +- Server: + - Add `lib/crates/fabro-server/src/server/handler/environments.rs`, following + `automations.rs` for routes, auth, ETag parsing, and error mapping. + - Merge the routes into real API routes; do not add demo routes unless an + existing convention requires it. + - Convert API request DTOs into `EnvironmentDraft` / `EnvironmentSettings` only + after rejecting Dockerfile path sources. + - Map `EnvironmentStoreError` similarly to Automations: duplicate, protected, + and stale as `409`; missing as `404`; validation as `422`; internal + storage/parse/io as curated `500`. + - After successful create, replace, or delete, refresh cached manifest run + settings from the current `EnvironmentStore` catalog so `/system/info` and + default run settings reflect the updated catalog. +- Domain and API types: + - Use a meaningful API DTO boundary rather than treating REST and TOML as + identical Dockerfile-source surfaces. + - Reuse `fabro-environment::Environment` for persisted domain behavior where + the wire shape matches; keep API-only request schemas distinct where + inline-only Dockerfile behavior differs. + +## Implementation Units + +- [ ] **Unit 1: Define the OpenAPI contract** + - Add the environment CRUD paths, schemas, and path parameter. + - Ensure the spec distinguishes REST-safe inline Dockerfile sources from the + existing workflow/settings Dockerfile source schema. + - Verification: OpenAPI route conformance can see the new paths and generated + clients expose an `EnvironmentsApi`. + +- [ ] **Unit 2: Add server environment handlers** + - Implement a new handler module mirroring the Automation CRUD handler shape. + - Enforce authentication, id parsing, ETag/If-Match behavior, and error mapping. + - Reject REST Dockerfile path sources before calling `EnvironmentStore`. + - Verification: server API tests prove CRUD behavior and failure responses. + +- [ ] **Unit 3: Refresh derived server state after mutations** + - Ensure successful environment create, replace, and delete refresh any cached + manifest run settings derived from `EnvironmentStore::catalog_layer()`. + - Preserve existing client-side environment precedence and behavior. + - Verification: a test proves newly created server environments affect the + resolved server default run environment where applicable. + +- [ ] **Unit 4: Regenerate clients and add contract tests** + - Regenerate `fabro-api` and `lib/packages/fabro-api-client`. + - Add Rust server integration tests and keep OpenAPI conformance passing. + - Verification: generated Rust and TypeScript surfaces compile and expose the + new environment operations. + +## Test Plan + +- Add server API tests in + `lib/crates/fabro-server/tests/it/api/environments.rs` and register the module. +- Cover: + - List returns seeded environments and correct total. + - Create persists `environments/{id}.toml`, returns `201`, and is visible via + list/get. + - Get returns current `ETag` matching `revision`. + - Replace with valid `If-Match` updates the file, returns a new revision, and + updates the `ETag`. + - Replace/delete without `If-Match` return `428`. + - Stale replace/delete return `409`. + - Duplicate create returns `409`. + - Invalid id/header returns `400`. + - Domain validation failures return `422`. + - Dockerfile `path` over REST returns `422` and does not persist or expose file + contents. + - Delete removes a non-default environment; deleting `default` returns a + protected conflict. + - Unauthenticated environment routes return `401`. + - Creating an environment referenced by server default run settings refreshes + cached manifest run settings. + +## Assumptions + +- This API manages server-owned environments only; client-defined catalogs remain + file/request scoped. +- Built-in seed behavior follows the current store: seeded environments are + listed, create conflicts with existing ids, and `default` is protected from + delete. +- Create responses match Automations and do not need an `ETag`; clients can use + the returned `revision` or call `GET`. +- Inline-only Dockerfile policy applies only to REST CRUD, not local TOML + configuration. + +## Sources + +- `docs/public/api-reference/fabro-api.yaml` +- `lib/crates/fabro-server/src/server/handler/automations.rs` +- `lib/crates/fabro-environment/src/store.rs` +- `lib/crates/fabro-environment/src/model.rs` +- `docs/public/execution/environments.mdx` + + +## 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, 2.2m tokens in / 38.1k 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..90e334155 --- /dev/null +++ b/stages/006-simplify_opus@1/response.md @@ -0,0 +1,28 @@ +## Summary + +Reviewed the Environment REST CRUD API diff (2,574 lines) with three parallel agents covering code reuse, code quality, and efficiency. + +### Fixed + +**Collapsed API DTO duplication in `lib/crates/fabro-server/src/server/handler/environments.rs`** (-27 net lines): +- Removed the redundant `ApiEnvironmentSettings` middleman struct (an exact field-by-field clone of `CreateEnvironmentRequest`/`ReplaceEnvironmentRequest`). +- Removed both `into_settings`/`into_draft` trampoline impls that constructed `ApiEnvironmentSettings` only to shell out to a shared converter. +- `CreateEnvironmentRequest::into_draft` and `ReplaceEnvironmentRequest::into_settings` now build `EnvironmentSettings` directly. +- Replaced `ApiDockerfileSource::Path { _path: String }` with `_path: IgnoredAny`, removing the per-request `String` allocation for a payload that's parsed and discarded. + +### Verified + +- `cargo +nightly clippy --workspace --all-targets -- -D warnings` ✅ +- 13/13 `api::environments` tests pass +- 4/4 `openapi_conformance` tests pass +- 170/170 `fabro-api` tests pass (including round-trip) +- 16/16 `fabro-environment` tests pass + +### Reviewed but not fixed (with rationale) + +- **Shared etag/parse helpers across automations.rs and environments.rs** (High priority from reuse review): The five helpers (`parse_path_id`, `parse_required_if_match`, `unquote_etag`, `*_with_etag_response`, `From<*StoreError> for ApiError`) are byte-twins with the automations versions. Extracting them requires changes to both modules and adds trait machinery; better tackled when a third resource lands. +- **Catalog deep clone in `resolve_manifest_run_settings_with_catalog`** (Medium efficiency): Would require restructuring `SettingsLayer.environments` from owned `MergeMap` to `Arc` across fabro-config. Environment writes are admin-only on small catalogs; deferred. +- **`Environment::from_settings` extra resolve round-trip** (Medium efficiency): The added `resolve_environment(&persisted)` call is intentional — it's the validation step that powers the new `create_invalid_settings_is_rejected` test. Correct as-is. +- **List pagination** (Medium efficiency): Out of scope; would require an OpenAPI spec change. +- **`EnvironmentListResponse`/`EnvironmentListMeta` generalization** (Medium reuse): Same pattern as `AutomationListResponse`; cross-cutting refactor worth doing later. +- **OpenAPI YAML repeats every 4xx/5xx response body** (Low quality): Pre-existing pattern across automations and other paths; systemic, not introduced by this PR. \ No newline at end of file