Simplify implicit run environment selection

Make the CLI resolve an omitted environment the same way the server does:
list the catalog once and apply the shared selection helper, instead of
fetching `default`, sniffing the 404, and then listing. Replace the
iterator-count dance in the helper with a slice match, lean on `?` for
error conversion at both call sites, and move the selection error type
into the crate's error module. Document `environment_selection_required`
in the endpoint's error codes and de-duplicate the docs prose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-09-03 11:47:45 -04:00
parent 32c2c1136b
commit 72da10816f
12 changed files with 96 additions and 100 deletions

View file

@ -1181,8 +1181,8 @@ paths:
Failures return the standard error body. The intent lane responds
`404` (`workflow_version_not_found`, `environment_not_found`), `422`
(`run_intent_invalid`, `target_invalid`,
`target_environment_unsupported`, `workflow_version_unusable`,
`run_compile_invalid`), `503` (`integration_unavailable`), or `500`
`target_environment_unsupported`, `environment_selection_required`,
`workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500`
(`workflow_version_store_error`, `credential_store_error`,
`variable_store_error`, `run_persistence_failed`).
requestBody:

View file

@ -178,10 +178,10 @@ fabro server start --environment default
## Seeded Environments
Install seeds a `default` environment into SQLite. It is a normal persisted
environment, so you can edit or delete it. When it is absent, run creation that
does not name an environment uses the sole Docker or Daytona environment in the
catalog; zero or multiple compatible environments require an explicit
selection. The standard Docker default is:
environment, so you can edit or delete it. Runs that do not name an environment
select it implicitly; see
[Run Configuration](/execution/run-configuration#runenvironment-and-server-managed-environments)
for the fallback rule when it is absent. The standard Docker default is:
```json title="GET /api/v1/environments/default"
{

View file

@ -318,7 +318,7 @@ memory = "8GB"
| Field | Description |
|---|---|
| `run.environment.id` | Environment slug to select. Defaults to `default`. |
| `run.environment.id` | Environment slug to select. When omitted, the implicit selection described above applies. |
| `environments.<slug>.provider` | Required provider: `local`, `docker`, or `daytona`. |
| `image.docker` | Docker image. Docker runs it directly; Daytona uses it to create or reuse an internally named snapshot. |
| `image.dockerfile` | Inline Dockerfile or `{ path = "Dockerfile" }`; Daytona uses it to create or reuse an internally named snapshot. Do not set it with `image.docker`. |

View file

@ -2,7 +2,7 @@ use std::path::Path;
use anyhow::{Context as _, anyhow, bail};
use fabro_config::project;
use fabro_environment::{DEFAULT_ENVIRONMENT_ID, Environment, select_implicit_run_environment};
use fabro_environment::{Environment, select_implicit_run_environment};
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::{DirtyStatus, RunId, RunIntent, RunTarget};
use fabro_util::terminal::Styles;
@ -109,24 +109,23 @@ pub(crate) async fn create_run(
})
}
/// Resolves the run environment the same way the server does for an omitted
/// `environment_id`, so the CLI can derive the run target from its provider.
async fn resolve_run_environment(
client: &fabro_client::Client,
explicit_id: Option<&str>,
) -> anyhow::Result<Environment> {
let id = explicit_id.unwrap_or(DEFAULT_ENVIRONMENT_ID);
match client.retrieve_environment(id).await {
Ok(environment) => Ok(environment),
Err(error) if explicit_id.is_none() && fabro_client::is_not_found_error(&error) => {
let environments = client
.list_environments()
.await
.context("could not list environments")?;
select_implicit_run_environment(&environments)
.cloned()
.map_err(anyhow::Error::new)
}
Err(error) => Err(error).with_context(|| format!("could not retrieve environment `{id}`")),
}
let Some(id) = explicit_id else {
let environments = client
.list_environments()
.await
.context("could not list environments")?;
return Ok(select_implicit_run_environment(&environments)?.clone());
};
client
.retrieve_environment(id)
.await
.with_context(|| format!("could not retrieve environment `{id}`"))
}
fn warn_untransmitted_settings(

View file

@ -12,7 +12,7 @@ use insta::assert_snapshot;
use serde_json::json;
use super::support::{
created_run_id, environment_json, fixture, mock_environment,
created_run_id, environment_catalog_json, fixture, mock_environment, mock_environment_catalog,
mock_workflow_version_registrations, mock_workflow_version_registrations_recording,
output_stderr, output_stdout, remote_run_summary_json, resolve_run, run_count_for_test_case,
run_git, run_state,
@ -123,7 +123,7 @@ fn create_uses_explicit_server_target_and_prints_remote_run_id() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -160,28 +160,7 @@ fn create_uses_the_sole_clone_based_environment_when_default_is_missing() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let default_mock = server.mock(|when, then| {
when.method("GET").path("/api/v1/environments/default");
then.status(404)
.header("content-type", "application/json")
.json_body(json!({
"errors": [{
"status": "404",
"title": "Not Found",
"detail": "environment `default` not found",
"code": "environment_not_found"
}]
}));
});
let list_mock = server.mock(|when, then| {
when.method("GET").path("/api/v1/environments");
then.status(200)
.header("content-type", "application/json")
.json_body(json!({
"data": [environment_json("production", "docker")],
"meta": { "total": 1 }
}));
});
let list_mock = mock_environment_catalog(&server, &[("production", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let requests = Arc::new(Mutex::new(Vec::new()));
let create_mock = mock_intent_create(&server, &run_id, Arc::clone(&requests));
@ -203,7 +182,6 @@ fn create_uses_the_sole_clone_based_environment_when_default_is_missing() {
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
default_mock.assert();
list_mock.assert();
version_mock.assert();
create_mock.assert();
@ -215,7 +193,7 @@ fn create_defers_provider_validation_to_the_server() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let registered_versions = Arc::new(Mutex::new(Vec::new()));
let version_mock =
mock_workflow_version_registrations_recording(&server, Arc::clone(&registered_versions));
@ -258,7 +236,7 @@ fn create_uses_configured_server_target_without_server_flag() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -293,7 +271,7 @@ fn create_parent_resolves_parent_and_sends_parent_id_in_intent() {
let run_id = unique_run_id();
let parent_id = unique_run_id();
let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id);
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST")
@ -363,7 +341,7 @@ fn create_cli_server_target_overrides_configured_server_target() {
});
let cli_server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&cli_server, "default", "docker");
let environment_mock = mock_environment_catalog(&cli_server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&cli_server);
let cli_mock = cli_server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -693,7 +671,7 @@ fn create_clone_targets_require_exact_git_observations() {
let environment_calls = Arc::new(AtomicUsize::new(0));
let environment_calls_for_mock = Arc::clone(&environment_calls);
let environment_mock = server.mock(|when, then| {
when.method("GET").path("/api/v1/environments/default");
when.method("GET").path("/api/v1/environments");
then.respond_with(move |_| {
let provider = match environment_calls_for_mock.fetch_add(1, Ordering::SeqCst) {
0 | 2 => "docker",
@ -703,7 +681,7 @@ fn create_clone_targets_require_exact_git_observations() {
HttpMockResponse::builder()
.status(200)
.header("content-type", "application/json")
.body(environment_json("default", provider).to_string())
.body(environment_catalog_json(&[("default", provider)]).to_string())
.build()
});
});
@ -866,7 +844,7 @@ fn create_clone_targets_require_exact_git_observations() {
fn create_rejects_unusable_git_checkouts_instead_of_sending_an_empty_target() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let run_id = unique_run_id();
let requests = Arc::new(Mutex::new(Vec::new()));
@ -935,7 +913,7 @@ fn create_rejects_unusable_git_checkouts_instead_of_sending_an_empty_target() {
fn create_rejects_an_unsupported_attached_origin_before_upload() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let run_id = unique_run_id();
let requests = Arc::new(Mutex::new(Vec::new()));

View file

@ -10,7 +10,7 @@ use httpmock::MockServer;
use serde_json::Value;
use super::support::{
created_run_id, mock_environment, mock_workflow_version_registrations, output_stderr,
created_run_id, mock_environment_catalog, mock_workflow_version_registrations, output_stderr,
remote_run_summary_json, run_state, wait_for_event_names,
};
use crate::support::{LightweightCli, run_output_filters, run_projection_json, unique_run_id};
@ -155,7 +155,7 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -209,7 +209,7 @@ fn run_parent_resolves_parent_and_sends_parent_id_in_intent() {
let run_id = unique_run_id();
let parent_id = unique_run_id();
let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id);
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST")
@ -265,7 +265,7 @@ fn detach_uses_configured_server_target_without_server_flag() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -314,7 +314,7 @@ fn detach_uses_configured_server_target_without_server_flag() {
fn run_create_failure_shows_action_context_and_response_body() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let create_mock = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -496,7 +496,7 @@ fn detach_cli_server_target_overrides_configured_server_target() {
});
let cli_server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&cli_server, "default", "docker");
let environment_mock = mock_environment_catalog(&cli_server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&cli_server);
let cli_create = cli_server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -550,7 +550,7 @@ fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summ
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let preflight = server.mock(|when, then| {
when.method("POST").path("/api/v1/preflight");
@ -672,7 +672,7 @@ fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summ
fn run_surfaces_server_rejection_for_unbound_template_inputs() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");
@ -714,7 +714,7 @@ fn run_surfaces_server_rejection_for_unbound_template_inputs() {
fn foreground_run_surfaces_server_rejection_for_invalid_workflow() {
let context = test_context!();
let server = MockServer::start();
let environment_mock = mock_environment(&server, "default", "docker");
let environment_mock = mock_environment_catalog(&server, &[("default", "docker")]);
let version_mock = mock_workflow_version_registrations(&server);
let create = server.mock(|when, then| {
when.method("POST").path("/api/v1/runs");

View file

@ -172,6 +172,32 @@ pub(crate) fn mock_environment<'a>(server: &'a MockServer, id: &str, provider: &
})
}
/// Canonical environment catalog body for mock servers, matching the
/// `GET /api/v1/environments` shape the run-intent create path reads when no
/// `--environment` is given.
pub(crate) fn environment_catalog_json(environments: &[(&str, &str)]) -> Value {
serde_json::json!({
"data": environments
.iter()
.map(|(id, provider)| environment_json(id, provider))
.collect::<Vec<_>>(),
"meta": { "total": environments.len() }
})
}
pub(crate) fn mock_environment_catalog<'a>(
server: &'a MockServer,
environments: &[(&str, &str)],
) -> Mock<'a> {
let body = environment_catalog_json(environments);
server.mock(|when, then| {
when.method("GET").path("/api/v1/environments");
then.status(200)
.header("content-type", "application/json")
.json_body(body);
})
}
pub(crate) fn mock_workflow_version_registrations(server: &MockServer) -> Mock<'_> {
mock_workflow_version_registrations_recording(server, Arc::new(Mutex::new(Vec::new())))
}

View file

@ -1078,9 +1078,7 @@ fn select_intent_environment_id(
) -> Result<EnvironmentId, EnvironmentSelectionError> {
let Some(value) = value else {
let environments = state.environment_store().list();
return select_implicit_run_environment(&environments)
.map(|environment| environment.id.clone())
.map_err(EnvironmentSelectionError::from);
return Ok(select_implicit_run_environment(&environments)?.id.clone());
};
let id =
value

View file

@ -134,3 +134,13 @@ impl EnvironmentStoreError {
}
}
}
/// Error returned when an omitted run environment cannot be resolved without
/// making an arbitrary choice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error(
"no environment was specified and no environment named `default` exists; expected exactly one Docker or Daytona environment, found {compatible_count}"
)]
pub struct ImplicitRunEnvironmentError {
pub compatible_count: usize,
}

View file

@ -3,11 +3,11 @@ mod id;
mod model;
mod store;
pub use error::{EnvironmentStoreError, EnvironmentValidationError};
pub use error::{EnvironmentStoreError, EnvironmentValidationError, ImplicitRunEnvironmentError};
pub use id::{EnvironmentId, EnvironmentRevision, EnvironmentRevisionParseError};
pub use model::{Environment, EnvironmentDraft};
pub use store::{
DEFAULT_ENVIRONMENT_ID, EnvironmentStore, ImplicitRunEnvironmentError, ImportReport,
import_legacy_directory_once, seed_default_environment, seed_environments,
seeded_catalog_layer, select_implicit_run_environment,
DEFAULT_ENVIRONMENT_ID, EnvironmentStore, ImportReport, import_legacy_directory_once,
seed_default_environment, seed_environments, seeded_catalog_layer,
select_implicit_run_environment,
};

View file

@ -20,7 +20,7 @@ use tracing::info;
use crate::{
Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError,
EnvironmentValidationError,
EnvironmentValidationError, ImplicitRunEnvironmentError,
};
/// Built-in default environment seeded by install/test setup. The server itself
@ -30,16 +30,6 @@ use crate::{
/// reserved, in-memory environment.
pub const DEFAULT_ENVIRONMENT_ID: &str = "default";
/// Error returned when an omitted run environment cannot be resolved without
/// making an arbitrary choice.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error(
"no environment was specified and no environment named `default` exists; expected exactly one Docker or Daytona environment, found {compatible_count}"
)]
pub struct ImplicitRunEnvironmentError {
pub compatible_count: usize,
}
/// Selects the environment used when run creation omits an environment ID.
///
/// A catalog entry named `default` preserves the established behavior. If it
@ -55,21 +45,16 @@ pub fn select_implicit_run_environment(
return Ok(environment);
}
let mut compatible = environments
let compatible: Vec<&Environment> = environments
.iter()
.filter(|environment| environment.settings.provider.is_clone_based());
let Some(environment) = compatible.next() else {
return Err(ImplicitRunEnvironmentError {
compatible_count: 0,
});
};
let Some(_second) = compatible.next() else {
return Ok(environment);
};
Err(ImplicitRunEnvironmentError {
compatible_count: 2 + compatible.count(),
})
.filter(|environment| environment.settings.provider.is_clone_based())
.collect();
match compatible.as_slice() {
[environment] => Ok(environment),
_ => Err(ImplicitRunEnvironmentError {
compatible_count: compatible.len(),
}),
}
}
/// `local` is a reserved environment: it is synthesized in memory only when the

View file

@ -372,7 +372,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `environment_selection_required`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* @summary Create Run
* @param {CreateRunRequest} createRunRequest
* @param {*} [options] Override http request option.
@ -1677,7 +1677,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `environment_selection_required`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* @summary Create Run
* @param {CreateRunRequest} createRunRequest
* @param {*} [options] Override http request option.
@ -2137,7 +2137,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.closeRunPullRequest(id, options).then((request) => request(axios, basePath));
},
/**
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `environment_selection_required`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* @summary Create Run
* @param {CreateRunRequest} createRunRequest
* @param {*} [options] Override http request option.
@ -2518,7 +2518,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* Creates a new workflow run in `submitted` status from either a self-contained legacy manifest or an immutable workflow-version intent. Creation does not start or schedule the run. Failures return the standard error body. The intent lane responds `404` (`workflow_version_not_found`, `environment_not_found`), `422` (`run_intent_invalid`, `target_invalid`, `target_environment_unsupported`, `environment_selection_required`, `workflow_version_unusable`, `run_compile_invalid`), `503` (`integration_unavailable`), or `500` (`workflow_version_store_error`, `credential_store_error`, `variable_store_error`, `run_persistence_failed`).
* @summary Create Run
* @param {CreateRunRequest} createRunRequest
* @param {*} [options] Override http request option.