From 037073d2b20d4acf7554c5f85eb04d46143b97ee Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 17:30:57 -0400 Subject: [PATCH 1/5] feat: Add Environment REST CRUD API under /api/v1/environments (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a server-managed Environment CRUD API at `/api/v1/environments`, modeled after the existing Automations API and backed by `EnvironmentStore`. The API manages only server-side environment definitions in `environments/*.toml`; client-side catalogs (workflow, project TOML, run inputs) are unaffected. ### Plan Summary - **OpenAPI contract**: new `Environments` tag, `EnvironmentId` path parameter, five CRUD paths, list envelope, and REST-specific inline-only image schema (`EnvironmentApiImageSettings`) - **Server handler** (`environments.rs`): mirrors `automations.rs` — auth guard, ETag/If-Match, and `EnvironmentStoreError → ApiError` mapping - **Shared handler utilities**: `parse_required_if_match` and `json_with_etag_response` extracted from `automations.rs` into `handler/mod.rs` so both modules share them - **Inline-only Dockerfile enforcement**: `ApiDockerfileSource::Path` is parsed and immediately rejected with `422`; the file is never read - **Manifest refresh**: `refresh_manifest_run_settings_from_environment_catalog()` called after create, replace, and delete so `/system/info` and default run settings stay consistent - **Client regeneration**: TypeScript Axios client regenerated with `EnvironmentsApi` and new model files; Rust `fabro-api` type aliases updated - **Tests**: integration suite in `tests/it/api/environments.rs` covering all CRUD paths, error cases, and the manifest-refresh invariant; OpenAPI conformance test verifies generated surfaces ## Key Design Decisions **Inline-only Dockerfile at the REST boundary.** Allowing `path` sources over REST would let callers silently read arbitrary server-local files into the environment catalog. The handler recognizes the `path` discriminant so it can return a descriptive `422` rather than a generic parse error, but the payload is discarded via `IgnoredAny` — no disk access occurs. **Shared ETag utilities instead of per-handler helpers.** The original `parse_required_if_match` and ETag header builder in `automations.rs` were duplicated for environments. They're now generic over any `FromStr` revision type in `handler/mod.rs`, making future resource handlers cheaper to add. **`Environment` response type aliased to domain type.** The OpenAPI-generated `Environment` response struct is replaced with `fabro_environment::Environment` via `build.rs` `with_replacement`. A compile-time function-cast witness in `fabro-api/tests/environment_round_trip.rs` confirms the alias holds. Request types (`CreateEnvironmentRequest`, `ReplaceEnvironmentRequest`) stay API-specific because their image schema differs from the workflow/settings schema. **Stale revision → `409`.** Consistent with Automations; `428` is reserved for missing `If-Match` only. ### Fabro Details
Ran 8 stages in 59m 23s for $30.41 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 1s | – | 0 | | preflight_compile | 2m 9s | – | 0 | | preflight_lint | 2m 25s | – | 0 | | implement | 25m 43s | $19.79 | 0 | | simplify_opus | 14m 34s | $6.98 | 0 | | simplify_gpt | 4m 39s | $3.64 | 0 | | verify | 9m 14s | – | 0 | | **Total** | **59m 23s** | **$30.41** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- Cargo.lock | 1 + docs/public/api-reference/fabro-api.yaml | 453 +++++++++++++ lib/crates/fabro-api/Cargo.toml | 1 + lib/crates/fabro-api/build.rs | 1 + lib/crates/fabro-api/src/lib.rs | 1 + .../fabro-api/tests/environment_round_trip.rs | 88 +++ lib/crates/fabro-environment/src/model.rs | 1 + lib/crates/fabro-environment/src/store.rs | 26 +- lib/crates/fabro-server/src/server.rs | 12 + .../src/server/handler/automations.rs | 44 +- .../src/server/handler/environments.rs | 260 ++++++++ .../fabro-server/src/server/handler/mod.rs | 53 +- .../fabro-server/tests/it/api/environments.rs | 602 ++++++++++++++++++ lib/crates/fabro-server/tests/it/api/mod.rs | 1 + .../tests/it/openapi_conformance.rs | 48 ++ .../src/.openapi-generator/FILES | 8 + lib/packages/fabro-api-client/src/api.ts | 1 + .../src/api/environments-api.ts | 453 +++++++++++++ .../src/models/create-environment-request.ts | 48 ++ ...nvironment-api-dockerfile-source-inline.ts | 26 + .../models/environment-api-image-settings.ts | 26 + .../src/models/environment-list-meta.ts | 25 + .../src/models/environment-list-response.ts | 29 + .../src/models/environment.ts | 52 ++ .../fabro-api-client/src/models/index.ts | 7 + .../src/models/replace-environment-request.ts | 47 ++ 26 files changed, 2274 insertions(+), 40 deletions(-) create mode 100644 lib/crates/fabro-api/tests/environment_round_trip.rs create mode 100644 lib/crates/fabro-server/src/server/handler/environments.rs create mode 100644 lib/crates/fabro-server/tests/it/api/environments.rs create mode 100644 lib/packages/fabro-api-client/src/api/environments-api.ts create mode 100644 lib/packages/fabro-api-client/src/models/create-environment-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-api-dockerfile-source-inline.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-list-meta.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment-list-response.ts create mode 100644 lib/packages/fabro-api-client/src/models/environment.ts create mode 100644 lib/packages/fabro-api-client/src/models/replace-environment-request.ts 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/automations.rs b/lib/crates/fabro-server/src/server/handler/automations.rs index c29920d1d..51e09a980 100644 --- a/lib/crates/fabro-server/src/server/handler/automations.rs +++ b/lib/crates/fabro-server/src/server/handler/automations.rs @@ -1,11 +1,10 @@ use std::sync::Arc; -use axum::http::{HeaderMap, HeaderValue, header}; +use axum::http::HeaderMap; use axum_extra::extract::Query as ExtraQuery; use chrono::Utc; use fabro_automation::{ - Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision, - AutomationStoreError, + Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationStoreError, }; use fabro_config::Storage; use fabro_types::{AutomationRef, RunId}; @@ -15,7 +14,7 @@ use super::super::{ ApiError, AppState, IntoResponse, Json, PaginationParams, Path, RequiredUser, Response, Router, State, StatusCode, get, paginate_items, }; -use super::{lifecycle, runs}; +use super::{json_with_etag_response, lifecycle, parse_required_if_match, runs}; use crate::automation_materializer::AutomationRunMaterializeInput; use crate::principal_middleware::RequiredRunToolActor; @@ -227,7 +226,7 @@ async fn replace_automation( Json(replacement): Json, ) -> Result { let id = parse_path_id(id)?; - let expected = parse_required_if_match(&headers, &id)?; + let expected = parse_required_if_match(&headers, "automation", &id)?; let automation = state .automation_store() .replace(&id, &expected, replacement) @@ -242,7 +241,7 @@ async fn delete_automation( Path(id): Path, ) -> Result { let id = parse_path_id(id)?; - let expected = parse_required_if_match(&headers, &id)?; + let expected = parse_required_if_match(&headers, "automation", &id)?; state.automation_store().delete(&id, &expected).await?; Ok(StatusCode::NO_CONTENT.into_response()) } @@ -252,38 +251,9 @@ fn parse_path_id(id: String) -> Result { .map_err(|err| ApiError::bad_request(format!("invalid automation id: {err}"))) } -fn parse_required_if_match( - headers: &HeaderMap, - id: &AutomationId, -) -> Result { - let Some(value) = headers.get(header::IF_MATCH) else { - return Err(ApiError::new( - StatusCode::PRECONDITION_REQUIRED, - format!("If-Match header is required for automation: {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 automation revision: {err}")) - }) -} - -fn unquote_etag(value: &str) -> &str { - value - .strip_prefix('"') - .and_then(|unquoted| unquoted.strip_suffix('"')) - .unwrap_or(value) -} - fn automation_with_etag_response(status: StatusCode, automation: Automation) -> Response { - let etag = HeaderValue::from_str(&format!("\"{}\"", automation.revision)) - .expect("automation revisions are valid ETag header values"); - let mut response = (status, Json(automation)).into_response(); - response.headers_mut().insert(header::ETAG, etag); - response + let revision = automation.revision.clone(); + json_with_etag_response(status, "automation", &revision, automation) } impl From for ApiError { 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..686bd089f --- /dev/null +++ b/lib/crates/fabro-server/src/server/handler/environments.rs @@ -0,0 +1,260 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use axum::http::HeaderMap; +use fabro_environment::{Environment, EnvironmentDraft, EnvironmentId, EnvironmentStoreError}; +use fabro_types::settings::InterpString; +use fabro_types::settings::run::{ + DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings, + EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings, + EnvironmentSettings, EnvironmentVolumeSettings, +}; +use serde::de::IgnoredAny; +use serde::{Deserialize, Serialize}; + +use super::super::{ + ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State, + StatusCode, get, +}; +use super::{json_with_etag_response, parse_required_if_match}; + +#[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, +} + +#[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, + }, + // Recognized so the handler can return a 422 with bespoke guidance. + // The `path` payload is parsed and discarded — never read from disk. + Path { + #[serde(rename = "path")] + _path: IgnoredAny, + }, +} + +impl CreateEnvironmentRequest { + fn into_draft(self) -> Result { + Ok(EnvironmentDraft { + id: self.id, + settings: 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 ReplaceEnvironmentRequest { + 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, "environment", &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, "environment", &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 environment_with_etag_response(status: StatusCode, environment: Environment) -> Response { + let revision = environment.revision.clone(); + json_with_etag_response(status, "environment", &revision, environment) +} + +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..5b931f76f 100644 --- a/lib/crates/fabro-server/src/server/handler/mod.rs +++ b/lib/crates/fabro-server/src/server/handler/mod.rs @@ -1,14 +1,17 @@ use std::sync::Arc; use axum::Router; +use axum::http::{HeaderMap, HeaderValue, header}; use axum::routing::{get, post}; +use serde::Serialize; -use super::{ApiError, AppState, IntoResponse, Response, StatusCode, demo}; +use super::{ApiError, AppState, IntoResponse, Json, Response, StatusCode, demo}; mod artifacts; mod automations; mod billing; mod completions; +mod environments; pub(in crate::server) mod events; pub(in crate::server) mod graph; mod lifecycle; @@ -31,6 +34,53 @@ async fn not_implemented() -> Response { ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response() } +fn parse_required_if_match( + headers: &HeaderMap, + resource: &str, + id: &impl std::fmt::Display, +) -> Result +where + R: std::str::FromStr, + R::Err: std::fmt::Display, +{ + let Some(value) = headers.get(header::IF_MATCH) else { + return Err(ApiError::new( + StatusCode::PRECONDITION_REQUIRED, + format!("If-Match header is required for {resource}: {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 {resource} revision: {err}")) + }) +} + +fn unquote_etag(value: &str) -> &str { + value + .strip_prefix('"') + .and_then(|unquoted| unquoted.strip_suffix('"')) + .unwrap_or(value) +} + +fn json_with_etag_response( + status: StatusCode, + resource: &str, + revision: &impl std::fmt::Display, + body: T, +) -> Response +where + T: Serialize, +{ + let etag = HeaderValue::from_str(&format!("\"{revision}\"")) + .unwrap_or_else(|_| panic!("{resource} revisions are valid ETag header values")); + let mut response = (status, Json(body)).into_response(); + response.headers_mut().insert(header::ETAG, etag); + response +} + pub(super) fn demo_routes() -> Router> { Router::new() .route("/runs", get(demo::list_runs).post(demo::create_run_stub)) @@ -158,6 +208,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; }; +} From a65473f2166744d61288a09544c0633ac07120d8 Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 22:18:04 -0400 Subject: [PATCH 2/5] Add "Create automation from run" prefill flow (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a frontend-only flow that lets users bootstrap a new automation from an existing run's metadata. The run actions menu grows a context-aware entry: ordinary runs get **Create automation from run** (navigates to `/automations/new?from_run=`); runs already tied to an automation get **View automation** instead. The `/automations/new` route reads the query param, loads the run + settings, and mounts a keyed form child pre-populated with the inferred values — no `useEffect` needed. ### Plan Summary - **`automation-form.tsx`** — new exported `automationFormValuesFromRun(run, settings)` helper plus three narrow private parsers (`githubRepositoryFromSettings`, `githubRepositoryName`, `githubRepositoryFromOriginUrl`) that only produce `owner/repo` for verifiable GitHub-style values and leave everything else blank. - **`automations-new.tsx`** — route split into a wrapper that reads `from_run`, calls `useRun`/`useRunSettings`, and either shows a loading placeholder, a graceful error fallback, or a keyed `` so initial state is set once from props rather than via effects. - **`run-detail.tsx`** — `automationAction` computed from `summary.automation?.id` and inserted into the `operations` group after Preview. - **Tests** — new `automations-new.test.tsx` covers empty form, prefill, and error-fallback paths; `run-detail.test.ts` extended with automation navigation assertions and refactored `makeRunSummary` to accept named params. ### Key design decisions | Decision | Rationale | |---|---| | Keyed child form (`key={`from-run:${id}`}`) | Lets React reset `useState` from props without `useEffect`, per the effects policy | | Wait for both queries before mounting | Prevents edits being overwritten when settings arrive after the run | | GitHub-only repository parsing | Narrow match avoids silently populating wrong values for non-GitHub or unknown providers | | No schedule inference | Prefilled automations default to manual/API trigger enabled, schedule disabled | | Navigation-only action | No disabled states for terminal/demo runs — it's just a link | ### Fabro Details
Ran 8 stages in 31m 21s for $11.39 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 6s | – | 0 | | preflight_compile | 2m 11s | – | 0 | | preflight_lint | 2m 31s | – | 0 | | implement | 10m 10s | $7.83 | 0 | | simplify_opus | 4m 23s | $2.16 | 0 | | simplify_gpt | 1m 34s | $1.40 | 0 | | verify | 9m 42s | – | 0 | | **Total** | **31m 21s** | **$11.39** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- .../app/components/automation-form.tsx | 87 ++++- .../app/routes/automations-new.test.tsx | 300 ++++++++++++++++++ apps/fabro-web/app/routes/automations-new.tsx | 66 +++- apps/fabro-web/app/routes/run-detail.test.ts | 89 +++++- apps/fabro-web/app/routes/run-detail.tsx | 13 + 5 files changed, 538 insertions(+), 17 deletions(-) create mode 100644 apps/fabro-web/app/routes/automations-new.test.tsx diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index 5f79f4a78..f7209dc7d 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -1,9 +1,15 @@ import { useRef, type ReactNode } from "react"; import { Switch } from "@headlessui/react"; -import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client"; +import type { + Automation, + AutomationTrigger, + Run, + WorkflowSettings, +} from "@qltysh/fabro-api-client"; import { Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; +import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; export interface AutomationFormValues { id: string; @@ -55,6 +61,37 @@ export function automationToFormValues(automation: Automation): AutomationFormVa }; } +export function automationFormValuesFromRun( + run: Run, + settings?: WorkflowSettings | null, +): AutomationFormValues { + const name = firstPresentString( + run.title, + run.workflow.name, + run.workflow.graph_name, + run.workflow.slug, + "New automation", + ); + const workflowName = firstPresentString( + run.workflow.name, + run.workflow.graph_name, + name, + ); + const repository = githubRepositoryFromSettings(settings) + ?? githubRepositoryName(run.repository?.name) + ?? githubRepositoryFromOriginUrl(run.repository?.origin_url) + ?? ""; + const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch; + return { + ...EMPTY_AUTOMATION_FORM, + id: kebabify(name), + name, + repository, + ref: cloneBranch ?? EMPTY_AUTOMATION_FORM.ref, + workflow: run.workflow.slug?.trim() || snakeify(workflowName), + }; +} + export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] { const triggers: AutomationTrigger[] = []; if (values.manualEnabled) { @@ -98,6 +135,54 @@ export function snakeify(value: string): string { .replace(/^_|_$/g, ""); } +function firstPresentString(...values: Array): string { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) return trimmed; + } + return ""; +} + +function githubRepositoryFromSettings( + settings?: WorkflowSettings | null, +): string | null { + const owner = settings?.run?.scm?.owner; + const repository = settings?.run?.scm?.repository; + if (!owner || !repository) return null; + return githubRepositoryName(`${owner}/${repository}`); +} + +function githubRepositoryName(value: string | null | undefined): string | null { + const trimmed = value?.trim().replace(/\.git$/i, ""); + if (!trimmed) return null; + + const match = trimmed.match(/^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+)$/); + if (!match) return null; + return `${match[1]}/${match[2]}`; +} + +function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + + const scpLikeMatch = trimmed.match( + /^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i, + ); + if (scpLikeMatch) { + return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`); + } + + try { + const url = new URL(trimmed); + if (url.hostname.toLowerCase() !== "github.com") return null; + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length !== 2) return null; + return githubRepositoryName(`${parts[0]}/${parts[1]}`); + } catch { + return null; + } +} + function describeCron(expression: string): string { const trimmed = expression.trim(); const preset = CRON_PRESETS.find((p) => p.value === trimmed); diff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx new file mode 100644 index 000000000..47f471abf --- /dev/null +++ b/apps/fabro-web/app/routes/automations-new.test.tsx @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { createElement } from "react"; +import TestRenderer, { act } from "react-test-renderer"; +import { createMemoryRouter, RouterProvider } from "react-router"; + +import { ToastProvider } from "../components/toast"; +import { setupReactTestEnv } from "../lib/test-utils"; + +let currentRun: any = null; +let currentRunError: unknown = null; +let currentRunLoading = false; +let currentRunSettings: any = null; +const queryCalls: Array<{ hook: string; id: string | undefined }> = []; +const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; +let teardownReactEnv: (() => void) | undefined; + +const createAutomationMock = mock((_payload: unknown) => + Promise.resolve({ data: {} }), +); +const swrMutateMock = mock((_key: unknown) => Promise.resolve(undefined)); + +mock.module("@headlessui/react", () => ({ + Dialog: ({ open, children }: any) => + open ? createElement("div", { role: "dialog" }, children) : null, + DialogPanel: ({ children, ...props }: any) => + createElement("div", props, children), + DialogTitle: ({ children, ...props }: any) => + createElement("h2", props, children), + Switch: ({ checked, onChange, children, ...props }: any) => + createElement( + "button", + { + ...props, + type: "button", + role: "switch", + "aria-checked": checked, + onClick: () => onChange(!checked), + }, + children, + ), +})); + +mock.module("../lib/queries", () => ({ + useRun: (id: string | undefined) => { + queryCalls.push({ hook: "useRun", id }); + return { + data: currentRun, + error: currentRunError, + isLoading: currentRunLoading, + }; + }, + useRunSettings: (id: string | undefined) => { + queryCalls.push({ hook: "useRunSettings", id }); + return { + data: currentRunSettings, + error: null, + isLoading: false, + }; + }, +})); + +mock.module("../lib/api-client", () => ({ + ApiError: class ApiError extends Error { + readonly status: number; + readonly requestId: string | null; + readonly body: unknown; + + constructor({ + status, + message, + requestId, + body, + }: { + status: number; + message: string; + requestId: string | null; + body: unknown; + }) { + super(message); + this.name = "ApiError"; + this.status = status; + this.requestId = requestId; + this.body = body; + } + }, + apiData: async function apiData( + call: () => Promise<{ data: T }>, + ): Promise { + const response = await call(); + return response.data; + }, + automationsApi: { + createAutomation: createAutomationMock, + }, +})); + +mock.module("swr", () => ({ + useSWRConfig: () => ({ mutate: swrMutateMock }), +})); + +const { default: AutomationsNew } = await import("./automations-new"); +mock.restore(); + +function makeRun(overrides: Record = {}) { + return { + id: "run_1", + children_count: 0, + goal: "Fix CI", + title: "Fix failing tests", + workflow: { + slug: "fix_ci", + name: "Fix CI", + graph_name: "ci_graph", + node_count: 0, + edge_count: 0, + }, + automation: null, + repository: { + name: "fallback/repo", + origin_url: "https://github.com/fallback/repo.git", + provider: "github", + }, + created_by: null, + origin: { kind: "api" }, + labels: {}, + lifecycle: { + status: { kind: "succeeded", reason: "completed" }, + approval: null, + pending_control: null, + queue_position: null, + error: null, + archived: false, + archived_at: null, + }, + sandbox: { + kind: "ready", + plan: { provider: "docker", image: null, snapshot: null }, + instance: { + provider: "docker", + image: null, + snapshot: null, + runtime: { + id: "container_1", + working_directory: "/workspace", + repo_cloned: true, + clone_origin_url: "https://github.com/qltysh/fabro.git", + clone_branch: "feature/from-run", + }, + }, + }, + models: [], + source_directory: null, + timestamps: { + created_at: "2026-04-20T12:00:00Z", + started_at: null, + last_event_at: null, + completed_at: null, + }, + timing: null, + billing: null, + size: "XS", + ask_fabro: { + available: false, + unavailable_reason: "no_sandbox", + default_model: null, + }, + diff: null, + pull_request: null, + current_question: null, + superseded_by: null, + retried_from: null, + links: { web: null }, + ...overrides, + }; +} + +function makeRunSettings() { + return { + run: { + scm: { + provider: "github", + owner: "qltysh", + repository: "fabro", + github: null, + }, + }, + }; +} + +async function renderAutomationsNew(initialEntry: string) { + const router = createMemoryRouter( + [ + { + path: "/automations", + element:
Automations
, + }, + { + path: "/automations/new", + element: , + }, + ], + { initialEntries: [initialEntry] }, + ); + + let renderer!: TestRenderer.ReactTestRenderer; + await act(async () => { + renderer = TestRenderer.create( + + + , + ); + }); + mountedRenderers.push(renderer); + return { renderer, router }; +} + +function fieldValue(renderer: TestRenderer.ReactTestRenderer, label: string) { + return renderer.root.findByProps({ "aria-label": label }).props.value; +} + +function switchChecked(renderer: TestRenderer.ReactTestRenderer, label: string) { + const props = renderer.root.findByProps({ "aria-label": label }).props; + return props["aria-checked"] ?? props.checked; +} + +function textFromNode( + node: ReturnType, +): string { + if (!node) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(textFromNode).join(" "); + return (node.children ?? []).map(textFromNode).join(" "); +} + +beforeEach(() => { + teardownReactEnv = setupReactTestEnv(); + currentRun = null; + currentRunError = null; + currentRunLoading = false; + currentRunSettings = null; + queryCalls.length = 0; + createAutomationMock.mockClear(); + swrMutateMock.mockClear(); +}); + +afterEach(() => { + for (const renderer of mountedRenderers.splice(0)) { + act(() => renderer.unmount()); + } + teardownReactEnv?.(); + teardownReactEnv = undefined; +}); + +describe("AutomationsNew", () => { + test("/automations/new renders empty form values", async () => { + const { renderer } = await renderAutomationsNew("/automations/new"); + + expect(fieldValue(renderer, "Automation name")).toBe(""); + expect(fieldValue(renderer, "Automation slug")).toBe(""); + expect(fieldValue(renderer, "Repository")).toBe(""); + expect(fieldValue(renderer, "Default branch")).toBe("main"); + expect(fieldValue(renderer, "Workflow slug")).toBe(""); + expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true); + expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false); + }); + + test("/automations/new?from_run=run_1 pre-populates from run and settings data", async () => { + currentRun = makeRun(); + currentRunSettings = makeRunSettings(); + + const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1"); + + expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests"); + expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests"); + expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro"); + expect(fieldValue(renderer, "Default branch")).toBe("feature/from-run"); + expect(fieldValue(renderer, "Workflow slug")).toBe("fix_ci"); + expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true); + expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false); + expect( + renderer.root.findAllByProps({ "aria-label": "Cron expression" }), + ).toHaveLength(0); + expect(queryCalls).toContainEqual({ hook: "useRun", id: "run_1" }); + expect(queryCalls).toContainEqual({ hook: "useRunSettings", id: "run_1" }); + }); + + test("missing source run data renders an editable empty form with a non-blocking error", async () => { + currentRun = null; + currentRunError = new Error("not found"); + + const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1"); + + expect(textFromNode(renderer.toJSON())).toContain("could not be loaded"); + expect(textFromNode(renderer.toJSON())).toContain("fill it out manually"); + expect(fieldValue(renderer, "Automation name")).toBe(""); + expect(fieldValue(renderer, "Repository")).toBe(""); + expect(fieldValue(renderer, "Default branch")).toBe("main"); + expect(fieldValue(renderer, "Workflow slug")).toBe(""); + }); +}); diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx index 1ee221c35..dffc1465d 100644 --- a/apps/fabro-web/app/routes/automations-new.tsx +++ b/apps/fabro-web/app/routes/automations-new.tsx @@ -1,13 +1,15 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router"; +import { Link, useNavigate, useSearchParams } from "react-router"; import { useSWRConfig } from "swr"; import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { ApiError, apiData, automationsApi } from "../lib/api-client"; import { queryKeys } from "../lib/query-keys"; +import { useRun, useRunSettings } from "../lib/queries"; import { AutomationFormFields, EMPTY_AUTOMATION_FORM, + automationFormValuesFromRun, isFormValid, triggersFromFormValues, type AutomationFormValues, @@ -26,10 +28,69 @@ export function meta() { export const handle = { hideHeader: true }; export default function AutomationsNew() { + const [searchParams] = useSearchParams(); + const fromRunId = searchParams.get("from_run")?.trim() || undefined; + const runQuery = useRun(fromRunId); + const settingsQuery = useRunSettings(fromRunId); + + if (!fromRunId) { + return ( + + ); + } + + // Wait for both queries to settle before mounting the form, so the user's + // edits aren't blown away when settings arrive after the run. + const runPending = runQuery.isLoading && !runQuery.data; + const settingsPending = settingsQuery.isLoading && !settingsQuery.data; + if (runPending || settingsPending) { + return ( +
+ +

+ Loading source run… +

+
+ ); + } + + if (!runQuery.data) { + return ( + + ); + } + + const initialValues = automationFormValuesFromRun( + runQuery.data, + settingsQuery.data ?? null, + ); + + return ( + + ); +} + +function AutomationCreateForm({ + initialValues, + sourceError = null, +}: { + initialValues: AutomationFormValues; + sourceError?: string | null; +}) { const navigate = useNavigate(); const { mutate } = useSWRConfig(); const toast = useToast(); - const [values, setValues] = useState(EMPTY_AUTOMATION_FORM); + const [values, setValues] = useState(initialValues); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -75,6 +136,7 @@ export default function AutomationsNew() { + {sourceError ? : null} {error ? : null} ({ data: currentRunSummary, isLoading: false, }), + useRunSettings: () => ({ + data: null, + isLoading: false, + }), useRunQuestions: () => ({ data: currentQuestions, }), @@ -184,13 +188,21 @@ type RunDetailActionResult = import("./run-detail/lifecycle-toasts").RunDetailAc const h = createElement; -function makeRunSummary( +function makeRunSummary({ status = "succeeded", - diffSummary: any = null, - pullRequest: any = null, + diffSummary = null as any, + pullRequest = null as any, title = "Run 1", - askFabro: any = null, -) { + askFabro = null as any, + automation = null as any, +}: { + status?: string; + diffSummary?: any; + pullRequest?: any; + title?: string; + askFabro?: any; + automation?: any; +} = {}) { const apiStatus = status === "succeeded" ? { kind: "succeeded", reason: "completed" } @@ -207,7 +219,7 @@ function makeRunSummary( goal: "Run 1", title, workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 }, - automation: null, + automation, repository: { name: "fabro", origin_url: null, provider: "unknown" }, created_by: null, origin: { kind: "api" }, @@ -269,6 +281,7 @@ async function renderRunDetailHarness({ pullRequest = null, title, askFabro = null, + automation = null, }: { initialEntry: string; status?: string; @@ -277,8 +290,9 @@ async function renderRunDetailHarness({ pullRequest?: any; title?: string; askFabro?: any; + automation?: any; }) { - currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title, askFabro); + currentRunSummary = makeRunSummary({ status, diffSummary, pullRequest, title, askFabro, automation }); currentQuestions = questions; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -303,6 +317,14 @@ async function renderRunDetailHarness({ }, ], }, + { + path: "/automations/new", + element: h("div", { "data-route": "automations-new" }, "New automation"), + }, + { + path: "/automations/:automationId", + element: h("div", { "data-route": "automation-detail" }, "Automation detail"), + }, ], { initialEntries: [initialEntry] }, ); @@ -364,13 +386,20 @@ function textFromTestNode(node: TestRenderer.ReactTestInstance): string { }).join(""); } -function findButtonByText( +function findButtonsByText( renderer: TestRenderer.ReactTestRenderer, text: string, ) { return renderer.root.findAll( (node) => node.type === "button" && textFromTestNode(node).includes(text), - )[0]; + ); +} + +function findButtonByText( + renderer: TestRenderer.ReactTestRenderer, + text: string, +) { + return findButtonsByText(renderer, text)[0]; } function deferred() { @@ -474,7 +503,7 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "cancel", ok: true, - run: makeRunSummary("failed"), + run: makeRunSummary({ status: "failed" }), }; result.run.lifecycle.status = { kind: "failed", reason: "cancelled" }; @@ -495,7 +524,7 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "cancel", ok: true, - run: makeRunSummary("running"), + run: makeRunSummary({ status: "running" }), }; handleLifecycleToastResult("cancel", result, initialState, api); @@ -508,7 +537,7 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "archive", ok: true, - run: makeRunSummary("archived"), + run: makeRunSummary({ status: "archived" }), }; const firstState = handleLifecycleToastResult("archive", result, initialState, api); @@ -528,7 +557,7 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "unarchive", ok: true, - run: makeRunSummary("succeeded"), + run: makeRunSummary({ status: "succeeded" }), }; const stateWithActiveToast: LifecycleToastState = { activeArchiveToastId: "toast-9", @@ -614,7 +643,7 @@ describe("RunDetail full-height child routes", () => { intent: "retry", ok: true, run: { - ...makeRunSummary("runnable"), + ...makeRunSummary({ status: "runnable" }), id: "run_retry", retried_from: "run_1", }, @@ -782,6 +811,38 @@ describe("RunDetail full-height child routes", () => { expect(tabCountBadges(renderer)).toHaveLength(0); }); + test("ordinary runs can navigate to create an automation from the run", async () => { + const { renderer, router } = await renderRunDetailHarness({ + initialEntry: "/runs/run_1", + }); + + expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(1); + expect(findButtonsByText(renderer, "View automation")).toHaveLength(0); + + await act(async () => { + findButtonByText(renderer, "Create automation from run")!.props.onClick(); + }); + + expect(router.state.location.pathname).toBe("/automations/new"); + expect(router.state.location.search).toBe("?from_run=run_1"); + }); + + test("automation-created runs navigate to the existing automation instead of duplicate creation", async () => { + const { renderer, router } = await renderRunDetailHarness({ + initialEntry: "/runs/run_1", + automation: { id: "fix build", name: "Fix Build", trigger_id: "manual" }, + }); + + expect(findButtonsByText(renderer, "View automation")).toHaveLength(1); + expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(0); + + await act(async () => { + findButtonByText(renderer, "View automation")!.props.onClick(); + }); + + expect(router.state.location.pathname).toBe("/automations/fix%20build"); + }); + test("confirms deleting an archived run and navigates back to runs", async () => { const deletion = deferred(); deleteRunApiResult = deletion.promise; diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index b97da4026..ad75da0e0 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -200,6 +200,18 @@ export default function RunDetail({ params }: { params: { id: string } }) { } }; const hasPendingQuestions = isBlocked && pendingQuestions.length > 0; + const automationId = summary.automation?.id ?? null; + const automationAction = automationId + ? { + key: "view-automation", + label: "View automation", + onSelect: () => navigate(`/automations/${encodeURIComponent(automationId)}`), + } + : { + key: "create-automation", + label: "Create automation from run", + onSelect: () => navigate(`/automations/new?from_run=${encodeURIComponent(params.id)}`), + }; const actionGroups: ActionGroups = { operations: [ ...(hasSandbox @@ -211,6 +223,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { onSelect: () => void handlePreview(), }] : []), + automationAction, { key: "interrupt", label: "Send interrupt", From fe1d33c0418a7c75992362064524a1b43850f010 Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 22:18:14 -0400 Subject: [PATCH 3/5] Remove top-level automation `enabled` master gate (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level `enabled` flag on automations created a confusing two-level activation model (automation-level + trigger-level). Since automations are brand new with no existing data to migrate, the master gate is removed entirely — trigger-level `enabled` is now the sole activation control. ## What changed **Domain model (`fabro-automation`):** `enabled` removed from `Automation`, `AutomationDraft`, `AutomationReplace`, and `PersistedAutomation`. `enabled_api_trigger()` no longer short-circuits on the automation flag. The `default_true()` helper is gone. A new test asserts that TOML with a top-level `enabled` key is rejected (no silent compatibility path). **Server handler:** Conflict detail updated from `"automation is disabled or has no enabled API trigger"` → `"automation has no enabled API trigger"`. The `disabled_automation_run_endpoint_returns_conflict_code` test is deleted; the trigger-disabled and missing-trigger tests remain as the authoritative inactive-run coverage. **OpenAPI + generated clients:** `enabled` removed from `Automation`, `CreateAutomationRequest`, and `ReplaceAutomationRequest` schemas and from the generated TypeScript interfaces. Trigger-level `enabled` on `AutomationApiTrigger` and `AutomationScheduleTrigger` is untouched. **Web UI:** `AutomationFormValues.enabled` and the "Enabled" toggle row are gone. `isFormValid` no longer requires at least one enabled trigger. `canRun` in the detail view is now just `apiTrigger?.enabled === true`. The `StatusChip` component is removed. The automations list uses a new `apiEnabled` field (derived from `hasEnabledApiTrigger`) to drive run-button state and tooltip copy. A shared `lib/automation.ts` helper centralises `findApiTrigger`, `findScheduleTrigger`, and `hasEnabledApiTrigger` to avoid repeated inline `.find()` calls across routes. ### Fabro Details
Ran 8 stages in 41m 34s for $17.84 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 1s | – | 0 | | preflight_compile | 2m 15s | – | 0 | | preflight_lint | 2m 25s | – | 0 | | implement | 13m 0s | $9.25 | 0 | | simplify_opus | 9m 43s | $6.18 | 0 | | simplify_gpt | 3m 56s | $2.41 | 0 | | verify | 9m 17s | – | 0 | | **Total** | **41m 34s** | **$17.84** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> exit [condition="outcome=succeeded"] verify -> fixup fixup -> verify } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro --- .../app/components/automation-form.tsx | 18 ++---- apps/fabro-web/app/lib/automation.ts | 20 +++++++ .../app/routes/automation-detail.tsx | 29 ++-------- .../fabro-web/app/routes/automations-edit.tsx | 1 - apps/fabro-web/app/routes/automations-new.tsx | 1 - apps/fabro-web/app/routes/automations.tsx | 36 +++++++----- docs/public/api-reference/fabro-api.yaml | 12 +--- .../fabro-api/tests/automation_round_trip.rs | 3 - lib/crates/fabro-automation/src/model.rs | 56 +++++++++---------- lib/crates/fabro-automation/src/store.rs | 2 - .../src/server/handler/automations.rs | 2 +- .../fabro-server/tests/it/api/automations.rs | 18 +----- .../fabro-api-client/src/models/automation.ts | 1 - .../src/models/create-automation-request.ts | 1 - .../src/models/replace-automation-request.ts | 1 - 15 files changed, 78 insertions(+), 123 deletions(-) create mode 100644 apps/fabro-web/app/lib/automation.ts diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index f7209dc7d..4788557ec 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -7,6 +7,7 @@ import type { WorkflowSettings, } from "@qltysh/fabro-api-client"; +import { findApiTrigger, findScheduleTrigger } from "../lib/automation"; import { Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; @@ -15,7 +16,6 @@ export interface AutomationFormValues { id: string; name: string; description: string; - enabled: boolean; repository: string; ref: string; workflow: string; @@ -28,7 +28,6 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = { id: "", name: "", description: "", - enabled: true, repository: "", ref: "main", workflow: "", @@ -45,13 +44,12 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [ ]; export function automationToFormValues(automation: Automation): AutomationFormValues { - const apiTrigger = automation.triggers.find((t) => t.type === "api"); - const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule"); + const apiTrigger = findApiTrigger(automation); + const scheduleTrigger = findScheduleTrigger(automation); return { id: automation.id, name: automation.name, description: automation.description ?? "", - enabled: automation.enabled, repository: automation.target.repository, ref: automation.target.ref, workflow: automation.target.workflow, @@ -114,8 +112,7 @@ export function isFormValid(values: AutomationFormValues): boolean { values.name.trim() !== "" && values.repository.trim() !== "" && values.ref.trim() !== "" && - values.workflow.trim() !== "" && - (values.manualEnabled || values.scheduleEnabled) + values.workflow.trim() !== "" ); } @@ -272,13 +269,6 @@ export function AutomationFormFields({ className={`${INPUT_CLASS} resize-y`} /> - - patch({ enabled })} - label="Enable automation" - /> - diff --git a/apps/fabro-web/app/lib/automation.ts b/apps/fabro-web/app/lib/automation.ts new file mode 100644 index 000000000..118ab61c2 --- /dev/null +++ b/apps/fabro-web/app/lib/automation.ts @@ -0,0 +1,20 @@ +import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client"; + +type TriggerOfType = Extract< + AutomationTrigger, + { type: K } +>; + +export function findApiTrigger(automation: Automation): TriggerOfType<"api"> | undefined { + return automation.triggers.find((t): t is TriggerOfType<"api"> => t.type === "api"); +} + +export function findScheduleTrigger( + automation: Automation, +): TriggerOfType<"schedule"> | undefined { + return automation.triggers.find((t): t is TriggerOfType<"schedule"> => t.type === "schedule"); +} + +export function hasEnabledApiTrigger(automation: Automation): boolean { + return findApiTrigger(automation)?.enabled === true; +} diff --git a/apps/fabro-web/app/routes/automation-detail.tsx b/apps/fabro-web/app/routes/automation-detail.tsx index d8c3021dc..a7cd29860 100644 --- a/apps/fabro-web/app/routes/automation-detail.tsx +++ b/apps/fabro-web/app/routes/automation-detail.tsx @@ -18,6 +18,7 @@ import type { import { toRunWithStatus } from "../data/runs"; import { ApiError, apiData, automationsApi } from "../lib/api-client"; +import { findApiTrigger, findScheduleTrigger } from "../lib/automation"; import { useAutomation, useAutomationRuns } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; import { useDataUpdatedAt } from "../hooks/use-data-updated-at"; @@ -90,9 +91,9 @@ function AutomationHeader({ automation }: { automation: Automation }) { const toast = useToast(); const [running, setRunning] = useState(false); - const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule"); - const apiTrigger = automation.triggers.find((t) => t.type === "api"); - const canRun = apiTrigger?.enabled === true && automation.enabled; + const scheduleTrigger = findScheduleTrigger(automation); + const apiTrigger = findApiTrigger(automation); + const canRun = apiTrigger?.enabled === true; async function onRun() { if (!canRun || running) return; @@ -137,7 +138,6 @@ function AutomationHeader({ automation }: { automation: Automation }) { {automation.id}
- {automation.target.repository} · {automation.target.ref} @@ -165,13 +165,7 @@ function AutomationHeader({ automation }: { automation: Automation }) { type="button" onClick={onRun} disabled={!canRun || running} - title={ - !automation.enabled - ? "Enable the automation to run it" - : !apiTrigger?.enabled - ? "Enable the API trigger to run it" - : undefined - } + title={canRun ? undefined : "Enable the API trigger to run it"} className={PRIMARY_BUTTON_CLASS} >