From e87130ae23c586cb752f6c1d701b6010967d76b7 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Sun, 30 Aug 2026 09:58:08 -0400 Subject: [PATCH 1/4] Require a server-managed environment for automations Automations now store an environment_id that must reference an enabled Docker or Daytona environment. Each trigger fire resolves the current environment definition and snapshots its settings into the run, and deleting an environment still referenced by an automation is rejected with a conflict. Existing automations are backfilled conservatively: a compatible environment named default is selected when present, otherwise the sole compatible environment. Anything ambiguous is left incomplete and cannot run until an operator selects an environment in the web UI. Scheduler failures are recorded on the automation as last_error and cleared after the next successful scheduled run. Co-Authored-By: Claude Fable 5 --- .../app/components/automation-form.tsx | 78 +++++++ .../app/routes/automation-detail.tsx | 21 +- .../fabro-web/app/routes/automations-edit.tsx | 40 +++- .../app/routes/automations-new.test.tsx | 61 +++++ apps/fabro-web/app/routes/automations-new.tsx | 35 ++- apps/fabro-web/app/routes/automations.tsx | 15 +- docs/public/api-reference/fabro-api.yaml | 22 ++ docs/public/execution/automations.mdx | 26 ++- .../src/automation_materializer.rs | 5 +- lib/apps/fabro-server/src/server.rs | 9 +- .../src/server/automation_scheduler.rs | 94 +++++++- .../src/server/handler/automations.rs | 88 ++++++- .../src/server/handler/environments.rs | 11 + .../fabro-server/src/server/handler/mod.rs | 2 +- .../fabro-server/tests/it/api/automations.rs | 120 ++++++++++ .../fabro-server/tests/it/api/environments.rs | 60 +++++ .../2026071101_file_definitions_to_sqlite.rs | 1 + .../2026082801_environment_selectors.rs | 76 ++++++ lib/components/fabro-automation/src/error.rs | 2 + lib/components/fabro-automation/src/lib.rs | 5 +- .../fabro-automation/src/migrations.rs | 5 + lib/components/fabro-automation/src/model.rs | 219 +++++++++++------- lib/components/fabro-automation/src/store.rs | 63 ++++- .../fabro-automation/tests/store.rs | 190 +++++++++++++-- .../src/workflow_version_collector.rs | 87 +++++-- .../fabro-api/tests/automation_round_trip.rs | 4 + .../2026082801_automation_environments.sql | 9 + lib/foundation/fabro-db/src/lib.rs | 1 + lib/foundation/fabro-db/tests/sqlite.rs | 26 ++- .../fabro-api-client/src/models/automation.ts | 8 + .../src/models/create-automation-request.ts | 4 + .../src/models/replace-automation-request.ts | 4 + 32 files changed, 1221 insertions(+), 170 deletions(-) create mode 100644 lib/components/fabro-automation/migrations/2026082801_environment_selectors.rs create mode 100644 lib/foundation/fabro-db/migrations/2026082801_automation_environments.sql diff --git a/apps/fabro-web/app/components/automation-form.tsx b/apps/fabro-web/app/components/automation-form.tsx index 84831c9a6..55f29d832 100644 --- a/apps/fabro-web/app/components/automation-form.tsx +++ b/apps/fabro-web/app/components/automation-form.tsx @@ -1,8 +1,10 @@ import { useRef, type ReactNode } from "react"; +import { Link } from "react-router"; import { Switch } from "@headlessui/react"; import type { Automation, AutomationTrigger, + Environment, Run, RunProjection, WorkflowSettings, @@ -22,6 +24,7 @@ export interface AutomationFormValues { id: string; name: string; description: string; + environmentId: string; repository: string; branch: string; tag: string; @@ -36,6 +39,7 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = { id: "", name: "", description: "", + environmentId: "", repository: "", branch: "main", tag: "", @@ -61,6 +65,7 @@ export function automationToFormValues(automation: Automation): AutomationFormVa id: automation.id, name: automation.name, description: automation.description ?? "", + environmentId: automation.environment_id ?? "", repository: target?.repo ?? "", branch: target?.branch ?? EMPTY_AUTOMATION_FORM.branch, tag: target?.tag ?? "", @@ -76,6 +81,7 @@ export function automationFormValuesFromRun( run: Run, runState?: RunProjection | null, settings?: WorkflowSettings | null, + environments?: Environment[], ): AutomationFormValues { const name = firstPresentString( run.title, @@ -96,10 +102,17 @@ export function automationFormValuesFromRun( ?? githubRepositoryFromOriginUrl(run.repository?.origin_url) ?? ""; 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) + ? sourceEnvironment.id + : ""; return { ...EMPTY_AUTOMATION_FORM, id: kebabify(name), name, + environmentId, repository, branch: canonicalTarget?.branch ?? cloneBranch @@ -130,6 +143,7 @@ export function isFormValid(values: AutomationFormValues): boolean { return ( values.id.trim() !== "" && values.name.trim() !== "" && + values.environmentId.trim() !== "" && values.repository.trim() !== "" && values.branch.trim() !== "" && isOptionalShaValid(values.sha) && @@ -172,6 +186,10 @@ 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 { @@ -226,15 +244,26 @@ interface AutomationFormFieldsProps { values: AutomationFormValues; onChange: (values: AutomationFormValues) => void; lockIdAndTarget?: boolean; + environments?: Environment[]; + environmentsLoading?: boolean; + environmentsError?: boolean; } export function AutomationFormFields({ values, onChange, lockIdAndTarget = false, + environments = [], + environmentsLoading = false, + environmentsError = false, }: AutomationFormFieldsProps) { const slugTouchedRef = useRef(values.id.length > 0); const shaValid = isOptionalShaValid(values.sha); + const compatibleEnvironments = environments + .filter((environment) => environment.provider === "docker" || environment.provider === "daytona") + .sort((left, right) => left.id.localeCompare(right.id)); + const selectedEnvironmentMissing = values.environmentId !== "" + && !compatibleEnvironments.some((environment) => environment.id === values.environmentId); function patch(partial: Partial) { onChange({ ...values, ...partial }); @@ -304,6 +333,55 @@ export function AutomationFormFields({ + + Environment} + help="Server-managed Docker or Daytona environment used whenever this automation runs." + > +
+ + {environmentsError ? ( +

+ Couldn't load environments. Refresh the page and try again. +

+ ) : !environmentsLoading && compatibleEnvironments.length === 0 ? ( +

+ No Docker or Daytona environments are available.{" "} + + Create an environment + {" "} + before saving this automation. +

+ ) : selectedEnvironmentMissing ? ( +

+ This environment is no longer available. Choose another environment before saving. +

+ ) : null} +
+
+
+ Repository} help="GitHub repository in owner/repo form."> {automation.workflow} + + {automation.environment_id ?? ( + Environment required + )} + {scheduleTrigger ? ( {scheduleTrigger.expression} ) : null} @@ -164,6 +170,11 @@ function AutomationHeader({ automation }: { automation: Automation }) { {automation.description}

) : null} + {automation.last_error ? ( +

+ Last scheduled run failed: {automation.last_error} +

+ ) : null}
@@ -177,7 +188,13 @@ function AutomationHeader({ automation }: { automation: Automation }) { type="button" onClick={onRun} disabled={!canRun || running} - title={canRun ? undefined : "Enable the API trigger to run it"} + title={ + canRun + ? undefined + : automation.environment_id === null + ? "Select an environment before running this automation" + : "Enable the API trigger to run it" + } className={PRIMARY_BUTTON_CLASS} >