diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index 55f29d832..b1a23149a 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -16,6 +16,7 @@ import { gitTarget, type GitRunTarget, } from "../lib/automation"; +import { isCloneBasedEnvironment, providerLabel } from "./environment-form"; import { Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; @@ -104,8 +105,9 @@ export function automationFormValuesFromRun( const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch; const sourceEnvironment = settings?.run?.environment; const environmentId = sourceEnvironment - && sourceEnvironment.provider !== "local" - && environments?.some((environment) => environment.id === sourceEnvironment.id) + && environments?.some( + (environment) => environment.id === sourceEnvironment.id && isCloneBasedEnvironment(environment), + ) ? sourceEnvironment.id : ""; return { @@ -186,10 +188,6 @@ function firstPresentString(...values: Array): string return ""; } -function providerLabel(provider: string): string { - return provider.charAt(0).toUpperCase() + provider.slice(1); -} - function githubRepositoryFromSettings( settings?: WorkflowSettings | null, ): string | null { @@ -260,7 +258,7 @@ export function AutomationFormFields({ const slugTouchedRef = useRef(values.id.length > 0); const shaValid = isOptionalShaValid(values.sha); const compatibleEnvironments = environments - .filter((environment) => environment.provider === "docker" || environment.provider === "daytona") + .filter(isCloneBasedEnvironment) .sort((left, right) => left.id.localeCompare(right.id)); const selectedEnvironmentMissing = values.environmentId !== "" && !compatibleEnvironments.some((environment) => environment.id === values.environmentId); diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 5ea597b17..d61a2c035 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -32,6 +32,16 @@ export const CREATABLE_PROVIDERS = [ EnvironmentProvider.DAYTONA, ] as const; +// Whether a server-managed environment can back Git-targeted work such as +// automations: only the clone-based (creatable) providers qualify. +export function isCloneBasedEnvironment(environment: Environment): boolean { + return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider); +} + +export function providerLabel(provider: string): string { + return provider.charAt(0).toUpperCase() + provider.slice(1); +} + // Parse the `provider` query param used by the create flow into a creatable // provider, defaulting to Docker for anything unexpected. export function parseCreatableProvider(value: string | null): EnvironmentProvider { diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx index 7b19c7a2c..82a5c35b3 100644 --- a/apps/fabro-web/app/routes/automations-new.tsx +++ b/apps/fabro-web/app/routes/automations-new.tsx @@ -36,15 +36,18 @@ export default function AutomationsNew() { const runStateQuery = useRunState(fromRunId); const settingsQuery = useRunSettings(fromRunId); const environmentsQuery = useEnvironments(); + const environments = environmentsQuery.data?.data; + const environmentsPending = environmentsQuery.isLoading && !environmentsQuery.data; + const environmentsError = Boolean(environmentsQuery.error); if (!fromRunId) { return ( ); } @@ -54,7 +57,6 @@ export default function AutomationsNew() { const runPending = runQuery.isLoading && !runQuery.data; const runStatePending = runStateQuery.isLoading && !runStateQuery.data; const settingsPending = settingsQuery.isLoading && !settingsQuery.data; - const environmentsPending = environmentsQuery.isLoading && !environmentsQuery.data; if (runPending || runStatePending || settingsPending || environmentsPending) { return (
@@ -71,8 +73,8 @@ export default function AutomationsNew() { ); @@ -82,15 +84,15 @@ export default function AutomationsNew() { runQuery.data, runStateQuery.data ?? null, settingsQuery.data ?? null, - environmentsQuery.data?.data, + environments, ); return ( ); } diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index e833c339b..78132aef7 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, environmentsApi } from "../lib/api-client"; import { useEnvironments, useServerSettings } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; -import { CREATABLE_PROVIDERS } from "../components/environment-form"; +import { CREATABLE_PROVIDERS, providerLabel } from "../components/environment-form"; import { Badge, Muted, @@ -62,10 +62,6 @@ export default function SettingsEnvironments() { const NEW_BUTTON_CLASS = "inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-2.5 py-1 text-sm font-medium text-fg-3 transition-colors hover:border-line-strong hover:bg-panel hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-line disabled:hover:bg-panel/80 disabled:hover:text-fg-3"; -function providerLabel(provider: string): string { - return provider.charAt(0).toUpperCase() + provider.slice(1); -} - // "New environment" is a provider picker: each enabled sandbox provider opens // the create form pre-set to that provider, which is then fixed for the // environment's lifetime. `local` is never offered (it's reserved/in-memory). diff --git a/lib/apps/fabro-server/src/server/automation_scheduler.rs b/lib/apps/fabro-server/src/server/automation_scheduler.rs index 185faeeae..43d30d93d 100644 --- a/lib/apps/fabro-server/src/server/automation_scheduler.rs +++ b/lib/apps/fabro-server/src/server/automation_scheduler.rs @@ -335,7 +335,9 @@ async fn fire_scheduled_automation_run( return; } - clear_scheduler_error(state.as_ref(), &automation_id).await; + if automation.last_error.is_some() { + set_scheduler_error(state.as_ref(), &automation_id, None).await; + } info!( run_id = %run_id, @@ -345,25 +347,15 @@ async fn fire_scheduled_automation_run( } async fn record_scheduler_error(state: &AppState, id: &AutomationId, message: &str) { - if let Err(err) = state - .automation_store() - .set_last_error(id, Some(message)) - .await - { - error!( - automation_id = %id, - error = ?err, - "Failed to persist automation scheduler error", - ); - } + set_scheduler_error(state, id, Some(message)).await; } -async fn clear_scheduler_error(state: &AppState, id: &AutomationId) { - if let Err(err) = state.automation_store().set_last_error(id, None).await { +async fn set_scheduler_error(state: &AppState, id: &AutomationId, message: Option<&str>) { + if let Err(err) = state.automation_store().set_last_error(id, message).await { error!( automation_id = %id, error = ?err, - "Failed to clear automation scheduler error", + "Failed to persist automation scheduler status", ); } } diff --git a/lib/apps/fabro-server/src/server/handler/automations.rs b/lib/apps/fabro-server/src/server/handler/automations.rs index cf3de9074..88eb51ad3 100644 --- a/lib/apps/fabro-server/src/server/handler/automations.rs +++ b/lib/apps/fabro-server/src/server/handler/automations.rs @@ -266,7 +266,7 @@ pub(in crate::server) fn resolve_automation_environment( "automation_environment_required", )); }; - let id = EnvironmentId::new(value.to_string()).map_err(|_| { + let id = value.parse::().map_err(|_| { ApiError::with_code( status, "automation environment id is invalid", diff --git a/lib/components/fabro-automation/migrations/2026082801_environment_selectors.rs b/lib/components/fabro-automation/migrations/2026082801_environment_selectors.rs index 385674eba..47301780e 100644 --- a/lib/components/fabro-automation/migrations/2026082801_environment_selectors.rs +++ b/lib/components/fabro-automation/migrations/2026082801_environment_selectors.rs @@ -22,6 +22,18 @@ pub struct EnvironmentSelectorBackfillReport { pub async fn backfill_environment_selectors( pool: &DbPool, ) -> Result { + let has_incomplete = + sqlx::query("SELECT 1 FROM automations WHERE environment_id IS NULL LIMIT 1") + .fetch_optional(pool) + .await? + .is_some(); + if !has_incomplete { + return Ok(EnvironmentSelectorBackfillReport { + updated_rows: 0, + environment_id: None, + }); + } + let compatible_ids = sqlx::query_scalar::<_, String>( "SELECT id FROM environments WHERE provider IN ('docker', 'daytona') ORDER BY id", ) diff --git a/lib/components/fabro-automation/src/model.rs b/lib/components/fabro-automation/src/model.rs index 1a65c9f46..ed0312661 100644 --- a/lib/components/fabro-automation/src/model.rs +++ b/lib/components/fabro-automation/src/model.rs @@ -308,13 +308,6 @@ fn validate_fields( if require_environment && value.environment_id.is_none() { return Err(AutomationValidationError::MissingEnvironment); } - if value - .environment_id - .as_deref() - .is_some_and(|environment_id| environment_id.trim().is_empty()) - { - return Err(AutomationValidationError::MissingEnvironment); - } validate_workflow_selector(&value.workflow)?; validate_triggers(&value.triggers) } @@ -326,7 +319,8 @@ fn normalize_replace( value.target = validate_target(value.target)?; value.environment_id = value .environment_id - .map(|environment_id| environment_id.trim().to_string()); + .map(|environment_id| environment_id.trim().to_string()) + .filter(|environment_id| !environment_id.is_empty()); validate_fields(&value, require_environment)?; let api_enabled = value diff --git a/lib/components/fabro-automation/src/store.rs b/lib/components/fabro-automation/src/store.rs index 07577f9bf..38c676e5e 100644 --- a/lib/components/fabro-automation/src/store.rs +++ b/lib/components/fabro-automation/src/store.rs @@ -281,10 +281,7 @@ impl StoredAutomation { workflow: self.workflow, triggers, }) - .map_err(|source| AutomationStoreError::StoredValidation { - id: id.clone(), - source, - })?; + .map_err(|source| AutomationStoreError::StoredValidation { id, source })?; automation.last_error = self.last_error; Ok(automation) } diff --git a/lib/foundation/fabro-db/src/lib.rs b/lib/foundation/fabro-db/src/lib.rs index 00b33b4e3..4f3687437 100644 --- a/lib/foundation/fabro-db/src/lib.rs +++ b/lib/foundation/fabro-db/src/lib.rs @@ -14,7 +14,6 @@ use tracing::info; pub type DbPool = sqlx::SqlitePool; -// Rebuild this crate whenever the bundled automation schema migration changes. static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); /// The blob-table migration, exposed so fixtures in other crates can install