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}
>
diff --git a/apps/fabro-web/app/routes/automations-edit.tsx b/apps/fabro-web/app/routes/automations-edit.tsx
index 41d5b90c1..939c60c6f 100644
--- a/apps/fabro-web/app/routes/automations-edit.tsx
+++ b/apps/fabro-web/app/routes/automations-edit.tsx
@@ -2,10 +2,10 @@ import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
-import type { Automation } from "@qltysh/fabro-api-client";
+import type { Automation, Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
-import { useAutomation } from "../lib/queries";
+import { useAutomation, useEnvironments } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import {
AutomationFormFields,
@@ -32,12 +32,19 @@ export const handle = { hideHeader: true };
export default function AutomationsEdit() {
const { id } = useParams<{ id: string }>();
const query = useAutomation(id);
+ const environmentsQuery = useEnvironments();
return (
{query.data ? (
-
+
) : query.error ? (
@@ -63,7 +70,17 @@ function PageHeader({ id, name }: { id: string; name: string | undefined }) {
);
}
-function EditAutomationForm({ automation }: { automation: Automation }) {
+function EditAutomationForm({
+ automation,
+ environments = [],
+ environmentsLoading,
+ environmentsError,
+}: {
+ automation: Automation;
+ environments?: Environment[];
+ environmentsLoading: boolean;
+ environmentsError: boolean;
+}) {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
@@ -73,7 +90,10 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState
(null);
- const canSubmit = isFormValid(values) && !submitting;
+ const canSubmit = isFormValid(values)
+ && !environmentsLoading
+ && !environmentsError
+ && !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
@@ -86,6 +106,7 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
automationsApi.replaceAutomation(automation.id, automation.revision, {
name: trimmedName,
description: values.description.trim() || null,
+ environment_id: values.environmentId.trim(),
target: targetFromFormValues(values),
workflow: values.workflow.trim(),
triggers: triggersFromFormValues(values),
@@ -107,7 +128,14 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
return (