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 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-08-30 09:58:08 -04:00
parent 775b62b500
commit e87130ae23
32 changed files with 1221 additions and 170 deletions

View file

@ -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 | null | undefined>): 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<AutomationFormValues>) {
onChange({ ...values, ...partial });
@ -304,6 +333,55 @@ export function AutomationFormFields({
</Row>
</Panel>
<Panel title="Runtime">
<Row
title={<Label required>Environment</Label>}
help="Server-managed Docker or Daytona environment used whenever this automation runs."
>
<div className="space-y-2">
<select
name="environment_id"
aria-label="Automation environment"
value={values.environmentId}
onChange={(event) => patch({ environmentId: event.target.value })}
disabled={environmentsLoading || environmentsError || compatibleEnvironments.length === 0}
className={`${INPUT_CLASS} font-mono`}
>
<option value="">
{environmentsLoading ? "Loading environments…" : "Select an environment…"}
</option>
{selectedEnvironmentMissing ? (
<option value={values.environmentId} disabled>
{values.environmentId} (unavailable)
</option>
) : null}
{compatibleEnvironments.map((environment) => (
<option key={environment.id} value={environment.id}>
{environment.id} · {providerLabel(environment.provider)}
</option>
))}
</select>
{environmentsError ? (
<p className="text-xs leading-relaxed text-coral">
Couldn&apos;t load environments. Refresh the page and try again.
</p>
) : !environmentsLoading && compatibleEnvironments.length === 0 ? (
<p className="text-xs leading-relaxed text-fg-muted">
No Docker or Daytona environments are available.{" "}
<Link to="/settings/environments" className="text-mint hover:text-fg">
Create an environment
</Link>{" "}
before saving this automation.
</p>
) : selectedEnvironmentMissing ? (
<p className="text-xs leading-relaxed text-coral">
This environment is no longer available. Choose another environment before saving.
</p>
) : null}
</div>
</Row>
</Panel>
<Panel title="Source">
<Row title={<Label required>Repository</Label>} help="GitHub repository in owner/repo form.">
<input

View file

@ -5,6 +5,7 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
ClockIcon,
CubeTransparentIcon,
FolderIcon,
MagnifyingGlassIcon,
PlayIcon,
@ -99,7 +100,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
const scheduleTrigger = findScheduleTrigger(automation);
const apiTrigger = findApiTrigger(automation);
const target = gitTarget(automation.target);
const canRun = apiTrigger?.enabled === true;
const canRun = apiTrigger?.enabled === true && automation.environment_id !== null;
async function onRun() {
if (!canRun || running) return;
@ -155,6 +156,11 @@ function AutomationHeader({ automation }: { automation: Automation }) {
) : null}
</Chip>
<Chip icon={RectangleStackIcon}>{automation.workflow}</Chip>
<Chip icon={CubeTransparentIcon}>
{automation.environment_id ?? (
<span className="text-coral">Environment required</span>
)}
</Chip>
{scheduleTrigger ? (
<Chip icon={ClockIcon}>{scheduleTrigger.expression}</Chip>
) : null}
@ -164,6 +170,11 @@ function AutomationHeader({ automation }: { automation: Automation }) {
{automation.description}
</p>
) : null}
{automation.last_error ? (
<p className="mt-3 max-w-prose rounded-md border border-coral/20 bg-coral/5 px-3 py-2 text-sm leading-relaxed text-coral">
Last scheduled run failed: {automation.last_error}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
@ -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}
>
<PlayIcon className="size-4" aria-hidden="true" />

View file

@ -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 (
<div className="space-y-6">
<PageHeader id={id ?? ""} name={query.data?.name} />
{query.data ? (
<EditAutomationForm key={query.data.id} automation={query.data} />
<EditAutomationForm
key={query.data.id}
automation={query.data}
environments={environmentsQuery.data?.data}
environmentsLoading={environmentsQuery.isLoading && !environmentsQuery.data}
environmentsError={Boolean(environmentsQuery.error)}
/>
) : query.error ? (
<Panel title="Automation">
<div className="px-4 py-6 text-sm text-fg-2">
@ -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<string | null>(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 (
<form onSubmit={onSubmit} className="space-y-6">
<AutomationFormFields values={values} onChange={setValues} lockIdAndTarget />
<AutomationFormFields
values={values}
onChange={setValues}
lockIdAndTarget
environments={environments}
environmentsLoading={environmentsLoading}
environmentsError={environmentsError}
/>
{error ? <ErrorMessage message={error} /> : null}

View file

@ -13,6 +13,8 @@ let currentRunLoading = false;
let currentRunState: any = null;
let currentRunStateLoading = false;
let currentRunSettings: any = null;
let currentEnvironments: any[] = [];
let currentEnvironmentsError: unknown = null;
const queryCalls: Array<{ hook: string; id: string | undefined }> = [];
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
let teardownReactEnv: (() => void) | undefined;
@ -44,6 +46,11 @@ mock.module("@headlessui/react", () => ({
}));
mock.module("../lib/queries", () => ({
useEnvironments: () => ({
data: { data: currentEnvironments, meta: { total: currentEnvironments.length } },
error: currentEnvironmentsError,
isLoading: false,
}),
useRun: (id: string | undefined) => {
queryCalls.push({ hook: "useRun", id });
return {
@ -189,6 +196,10 @@ function makeRun(overrides: Record<string, unknown> = {}) {
function makeRunSettings() {
return {
run: {
environment: {
id: "default",
provider: "docker",
},
scm: {
provider: "github",
owner: "qltysh",
@ -266,6 +277,12 @@ beforeEach(() => {
currentRunState = null;
currentRunStateLoading = false;
currentRunSettings = null;
currentEnvironments = [
{ id: "default", provider: "docker" },
{ id: "daytona-smoke", provider: "daytona" },
{ id: "local", provider: "local" },
];
currentEnvironmentsError = null;
queryCalls.length = 0;
createAutomationMock.mockClear();
swrMutateMock.mockClear();
@ -290,10 +307,53 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Tag")).toBe("");
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
expect(fieldValue(renderer, "Workflow slug")).toBe("");
expect(fieldValue(renderer, "Automation environment")).toBe("");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
});
test("environment selector offers Docker and Daytona but not local", async () => {
const { renderer } = await renderAutomationsNew("/automations/new");
const options = byLabel(renderer, "Automation environment").findAllByType("option");
const optionText = options.map((option) =>
option.children.join("").replace(/\s+/g, " ").trim(),
);
expect(optionText).toContain("default · Docker");
expect(optionText).toContain("daytona-smoke · Daytona");
expect(optionText.some((text) => text.includes("local"))).toBe(false);
});
test("no compatible environments explains recovery and blocks creation", async () => {
currentEnvironments = [{ id: "local", provider: "local" }];
const { renderer } = await renderAutomationsNew("/automations/new");
expect(textFromNode(renderer.toJSON())).toContain(
"No Docker or Daytona environments are available",
);
expect(
renderer.root.findByProps({ type: "submit" }).props.disabled,
).toBe(true);
});
test("creation sends the selected environment id", async () => {
const { renderer } = await renderAutomationsNew("/automations/new");
changeField(renderer, "Automation name", "Nightly");
changeField(renderer, "Repository", "fabro-sh/fabro");
changeField(renderer, "Workflow slug", "hello");
changeField(renderer, "Automation environment", "daytona-smoke");
await act(async () => {
await renderer.root.findByType("form").props.onSubmit({ preventDefault() {} });
});
expect(createAutomationMock).toHaveBeenCalledTimes(1);
expect(createAutomationMock.mock.calls[0]?.[0]).toMatchObject({
environment_id: "daytona-smoke",
});
});
test("workflow slug input normalizes to kebab-case and preserves dashes", async () => {
const { renderer } = await renderAutomationsNew("/automations/new");
@ -317,6 +377,7 @@ describe("AutomationsNew", () => {
expect(fieldValue(renderer, "Tag")).toBe("");
expect(fieldValue(renderer, "Exact commit SHA")).toBe("");
expect(fieldValue(renderer, "Workflow slug")).toBe("fix-ci");
expect(fieldValue(renderer, "Automation environment")).toBe("default");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
expect(

View file

@ -2,10 +2,11 @@ import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import type { Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import { useRun, useRunSettings, useRunState } from "../lib/queries";
import { useEnvironments, useRun, useRunSettings, useRunState } from "../lib/queries";
import {
AutomationFormFields,
EMPTY_AUTOMATION_FORM,
@ -34,12 +35,16 @@ export default function AutomationsNew() {
const runQuery = useRun(fromRunId);
const runStateQuery = useRunState(fromRunId);
const settingsQuery = useRunSettings(fromRunId);
const environmentsQuery = useEnvironments();
if (!fromRunId) {
return (
<AutomationCreateForm
key="blank"
initialValues={EMPTY_AUTOMATION_FORM}
environments={environmentsQuery.data?.data}
environmentsLoading={environmentsQuery.isLoading && !environmentsQuery.data}
environmentsError={Boolean(environmentsQuery.error)}
/>
);
}
@ -49,7 +54,8 @@ export default function AutomationsNew() {
const runPending = runQuery.isLoading && !runQuery.data;
const runStatePending = runStateQuery.isLoading && !runStateQuery.data;
const settingsPending = settingsQuery.isLoading && !settingsQuery.data;
if (runPending || runStatePending || settingsPending) {
const environmentsPending = environmentsQuery.isLoading && !environmentsQuery.data;
if (runPending || runStatePending || settingsPending || environmentsPending) {
return (
<div className="space-y-6">
<PageHeader />
@ -65,6 +71,8 @@ export default function AutomationsNew() {
<AutomationCreateForm
key={`missing:${fromRunId}`}
initialValues={EMPTY_AUTOMATION_FORM}
environments={environmentsQuery.data?.data}
environmentsError={Boolean(environmentsQuery.error)}
sourceError="The source run could not be loaded. You can still fill it out manually."
/>
);
@ -74,21 +82,30 @@ export default function AutomationsNew() {
runQuery.data,
runStateQuery.data ?? null,
settingsQuery.data ?? null,
environmentsQuery.data?.data,
);
return (
<AutomationCreateForm
key={`from-run:${fromRunId}`}
initialValues={initialValues}
environments={environmentsQuery.data?.data}
environmentsError={Boolean(environmentsQuery.error)}
/>
);
}
function AutomationCreateForm({
initialValues,
environments = [],
environmentsLoading = false,
environmentsError = false,
sourceError = null,
}: {
initialValues: AutomationFormValues;
environments?: Environment[];
environmentsLoading?: boolean;
environmentsError?: boolean;
sourceError?: string | null;
}) {
const navigate = useNavigate();
@ -98,7 +115,10 @@ function AutomationCreateForm({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = isFormValid(values) && !submitting;
const canSubmit = isFormValid(values)
&& !environmentsLoading
&& !environmentsError
&& !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
@ -112,6 +132,7 @@ function AutomationCreateForm({
id: values.id.trim(),
name: trimmedName,
description: values.description.trim() || null,
environment_id: values.environmentId.trim(),
target: targetFromFormValues(values),
workflow: values.workflow.trim(),
triggers: triggersFromFormValues(values),
@ -134,7 +155,13 @@ function AutomationCreateForm({
<form onSubmit={onSubmit} className="space-y-6">
<PageHeader />
<AutomationFormFields values={values} onChange={setValues} />
<AutomationFormFields
values={values}
onChange={setValues}
environments={environments}
environmentsLoading={environmentsLoading}
environmentsError={environmentsError}
/>
{sourceError ? <ErrorMessage message={sourceError} /> : null}
{error ? <ErrorMessage message={error} /> : null}

View file

@ -54,6 +54,7 @@ interface AutomationRow {
name: string;
workflow: string;
repository: string;
environmentId: string | null;
schedule?: string;
apiEnabled: boolean;
icon: ComponentType<{ className?: string }>;
@ -94,6 +95,7 @@ function mapAutomations(result: AutomationListResponse | undefined): AutomationR
name: a.name,
workflow: a.workflow,
repository: target?.repo ?? UNSUPPORTED_TARGET_LABEL,
environmentId: a.environment_id,
schedule: findScheduleTrigger(a)?.expression,
apiEnabled: hasEnabledApiTrigger(a),
icon: slugIconMap[a.workflow] ?? CodeBracketIcon,
@ -124,7 +126,7 @@ function AutomationCard({
onDelete: () => void;
}) {
const Icon = automation.icon;
const runDisabled = busy || running || !automation.apiEnabled;
const runDisabled = busy || running || !automation.apiEnabled || automation.environmentId === null;
return (
<div className="group flex items-center gap-4 rounded-md border border-line bg-panel/80 p-4 transition-all duration-200 hover:border-line-strong hover:bg-panel hover:shadow-lg hover:shadow-black/20">
<Link to={`/automations/${automation.id}`} className="flex min-w-0 flex-1 items-center gap-4">
@ -146,7 +148,12 @@ function AutomationCard({
</span>
)}
</div>
<p className="mt-1 text-xs text-fg-muted">{automation.repository}</p>
<p className="mt-1 text-xs text-fg-muted">
{automation.repository}
<span className={automation.environmentId ? "" : " text-coral"}>
{" · "}{automation.environmentId ?? "environment required"}
</span>
</p>
</div>
</Link>
@ -168,7 +175,9 @@ function AutomationCard({
running
? "Starting run..."
: automation.apiEnabled
? "Run automation"
? automation.environmentId
? "Run automation"
: "Select an environment before running this automation"
: "Enable the API trigger to run it"
}
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-mint/20 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-mint"

View file

@ -6737,6 +6737,8 @@ components:
- revision
- name
- description
- environment_id
- last_error
- target
- workflow
- triggers
@ -6756,6 +6758,16 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
environment_id:
type: ["string", "null"]
description: |
Server-managed Docker or Daytona environment selected when the
automation fires. Null only for an incomplete definition migrated
from a release that predated environment selection.
example: daytona-smoke
last_error:
type: ["string", "null"]
description: Most recent scheduled-run failure, cleared after a scheduled run is queued successfully.
target:
$ref: "#/components/schemas/RunTarget"
workflow:
@ -6832,6 +6844,7 @@ components:
required:
- id
- name
- environment_id
- target
- workflow
- triggers
@ -6846,6 +6859,10 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
environment_id:
type: string
description: Server-managed Docker or Daytona environment selected when the automation fires.
example: daytona-smoke
target:
$ref: "#/components/schemas/RunTarget"
workflow:
@ -6863,6 +6880,7 @@ components:
additionalProperties: false
required:
- name
- environment_id
- target
- workflow
- triggers
@ -6873,6 +6891,10 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
environment_id:
type: string
description: Server-managed Docker or Daytona environment selected when the automation fires.
example: daytona-smoke
target:
$ref: "#/components/schemas/RunTarget"
workflow:

View file

@ -3,7 +3,7 @@ title: "Automations"
description: "Named, repeatable run configurations with API and schedule triggers"
---
An **automation** is a saved run configuration — a Git repository, working branch, optional tag or exact commit, and workflow — plus the triggers that may start it. Every trigger fire creates and starts a normal Fabro run through the same pipeline as `POST /api/v1/runs`, so automation runs get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
An **automation** is a saved run configuration — a Git repository, working branch, optional tag or exact commit, workflow, and server-managed environment — plus the triggers that may start it. When a trigger fires, Fabro packages the selected workflow as an immutable workflow version and admits it through the same `RunIntent` pipeline as `POST /api/v1/runs`. Automation runs therefore get the same lifecycle, events, and observability as manually created runs. Each run records the automation and trigger that created it.
## Defining automations
@ -13,8 +13,10 @@ New definitions use Fabro's canonical Git run target. The working branch is alwa
```json title="Create automation request"
{
"id": "nightly-release",
"name": "Nightly release",
"description": "Cut a nightly build from main",
"environment_id": "ci",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
@ -31,6 +33,18 @@ New definitions use Fabro's canonical Git run target. The working branch is alwa
Automations currently support Git targets only. Folder and empty run targets are rejected during validation.
### Environment selection
Every new or edited automation must select a server-managed Docker or Daytona environment. Local environments are intentionally unavailable because automation Git targets require a clone-based provider. Fabro validates that the selected environment exists, uses a compatible provider, and is enabled and ready on the server.
The automation stores the environment ID rather than a copy of its settings. Each trigger fire resolves the current environment definition and snapshots those settings into the new run. Deleting an environment that an automation still references returns a conflict; edit or delete the automation first.
### Workflow resolution
An extensionless workflow such as `"release"` resolves directly to `.fabro/workflows/release/workflow.toml` in the selected repository checkout. You may also provide an explicit repository-relative workflow path.
Automation admission does not read `.fabro/project.toml`. Put settings needed by the run in the workflow configuration or the selected server environment. Fabro packages the workflow and its runnable dependencies into immutable workflow versions before creating the run.
### Upgrading legacy targets
When upgrading from file-backed automation storage, startup imports every valid `automations/*.toml` file next to the active `settings.toml`. Existing SQLite definitions win on ID conflicts. After a successful import, Fabro renames the directory to a timestamped backup such as `automations.imported-20260711T180000000000Z.bak`. Invalid TOML or an invalid target leaves the original directory untouched for operator repair.
@ -70,7 +84,9 @@ The `main` default is only a migration assumption. If the repository uses anothe
The same conversion runs transactionally for automations already in SQLite. An unsupported `refs/*` selector or an invalid branch or tag name aborts startup with an actionable error instead of guessing. The database remains on its previous schema and data, and the migration snapshot remains available. Edit the unsupported legacy `target_ref` to a branch, head selector, tag selector, `HEAD`, or exact SHA, then restart Fabro.
When a trigger fires, Fabro clones the repository at the selected branch, tag, or exact commit, resolves the workflow, and creates and starts the run. The created run records the exact checked-out commit in its canonical target, so later inspection and automation creation preserve the revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
Automations created before environment selection was introduced are backfilled conservatively. Fabro selects a compatible environment named `default` when one exists, or the sole Docker or Daytona environment when there is exactly one. With no compatible environment or multiple ambiguous choices, the automation remains incomplete until an operator selects one in the web UI. An incomplete automation cannot run.
When a trigger fires, Fabro prepares the repository at the selected branch, tag, or exact commit, packages the workflow, and creates and starts the run. The created run records the exact checked-out commit in its canonical target, so later inspection and automation creation preserve the revision that actually ran. Repositories are cached server-side as bare clones, so repeat fires fetch only what changed.
## Triggers
@ -92,14 +108,14 @@ enabled = true
expression = "0 9 * * 1-5"
```
The server fires each enabled schedule trigger at its next occurrence and creates and starts a run. Creating, editing, or deleting an automation takes effect immediately — no restart needed. If a fire fails (for example, the clone or workflow resolution errors), Fabro logs a warning and waits for the next occurrence rather than retrying.
The server fires each enabled schedule trigger at its next occurrence and creates and starts a run. Creating, editing, or deleting an automation takes effect immediately — no restart needed. If a fire fails (for example, because its environment is unavailable or workflow packaging fails), Fabro stores the failure on the automation as `last_error`, logs it, and waits for the next occurrence rather than retrying. The error is cleared after a later scheduled run is queued successfully.
## Web UI
The `/automations` area lists automations with create, edit, delete, and Run actions. Saves are revision-checked, so concurrent edits fail loudly instead of silently overwriting each other. The detail page shows the automation's configuration and its run history with status, time, and repo filters.
The `/automations` area lists automations with create, edit, delete, and Run actions. The create and edit forms require a Docker or Daytona environment. Migrated automations without an environment are shown as incomplete and cannot run until edited. Saves are revision-checked, so concurrent edits fail loudly instead of silently overwriting each other. The detail page shows the automation's configuration, its most recent schedule error, and its run history with status, time, and repo filters.
To bootstrap an automation from work you have already run, open a run's actions menu and choose **Create automation from run** — the new-automation form is pre-filled from that run's repository and workflow. Runs that were created by an automation show **View automation** instead.
## API
`/api/v1/automations` provides full CRUD: list, create, fetch, replace, and delete. Responses carry an `ETag` revision; `PUT` and `DELETE` require a matching `If-Match` header. `GET /api/v1/automations/{id}/runs` lists the automation's runs newest-first with standard pagination.
`/api/v1/automations` provides full CRUD: list, create, fetch, replace, and delete. `environment_id` is required in create and replace requests. Automation responses may return a null environment only for an incomplete migrated definition, and expose the latest scheduled-run failure through `last_error`. Responses carry an `ETag` revision; `PUT` and `DELETE` require a matching `If-Match` header. `GET /api/v1/automations/{id}/runs` lists the automation's runs newest-first with standard pagination.

View file

@ -33,12 +33,12 @@ pub(crate) struct AutomationRunMaterialized {
impl AutomationRunMaterialized {
/// The admission request for an automation run: the packaged workflow
/// version at the exact checked-out target, with no caller overrides.
pub(crate) fn into_run_intent(self) -> RunIntent {
pub(crate) fn into_run_intent(self, environment_id: String) -> RunIntent {
RunIntent {
workflow_version_id: self.workflow_version_id,
target: RunTarget::Git(self.target),
args: RunIntentArgs::default(),
environment_id: None,
environment_id: Some(environment_id),
parent_id: None,
title: None,
goal: None,
@ -367,7 +367,6 @@ mod tests {
let checkout = temp.path().join("checkout");
let workflow_dir = checkout.join(".fabro/workflows/root");
fs::create_dir_all(&workflow_dir).unwrap();
fs::write(checkout.join(".fabro/project.toml"), "_version = 1\n").unwrap();
fs::write(
workflow_dir.join("workflow.toml"),
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n",

View file

@ -50,7 +50,7 @@ pub use fabro_api::types::{
WriteBlobResponse,
};
use fabro_auth::{CredentialSource, SqlVaultCredentialSource, auth_issue_message};
use fabro_automation::AutomationStore;
use fabro_automation::{self, AutomationStore};
use fabro_config::daemon::ServerDaemon;
use fabro_config::{RunLayer, Storage, WorkflowSettingsBuilder};
use fabro_db::DbPool;
@ -2430,6 +2430,13 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
automation_materializer_override,
} = config;
let automation_migration_pool = db_pool.clone();
load_store_blocking("automation environment migration", move || async move {
fabro_automation::backfill_environment_selectors(&automation_migration_pool)
.await
.map_err(anyhow::Error::new)
})
.context("backfill automation environment selectors")?;
let automation_store = Arc::new(AutomationStore::new(db_pool.clone()));
let local_provider_enabled = resolved_settings
.server_settings

View file

@ -2,13 +2,14 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::http::HeaderMap;
use axum::http::{HeaderMap, StatusCode};
use chrono::{DateTime, Utc};
use croner::errors::CronError;
use fabro_automation::{
Automation, AutomationId, AutomationRevision, AutomationTriggerId, parse_schedule_expression,
};
use fabro_types::{AutomationRef, Principal, RunId, SystemActorKind};
use fabro_util::error as error_util;
use tokio::time::sleep;
use tracing::{Instrument, error, info, info_span, warn};
@ -229,7 +230,29 @@ async fn fire_scheduled_automation_run(
) {
let automation_id = automation.id.clone();
let run_id = RunId::new();
let environment_id = match handler::automations::resolve_automation_environment(
state.as_ref(),
automation.environment_id.as_deref(),
StatusCode::CONFLICT,
) {
Ok(environment_id) => environment_id,
Err(err) => {
record_scheduler_error(state.as_ref(), &automation_id, err.detail()).await;
error!(
due_at = %due_at,
error = ?err,
"Scheduled automation environment is not runnable",
);
return;
}
};
let Some(target) = automation.git_target().cloned() else {
record_scheduler_error(
state.as_ref(),
&automation_id,
"Stored automation target is not Git-backed",
)
.await;
error!(
automation_id = %automation_id,
"Stored automation target is not Git-backed",
@ -248,6 +271,8 @@ async fn fire_scheduled_automation_run(
{
Ok(materialized) => materialized,
Err(err) => {
let message = error_util::collect_chain(&err).join(": ");
record_scheduler_error(state.as_ref(), &automation_id, &message).await;
error!(
due_at = %due_at,
error = ?err,
@ -270,7 +295,7 @@ async fn fire_scheduled_automation_run(
let response = Box::pin(handler::runs::create_run_from_intent(
Arc::clone(&state),
handler::runs::CreateRunFromIntentRequest {
intent: materialized.into_run_intent(),
intent: materialized.into_run_intent(environment_id),
explicit_run_id: Some(run_id),
actor: actor.clone(),
headers: HeaderMap::new(),
@ -281,6 +306,12 @@ async fn fire_scheduled_automation_run(
let status = response.status();
if !status.is_success() {
record_scheduler_error(
state.as_ref(),
&automation_id,
&format!("Failed to create scheduled automation run ({status})"),
)
.await;
warn!(
run_id = %run_id,
due_at = %due_at,
@ -293,6 +324,7 @@ async fn fire_scheduled_automation_run(
if let Err(err) =
handler::lifecycle::queue_run_start(state.as_ref(), run_id, false, actor).await
{
record_scheduler_error(state.as_ref(), &automation_id, err.detail()).await;
warn!(
run_id = %run_id,
due_at = %due_at,
@ -303,6 +335,8 @@ async fn fire_scheduled_automation_run(
return;
}
clear_scheduler_error(state.as_ref(), &automation_id).await;
info!(
run_id = %run_id,
due_at = %due_at,
@ -310,6 +344,30 @@ 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",
);
}
}
async fn clear_scheduler_error(state: &AppState, id: &AutomationId) {
if let Err(err) = state.automation_store().set_last_error(id, None).await {
error!(
automation_id = %id,
error = ?err,
"Failed to clear automation scheduler error",
);
}
}
/// Drive one tick of the scheduler from a test. Boxed so the calling test
/// future stays small (clippy `large_futures`).
#[cfg(test)]
@ -379,6 +437,8 @@ mod tests {
revision: AutomationRevision::from_bytes(format!("{id}:{name}").as_bytes()),
name: name.to_string(),
description: None,
environment_id: Some("default".to_string()),
last_error: None,
target: target(),
workflow: "workflow.fabro".to_string(),
triggers,
@ -397,6 +457,7 @@ mod tests {
id: AutomationId::new(id).expect("test automation id should be valid"),
name: name.to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "workflow.fabro".to_string(),
triggers,
@ -568,10 +629,15 @@ mod tests {
async fn due_schedule_only_automation_creates_started_run_with_automation_metadata() {
let materializer = succeeding_materializer();
let state = test_state_with_materializer(materializer);
create_automation(state.as_ref(), "nightly", "Nightly", vec![
let automation = create_automation(state.as_ref(), "nightly", "Nightly", vec![
schedule_trigger("schedule", "* * * * *", true),
])
.await;
state
.automation_store()
.set_last_error(&automation.id, Some("old failure"))
.await
.unwrap();
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
@ -579,6 +645,16 @@ mod tests {
let runs = cached_runs(state.as_ref()).await;
assert_eq!(runs.len(), 1);
assert_eq!(
state
.automation_store()
.get(&automation.id)
.await
.unwrap()
.unwrap()
.last_error,
None,
);
let automation_ref = runs[0].automation.as_ref().unwrap();
assert_eq!(automation_ref.id, "nightly");
assert_eq!(automation_ref.name.as_deref(), Some("Nightly"));
@ -706,7 +782,7 @@ mod tests {
async fn failing_materializer_waits_until_next_cron_occurrence() {
let materializer = TestAutomationRunMaterializer::fail_invalid_target();
let state = test_state_with_materializer(materializer.clone());
create_automation(state.as_ref(), "nightly", "Nightly", vec![
let automation = create_automation(state.as_ref(), "nightly", "Nightly", vec![
schedule_trigger("schedule", "* * * * *", true),
])
.await;
@ -718,6 +794,16 @@ mod tests {
assert!(cached_runs(state.as_ref()).await.is_empty());
assert_eq!(materializer.captured_inputs().len(), 1);
assert!(
state
.automation_store()
.get(&automation.id)
.await
.unwrap()
.unwrap()
.last_error
.is_some()
);
run_due_schedules_once(Arc::clone(&state), &mut planner, second_due_time()).await;

View file

@ -5,8 +5,9 @@ use axum_extra::extract::Query as ExtraQuery;
use fabro_automation::{
Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationStoreError,
};
use fabro_environment::EnvironmentId;
use fabro_store::{RunSummaryListQuery, RunSummaryVisibility};
use fabro_types::{AutomationRef, RunId};
use fabro_types::{AutomationRef, RunId, SandboxProviderKind};
use fabro_util::error as error_util;
use serde::Serialize;
@ -17,6 +18,7 @@ use super::super::{
use super::{json_with_etag_response, lifecycle, parse_required_if_match, runs};
use crate::automation_materializer::AutomationRunMaterializeInput;
use crate::principal_middleware::RequiredRunToolActor;
use crate::run_manifest;
#[derive(Serialize)]
struct AutomationListResponse {
@ -117,6 +119,14 @@ async fn create_automation_run(
.into_response();
};
let api_trigger_id = api_trigger.id.to_string();
let environment_id = match resolve_automation_environment(
state.as_ref(),
automation.environment_id.as_deref(),
StatusCode::CONFLICT,
) {
Ok(environment_id) => environment_id,
Err(err) => return err.into_response(),
};
let Some(target) = automation.git_target().cloned() else {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
@ -151,7 +161,7 @@ async fn create_automation_run(
let response = Box::pin(runs::create_run_from_intent(
Arc::clone(&state),
runs::CreateRunFromIntentRequest {
intent: materialized.into_run_intent(),
intent: materialized.into_run_intent(environment_id),
explicit_run_id: Some(run_id),
actor: actor.clone(),
headers,
@ -180,8 +190,13 @@ async fn create_automation_run(
async fn create_automation(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(draft): Json<AutomationDraft>,
Json(mut draft): Json<AutomationDraft>,
) -> Result<Response, ApiError> {
draft.environment_id = Some(resolve_automation_environment(
state.as_ref(),
draft.environment_id.as_deref(),
StatusCode::UNPROCESSABLE_ENTITY,
)?);
let automation = state.automation_store().create(draft).await?;
state.notify_automation_scheduler();
Ok((StatusCode::CREATED, Json(automation)).into_response())
@ -204,10 +219,15 @@ async fn replace_automation(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(replacement): Json<AutomationReplace>,
Json(mut replacement): Json<AutomationReplace>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, "automation", &id)?;
replacement.environment_id = Some(resolve_automation_environment(
state.as_ref(),
replacement.environment_id.as_deref(),
StatusCode::UNPROCESSABLE_ENTITY,
)?);
let automation = state
.automation_store()
.replace(&id, &expected, replacement)
@ -234,6 +254,66 @@ fn parse_path_id(id: String) -> Result<AutomationId, ApiError> {
.map_err(|err| ApiError::bad_request(format!("invalid automation id: {err}")))
}
pub(in crate::server) fn resolve_automation_environment(
state: &AppState,
value: Option<&str>,
status: StatusCode,
) -> Result<String, ApiError> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Err(ApiError::with_code(
status,
"automation environment is required",
"automation_environment_required",
));
};
let id = EnvironmentId::new(value.to_string()).map_err(|_| {
ApiError::with_code(
status,
"automation environment id is invalid",
"automation_environment_invalid",
)
})?;
let Some(environment) = state.environment_store().get(&id) else {
return Err(ApiError::with_code(
status,
format!("automation environment not found: {id}"),
"automation_environment_not_found",
));
};
if !environment.settings.provider.is_clone_based() {
return Err(ApiError::with_code(
status,
format!(
"automation environment `{id}` is incompatible; Git-backed automations require Docker or Daytona"
),
"automation_environment_incompatible",
));
}
let provider = SandboxProviderKind::from(environment.settings.provider);
if let Some(message) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
{
return Err(ApiError::with_code(
status,
message,
"automation_environment_provider_disabled",
));
}
if !state
.sandbox_provider_registry()
.providers()
.iter()
.any(|sandbox_provider| sandbox_provider.kind() == provider)
{
return Err(ApiError::with_code(
status,
format!("sandbox provider `{provider}` is not ready on this server"),
"automation_environment_provider_unavailable",
));
}
Ok(id.to_string())
}
fn automation_with_etag_response(status: StatusCode, automation: Automation) -> Response {
let revision = automation.revision.clone();
json_with_etag_response(status, "automation", &revision, automation)

View file

@ -211,6 +211,17 @@ async fn delete_environment(
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, "environment", &id)?;
if state
.automation_store()
.references_environment(id.as_str())
.await?
{
return Err(ApiError::with_code(
StatusCode::CONFLICT,
format!("environment is used by an automation: {id}"),
"environment_in_use",
));
}
state.environment_store().delete(&id, &expected).await?;
state.refresh_manifest_run_settings_from_environment_catalog();
Ok(StatusCode::NO_CONTENT.into_response())

View file

@ -8,7 +8,7 @@ use serde::Serialize;
use super::{ApiError, AppState, IntoResponse, Json, Response, StatusCode, demo};
mod artifacts;
mod automations;
pub(in crate::server) mod automations;
mod billing;
mod completions;
mod environments;

View file

@ -20,6 +20,7 @@ fn automation_body(id: &str, name: &str) -> Value {
"id": id,
"name": name,
"description": "Runs on a schedule.",
"environment_id": "default",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
@ -46,6 +47,7 @@ fn replacement_body(name: &str) -> Value {
json!({
"name": name,
"description": null,
"environment_id": "default",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
@ -269,6 +271,7 @@ async fn create_automation_persists_sql_aggregate() {
assert_eq!(body["id"], "nightly");
assert_eq!(body["name"], "Nightly");
assert_eq!(body["environment_id"], "default");
assert_eq!(
persisted_automation(&sqlite_path, "nightly").await,
Some(json!({
@ -282,6 +285,57 @@ async fn create_automation_persists_sql_aggregate() {
);
}
#[tokio::test]
async fn create_automation_requires_an_environment() {
let (app, _temp_dir, _sqlite_path) = automation_app();
let mut body = automation_body("nightly", "Nightly");
body.as_object_mut()
.expect("automation body should be an object")
.remove("environment_id");
let response = app
.oneshot(json_request(Method::POST, "/automations", &body))
.await
.expect("create automation without environment should respond");
let error = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/automations without environment",
)
.await;
assert_eq!(
error["errors"][0]["code"],
"automation_environment_required"
);
}
#[tokio::test]
async fn create_automation_rejects_missing_and_local_environments() {
let (app, _temp_dir, _sqlite_path) = automation_app();
for (environment_id, expected_code) in [
("missing", "automation_environment_not_found"),
("local", "automation_environment_incompatible"),
] {
let mut body = automation_body(environment_id, "Nightly");
body["environment_id"] = json!(environment_id);
let response = app
.clone()
.oneshot(json_request(Method::POST, "/automations", &body))
.await
.expect("invalid automation environment should respond");
let error = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
format!("POST /api/v1/automations with {environment_id} environment"),
)
.await;
assert_eq!(error["errors"][0]["code"], expected_code);
}
}
#[tokio::test]
async fn automation_persists_across_app_rebuild() {
let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
@ -914,6 +968,72 @@ async fn successful_api_triggered_automation_run_persists_automation_metadata()
assert_eq!(retrieved["automation"], created["automation"]);
}
#[tokio::test]
async fn api_triggered_automation_uses_its_selected_environment() {
let (app, _temp_dir, _sqlite_path) = automation_app_with_fake_materializer();
let environment = json!({
"id": "smoke",
"provider": "docker",
"cwd": null,
"image": { "docker": "buildpack-deps:noble", "dockerfile": null },
"resources": { "cpu": 2, "memory": "4GB", "disk": null },
"network": { "mode": "allow_all", "allow": [] },
"lifecycle": { "preserve": false, "stop_on_terminal": true, "auto_stop": null },
"labels": {},
"env": {}
});
let response = app
.clone()
.oneshot(json_request(Method::POST, "/environments", &environment))
.await
.expect("create smoke environment should respond");
response_status(
response,
StatusCode::CREATED,
"POST /api/v1/environments smoke",
)
.await;
let mut body = automation_body("nightly", "Nightly");
body["environment_id"] = json!("smoke");
create_automation_with_body(&app, &body).await;
let created = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
let run_id = created["id"]
.as_str()
.expect("created automation run should include id");
let response = app
.oneshot(empty_request(
Method::GET,
&format!("/runs/{run_id}/settings"),
))
.await
.expect("automation run settings should respond");
let settings = response_json(response, StatusCode::OK, "GET /api/v1/runs/{id}/settings").await;
assert_eq!(settings["run"]["environment"]["id"], "smoke");
}
#[tokio::test]
async fn incomplete_legacy_automation_fails_before_materialization() {
let materializer = TestAutomationRunMaterializer::fail_invalid_target();
let (app, _temp_dir, sqlite_path) = automation_app_with_materializer(materializer);
create_automation(&app, "nightly", "Nightly").await;
let database = fabro_db::Database::connect(sqlite_path)
.await
.expect("automation test database should open");
sqlx::query("UPDATE automations SET environment_id = NULL WHERE id = 'nightly'")
.execute(database.pool())
.await
.expect("test automation environment should clear");
let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;
assert_eq!(
error["errors"][0]["code"],
"automation_environment_required"
);
}
#[tokio::test]
async fn api_triggered_automation_with_missing_version_does_not_create_or_start_a_run() {
let materializer = TestAutomationRunMaterializer::return_unstored_version(GitRunTarget {

View file

@ -694,6 +694,66 @@ async fn delete_environment_removes_non_default_and_default_is_deletable() {
.await;
}
#[tokio::test]
async fn delete_environment_rejects_an_environment_used_by_an_automation() {
let (app, _temp_dir, _environment_dir) = environment_app();
let environment = create_environment(&app, "automation-env", "docker").await;
let revision = revision_from(&environment);
let automation = json!({
"id": "nightly",
"name": "Nightly",
"description": null,
"environment_id": "automation-env",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "main"
},
"workflow": "release",
"triggers": [{ "type": "api", "id": "manual", "enabled": true }]
});
let response = app
.clone()
.oneshot(json_request(Method::POST, "/automations", &automation))
.await
.expect("create automation should respond");
response_status(
response,
StatusCode::CREATED,
"POST /api/v1/automations using environment",
)
.await;
let response = app
.clone()
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/automation-env",
revision,
None,
))
.await
.expect("delete used environment should respond");
let error = response_json(
response,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/automation-env in use",
)
.await;
assert_eq!(error["errors"][0]["code"], "environment_in_use");
let response = app
.oneshot(empty_request(Method::GET, "/environments/automation-env"))
.await
.expect("used environment should still exist");
response_status(
response,
StatusCode::OK,
"GET /api/v1/environments/automation-env after rejected delete",
)
.await;
}
#[tokio::test]
async fn environment_routes_require_authenticated_user() {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");

View file

@ -105,6 +105,7 @@ fn parse_legacy_automation(
Automation::from_stored(id.clone(), revision, AutomationReplace {
name: legacy.name,
description: legacy.description,
environment_id: None,
target,
workflow,
triggers: legacy.triggers,

View file

@ -0,0 +1,76 @@
//! Assigns a safe environment selector to automations created before the
//! selector column existed. Remove this compatibility migration after
//! 2026-11-28, once supported upgrades no longer span that release.
use fabro_db::DbPool;
use tracing::info;
use crate::{AutomationReplace, AutomationStore, AutomationStoreError};
pub(crate) const REMOVAL_DEADLINE: &str = "2026-11-28";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentSelectorBackfillReport {
pub updated_rows: usize,
pub environment_id: Option<String>,
}
/// Backfill incomplete automations only when selection is unambiguous:
/// prefer a clone-compatible `default`, otherwise use the sole compatible
/// environment. Zero or multiple candidates remain incomplete for an operator
/// to resolve in the UI.
pub async fn backfill_environment_selectors(
pool: &DbPool,
) -> Result<EnvironmentSelectorBackfillReport, AutomationStoreError> {
let compatible_ids = sqlx::query_scalar::<_, String>(
"SELECT id FROM environments WHERE provider IN ('docker', 'daytona') ORDER BY id",
)
.fetch_all(pool)
.await?;
let environment_id = compatible_ids
.iter()
.find(|id| id.as_str() == "default")
.cloned()
.or_else(|| (compatible_ids.len() == 1).then(|| compatible_ids[0].clone()));
let Some(environment_id) = environment_id else {
return Ok(EnvironmentSelectorBackfillReport {
updated_rows: 0,
environment_id: None,
});
};
let store = AutomationStore::new(pool.clone());
let incomplete = store
.list()
.await?
.into_iter()
.filter(|automation| automation.environment_id.is_none())
.collect::<Vec<_>>();
for automation in &incomplete {
store
.replace(&automation.id, &automation.revision, AutomationReplace {
name: automation.name.clone(),
description: automation.description.clone(),
environment_id: Some(environment_id.clone()),
target: automation.target.clone(),
workflow: automation.workflow.clone(),
triggers: automation.triggers.clone(),
})
.await?;
}
if !incomplete.is_empty() {
info!(
updated_rows = incomplete.len(),
environment_id,
removal_deadline = REMOVAL_DEADLINE,
"Backfilled automation environment selectors"
);
}
Ok(EnvironmentSelectorBackfillReport {
updated_rows: incomplete.len(),
environment_id: Some(environment_id),
})
}

View file

@ -15,6 +15,8 @@ pub enum AutomationValidationError {
InvalidAutomationTriggerId { value: String },
#[error("automation name must not be empty")]
EmptyName,
#[error("automation environment is required")]
MissingEnvironment,
#[error("automation target kind {kind:?} is not supported; only Git targets are accepted")]
UnsupportedTarget { kind: String },
#[error("automation Git target is invalid")]

View file

@ -7,7 +7,10 @@ mod store;
pub use error::{AutomationStoreError, AutomationValidationError};
pub use fabro_types::GitHubRepositorySlug;
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
pub use migrations::{ImportReport, import_legacy_directory_once};
pub use migrations::{
EnvironmentSelectorBackfillReport, ImportReport, backfill_environment_selectors,
import_legacy_directory_once,
};
pub use model::{
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTrigger, ScheduleTrigger,
parse_schedule_expression,

View file

@ -1,5 +1,10 @@
#[path = "../migrations/2026082801_environment_selectors.rs"]
mod environment_selectors;
#[path = "../migrations/2026071101_file_definitions_to_sqlite.rs"]
mod file_definitions_to_sqlite;
pub use environment_selectors::{
EnvironmentSelectorBackfillReport, backfill_environment_selectors,
};
pub use fabro_db::ImportReport;
pub use file_definitions_to_sqlite::import_legacy_directory_once;

View file

@ -34,13 +34,19 @@ pub fn parse_schedule_expression(expression: &str) -> Result<Cron, CronError> {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Automation {
pub id: AutomationId,
pub revision: AutomationRevision,
pub name: String,
pub description: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
pub id: AutomationId,
pub revision: AutomationRevision,
pub name: String,
pub description: Option<String>,
/// Server-managed environment selected when the automation fires. Legacy
/// rows may be incomplete until an operator selects one.
pub environment_id: Option<String>,
/// Most recent scheduler failure. Runtime status is not part of the
/// optimistic-concurrency revision.
pub last_error: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
}
impl Automation {
@ -54,7 +60,7 @@ impl Automation {
id: AutomationId,
draft: AutomationReplace,
) -> Result<(Self, Vec<u8>), AutomationStoreError> {
let draft = normalize_replace(draft)?;
let draft = normalize_replace(draft, true)?;
let persisted = PersistedAutomation::from(draft.clone());
let bytes = canonical_bytes(&persisted)?;
let revision = AutomationRevision::from_bytes(&bytes);
@ -67,7 +73,7 @@ impl Automation {
revision: AutomationRevision,
value: AutomationReplace,
) -> Result<Self, AutomationValidationError> {
let value = normalize_replace(value)?;
let value = normalize_replace(value, false)?;
Ok(Self::from_validated_replace(id, revision, value))
}
@ -116,7 +122,7 @@ impl Automation {
revision: AutomationRevision,
persisted: PersistedAutomation,
) -> Result<Self, AutomationValidationError> {
let replace = normalize_replace(AutomationReplace::from(persisted))?;
let replace = normalize_replace(AutomationReplace::from(persisted), false)?;
Ok(Self::from_validated_replace(id, revision, replace))
}
@ -130,6 +136,8 @@ impl Automation {
revision,
name: replace.name,
description: replace.description,
environment_id: replace.environment_id,
last_error: None,
target: replace.target,
workflow: replace.workflow,
triggers: replace.triggers,
@ -192,23 +200,26 @@ pub struct ScheduleTrigger {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationDraft {
pub id: AutomationId,
pub name: String,
pub id: AutomationId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
pub description: Option<String>,
#[serde(default)]
pub environment_id: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
}
impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
fn from(value: AutomationDraft) -> Self {
(value.id, AutomationReplace {
name: value.name,
description: value.description,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
})
}
}
@ -216,34 +227,39 @@ impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationReplace {
pub name: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
pub description: Option<String>,
#[serde(default)]
pub environment_id: Option<String>,
pub target: RunTarget,
pub workflow: String,
pub triggers: Vec<AutomationTrigger>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PersistedAutomation {
name: String,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
target: RunTarget,
workflow: String,
description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
environment_id: Option<String>,
target: RunTarget,
workflow: String,
#[serde(default)]
triggers: Vec<AutomationTrigger>,
triggers: Vec<AutomationTrigger>,
}
impl From<AutomationReplace> for PersistedAutomation {
fn from(value: AutomationReplace) -> Self {
Self {
name: value.name,
description: value.description,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
}
}
}
@ -251,11 +267,12 @@ impl From<AutomationReplace> for PersistedAutomation {
impl From<PersistedAutomation> for AutomationReplace {
fn from(value: PersistedAutomation) -> Self {
Self {
name: value.name,
description: value.description,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
name: value.name,
description: value.description,
environment_id: value.environment_id,
target: value.target,
workflow: value.workflow,
triggers: value.triggers,
}
}
}
@ -281,19 +298,36 @@ fn parse_persisted(
})
}
fn validate_fields(value: &AutomationReplace) -> Result<(), AutomationValidationError> {
fn validate_fields(
value: &AutomationReplace,
require_environment: bool,
) -> Result<(), AutomationValidationError> {
if value.name.trim().is_empty() {
return Err(AutomationValidationError::EmptyName);
}
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)
}
fn normalize_replace(
mut value: AutomationReplace,
require_environment: bool,
) -> Result<AutomationReplace, AutomationValidationError> {
value.target = validate_target(value.target)?;
validate_fields(&value)?;
value.environment_id = value
.environment_id
.map(|environment_id| environment_id.trim().to_string());
validate_fields(&value, require_environment)?;
let api_enabled = value
.triggers
@ -502,11 +536,12 @@ enabled = true
fn enabled_schedule_triggers_returns_only_enabled_schedule_triggers() {
let (automation, _) =
Automation::from_replace(AutomationId::new("nightly").unwrap(), AutomationReplace {
name: "Nightly".to_string(),
description: None,
target: target(),
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
triggers: vec![
name: "Nightly".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: ".fabro/workflows/test/workflow.toml".to_string(),
triggers: vec![
api_trigger("manual"),
schedule_trigger_with_enabled("nightly", "0 0 * * *", true),
schedule_trigger_with_enabled("disabled", "0 1 * * *", false),
@ -552,73 +587,81 @@ enabled = true
fn validation_rejects_invalid_inputs() {
let cases = [
AutomationReplace {
name: " ".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
name: " ".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad repo".to_string(),
description: None,
target: RunTarget::Git(GitRunTarget {
name: "Bad repo".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: RunTarget::Git(GitRunTarget {
repo: "not/github/slug".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
}),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad ref".to_string(),
description: None,
target: RunTarget::Git(GitRunTarget {
name: "Bad ref".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main;rm".to_string(),
tag: None,
sha: None,
}),
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
workflow: "release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad workflow".to_string(),
description: None,
target: target(),
workflow: "../release".to_string(),
triggers: vec![api_trigger("manual")],
name: "Bad workflow".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "../release".to_string(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Duplicate trigger".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![
name: "Duplicate trigger".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
api_trigger("manual"),
schedule_trigger("manual", "0 0 * * *"),
],
},
AutomationReplace {
name: "Two API triggers".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("one"), api_trigger("two")],
name: "Two API triggers".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![api_trigger("one"), api_trigger("two")],
},
AutomationReplace {
name: "Six field cron".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
name: "Six field cron".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
},
AutomationReplace {
name: "Bad cron".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
name: "Bad cron".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
},
];

View file

@ -20,6 +20,8 @@ macro_rules! select_automations_sql {
a.revision,
a.name,
a.description,
a.environment_id,
a.last_error,
a.api_enabled,
a.target_repository,
a.target_branch,
@ -77,6 +79,33 @@ impl AutomationStore {
Ok(row.is_some())
}
pub async fn references_environment(
&self,
environment_id: &str,
) -> Result<bool, AutomationStoreError> {
let row = sqlx::query("SELECT 1 FROM automations WHERE environment_id = ? LIMIT 1")
.bind(environment_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
pub async fn set_last_error(
&self,
id: &AutomationId,
message: Option<&str>,
) -> Result<(), AutomationStoreError> {
let result = sqlx::query("UPDATE automations SET last_error = ? WHERE id = ?")
.bind(message)
.bind(id.as_str())
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(AutomationStoreError::NotFound { id: id.clone() });
}
Ok(())
}
pub async fn create(&self, draft: AutomationDraft) -> Result<Automation, AutomationStoreError> {
let (id, replace) = draft.into();
let (automation, _) = Automation::from_replace(id.clone(), replace)?;
@ -103,6 +132,8 @@ impl AutomationStore {
revision = ?,
name = ?,
description = ?,
environment_id = ?,
last_error = NULL,
api_enabled = ?,
target_repository = ?,
target_branch = ?,
@ -115,6 +146,7 @@ impl AutomationStore {
.bind(automation.revision.as_str())
.bind(&automation.name)
.bind(automation.description.as_deref())
.bind(automation.environment_id.as_deref())
.bind(automation.api_enabled())
.bind(&target.repo)
.bind(&target.branch)
@ -162,6 +194,8 @@ struct StoredAutomation {
revision: AutomationRevision,
name: String,
description: Option<String>,
environment_id: Option<String>,
last_error: Option<String>,
api_enabled: bool,
target: RunTarget,
workflow: String,
@ -187,6 +221,8 @@ impl StoredAutomation {
revision,
name: row.try_get("name")?,
description: row.try_get("description")?,
environment_id: row.try_get("environment_id")?,
last_error: row.try_get("last_error")?,
api_enabled: row.try_get("api_enabled")?,
target: RunTarget::Git(GitRunTarget {
repo: row.try_get("target_repository")?,
@ -236,14 +272,21 @@ impl StoredAutomation {
triggers.push(AutomationTrigger::Api(ApiTrigger::manual()));
}
let id = self.id;
Automation::from_stored(id.clone(), self.revision, AutomationReplace {
name: self.name,
description: self.description,
target: self.target,
workflow: self.workflow,
triggers,
})
.map_err(|source| AutomationStoreError::StoredValidation { id, source })
let mut automation =
Automation::from_stored(id.clone(), self.revision, AutomationReplace {
name: self.name,
description: self.description,
environment_id: self.environment_id,
target: self.target,
workflow: self.workflow,
triggers,
})
.map_err(|source| AutomationStoreError::StoredValidation {
id: id.clone(),
source,
})?;
automation.last_error = self.last_error;
Ok(automation)
}
}
@ -291,13 +334,14 @@ pub(crate) async fn insert_automation_ignoring_conflict(
revision,
name,
description,
environment_id,
api_enabled,
target_repository,
target_branch,
target_tag,
target_sha,
target_workflow
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
",
)
@ -305,6 +349,7 @@ pub(crate) async fn insert_automation_ignoring_conflict(
.bind(automation.revision.as_str())
.bind(&automation.name)
.bind(automation.description.as_deref())
.bind(automation.environment_id.as_deref())
.bind(automation.api_enabled())
.bind(&target.repo)
.bind(&target.branch)

View file

@ -19,6 +19,7 @@ async fn test_database() -> (tempfile::TempDir, Database) {
.await
.unwrap();
database.migrate().await.unwrap();
insert_environment(database.pool(), "default", "docker").await;
(dir, database)
}
@ -41,12 +42,13 @@ fn schedule(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
AutomationDraft {
id: AutomationId::new(id).unwrap(),
name: "Nightly".to_string(),
description: Some("Runs every night".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
id: AutomationId::new(id).unwrap(),
name: "Nightly".to_string(),
description: Some("Runs every night".to_string()),
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
schedule("z-last", "0 2 * * *", false),
AutomationTrigger::Api(ApiTrigger {
id: AutomationTriggerId::new("custom-api-id").unwrap(),
@ -59,11 +61,12 @@ fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
fn replacement(name: &str, expression: &str) -> AutomationReplace {
AutomationReplace {
name: name.to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![
name: name.to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![
schedule("nightly", expression, true),
AutomationTrigger::Api(ApiTrigger {
id: AutomationTriggerId::new("api").unwrap(),
@ -87,6 +90,7 @@ async fn crud_normalizes_api_and_schedule_order() {
let store = AutomationStore::new(database.clone_pool());
let created = store.create(draft("nightly", true)).await.unwrap();
assert_eq!(created.environment_id.as_deref(), Some("default"));
assert_eq!(trigger_ids(&created), vec!["manual", "a-first", "z-last"]);
assert!(created.enabled_api_trigger().is_some());
@ -112,6 +116,158 @@ async fn crud_normalizes_api_and_schedule_order() {
assert!(store.get(&replaced.id).await.unwrap().is_none());
}
#[tokio::test]
async fn create_requires_an_environment_and_environment_changes_revision() {
let (_dir, database) = test_database().await;
let store = AutomationStore::new(database.clone_pool());
let mut missing = draft("missing", true);
missing.environment_id = None;
let error = store.create(missing).await.unwrap_err();
assert!(matches!(error, AutomationStoreError::Validation { .. }));
let created = store.create(draft("nightly", true)).await.unwrap();
insert_environment(database.pool(), "daytona-smoke", "daytona").await;
let mut update = replacement("Nightly", "0 1 * * *");
update.environment_id = Some("daytona-smoke".to_string());
let replaced = store
.replace(&created.id, &created.revision, update)
.await
.unwrap();
assert_eq!(replaced.environment_id.as_deref(), Some("daytona-smoke"));
assert_ne!(replaced.revision, created.revision);
store
.set_last_error(&replaced.id, Some("environment unavailable"))
.await
.unwrap();
let failed = store.get(&replaced.id).await.unwrap().unwrap();
assert_eq!(
failed.last_error.as_deref(),
Some("environment unavailable")
);
assert_eq!(failed.revision, replaced.revision);
store.set_last_error(&replaced.id, None).await.unwrap();
assert_eq!(
store.get(&replaced.id).await.unwrap().unwrap().last_error,
None,
);
}
#[tokio::test]
async fn legacy_environment_backfill_prefers_default_then_a_single_compatible_environment() {
let (_dir, database) = test_database().await;
insert_incomplete_automation(database.pool(), "with-default").await;
insert_environment(database.pool(), "daytona-smoke", "daytona").await;
let report = fabro_automation::backfill_environment_selectors(database.pool())
.await
.unwrap();
let store = AutomationStore::new(database.clone_pool());
let migrated = store
.get(&AutomationId::new("with-default").unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(report.updated_rows, 1);
assert_eq!(report.environment_id.as_deref(), Some("default"));
assert_eq!(migrated.environment_id.as_deref(), Some("default"));
assert_ne!(migrated.revision.as_str(), &"a".repeat(64));
sqlx::query("DELETE FROM automations WHERE id = 'with-default'")
.execute(database.pool())
.await
.unwrap();
sqlx::query("DELETE FROM environments WHERE id = 'default'")
.execute(database.pool())
.await
.unwrap();
insert_incomplete_automation(database.pool(), "single").await;
let report = fabro_automation::backfill_environment_selectors(database.pool())
.await
.unwrap();
let migrated = store
.get(&AutomationId::new("single").unwrap())
.await
.unwrap()
.unwrap();
assert_eq!(report.environment_id.as_deref(), Some("daytona-smoke"));
assert_eq!(migrated.environment_id.as_deref(), Some("daytona-smoke"));
}
#[tokio::test]
async fn legacy_environment_backfill_leaves_ambiguous_or_empty_catalogs_incomplete() {
let (_dir, database) = test_database().await;
sqlx::query("DELETE FROM environments WHERE id = 'default'")
.execute(database.pool())
.await
.unwrap();
insert_incomplete_automation(database.pool(), "empty").await;
let empty = fabro_automation::backfill_environment_selectors(database.pool())
.await
.unwrap();
assert_eq!(empty.updated_rows, 0);
assert_eq!(empty.environment_id, None);
insert_environment(database.pool(), "docker-one", "docker").await;
insert_environment(database.pool(), "daytona-two", "daytona").await;
insert_incomplete_automation(database.pool(), "ambiguous").await;
let ambiguous = fabro_automation::backfill_environment_selectors(database.pool())
.await
.unwrap();
let store = AutomationStore::new(database.clone_pool());
assert_eq!(ambiguous.updated_rows, 0);
assert_eq!(ambiguous.environment_id, None);
assert_eq!(
store
.get(&AutomationId::new("ambiguous").unwrap())
.await
.unwrap()
.unwrap()
.environment_id,
None,
);
}
async fn insert_incomplete_automation(pool: &fabro_db::DbPool, id: &str) {
sqlx::query(
r"
INSERT INTO automations (
id, revision, name, api_enabled, target_repository, target_branch,
target_tag, target_sha, target_workflow, environment_id
) VALUES (?, ?, 'Legacy', 1, 'fabro-sh/fabro', 'main', NULL, NULL, 'release', NULL)
",
)
.bind(id)
.bind("a".repeat(64))
.execute(pool)
.await
.unwrap();
}
async fn insert_environment(pool: &fabro_db::DbPool, id: &str, provider: &str) {
sqlx::query(
r"
INSERT INTO environments (
id, revision, provider, network_mode,
lifecycle_preserve, lifecycle_stop_on_terminal
) VALUES (?, ?, ?, 'allow_all', 0, 1)
",
)
.bind(id)
.bind("b".repeat(64))
.bind(provider)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn disabled_api_trigger_normalizes_to_absent() {
let (_dir, database) = test_database().await;
@ -169,6 +325,7 @@ async fn independent_pools_observe_writes_and_revision_conflicts() {
let path = dir.path().join("fabro.sqlite3");
let first_database = Database::connect(&path).await.unwrap();
first_database.migrate().await.unwrap();
insert_environment(first_database.pool(), "default", "docker").await;
let second_database = Database::connect(&path).await.unwrap();
second_database.migrate().await.unwrap();
let first = AutomationStore::new(first_database.clone_pool());
@ -218,11 +375,12 @@ async fn failed_schedule_insert_rolls_back_parent_replace() {
.await
.unwrap();
let replacement = AutomationReplace {
name: "Should roll back".to_string(),
description: None,
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule("blocked", "0 7 * * *", true)],
name: "Should roll back".to_string(),
description: None,
environment_id: Some("default".to_string()),
target: target(),
workflow: "release".to_string(),
triggers: vec![schedule("blocked", "0 7 * * *", true)],
};
let err = store

View file

@ -72,28 +72,18 @@ pub enum WorkflowVersionCollectError {
}
/// Package one workflow and every separately runnable dependency from a local
/// checkout. All paths are rooted at `checkout_root`, so moving the physical
/// checkout does not change canonical version bytes or IDs.
/// checkout. An extensionless selector names
/// `.fabro/workflows/<selector>/workflow.toml` directly; repository project
/// settings and user workflows do not participate in selection. All paths are
/// rooted at `checkout_root`, so moving the physical checkout does not change
/// canonical version bytes or IDs.
pub fn collect_workflow_versions(
workflow: &Path,
checkout_root: &Path,
) -> Result<CollectedWorkflowClosure, WorkflowVersionCollectError> {
let location =
crate::resolve_existing_workflow_location(workflow, checkout_root).map_err(|source| {
if matches!(
source.downcast_ref::<fabro_config::Error>(),
Some(fabro_config::Error::WorkflowNotFound(_))
) {
WorkflowVersionCollectError::WorkflowNotFound {
path: workflow.to_path_buf(),
}
} else {
WorkflowVersionCollectError::Collect {
path: workflow.to_path_buf(),
source,
}
}
})?;
let repository_workflow = repository_workflow_path(workflow);
let location = crate::resolve_existing_workflow_location(&repository_workflow, checkout_root)
.map_err(|source| location_error(workflow, source))?;
let inputs = HashMap::new();
let collected = WorkflowBundler::new(checkout_root, &inputs)
@ -105,6 +95,32 @@ pub fn collect_workflow_versions(
VersionAssembler::new(collected).assemble()
}
fn repository_workflow_path(workflow: &Path) -> PathBuf {
if workflow.is_relative() && workflow.extension().is_none() {
Path::new(".fabro/workflows")
.join(workflow)
.join("workflow.toml")
} else {
workflow.to_path_buf()
}
}
fn location_error(workflow: &Path, source: anyhow::Error) -> WorkflowVersionCollectError {
if matches!(
source.downcast_ref::<fabro_config::Error>(),
Some(fabro_config::Error::WorkflowNotFound(_))
) {
WorkflowVersionCollectError::WorkflowNotFound {
path: workflow.to_path_buf(),
}
} else {
WorkflowVersionCollectError::Collect {
path: workflow.to_path_buf(),
source,
}
}
}
struct VersionAssembler {
root_key: String,
/// Sources still waiting to be assembled; each is removed once visited.
@ -253,7 +269,6 @@ mod tests {
}
fn write_complete_fixture(root: &Path) {
write(root, ".fabro/project.toml", "_version = 1\n");
write(
root,
".fabro/workflows/root/workflow.toml",
@ -297,7 +312,7 @@ dockerfile = { path = "Dockerfile" }
}
#[test]
fn packages_complete_checkout_relative_dependency_closure() {
fn packages_named_workflow_without_project_config() {
let temp = tempfile::tempdir().unwrap();
write_complete_fixture(temp.path());
@ -361,10 +376,39 @@ dockerfile = { path = "Dockerfile" }
);
}
#[test]
fn named_and_explicit_selectors_ignore_project_config() {
let temp = tempfile::tempdir().unwrap();
write_complete_fixture(temp.path());
let named = collect_workflow_versions(Path::new("root"), temp.path()).unwrap();
write(
temp.path(),
".fabro/project.toml",
"this project config must not be loaded",
);
let explicit = collect_workflow_versions(
Path::new(".fabro/workflows/root/workflow.toml"),
temp.path(),
)
.unwrap();
assert_eq!(named.root_id(), explicit.root_id());
assert_eq!(
named
.versions()
.map(|(id, version)| (id, version.version().canonical_bytes().unwrap()))
.collect::<Vec<_>>(),
explicit
.versions()
.map(|(id, version)| (id, version.version().canonical_bytes().unwrap()))
.collect::<Vec<_>>()
);
}
#[test]
fn packages_nested_imported_and_diamond_dependencies_deterministically() {
let temp = tempfile::tempdir().unwrap();
write(temp.path(), ".fabro/project.toml", "_version = 1\n");
write(
temp.path(),
".fabro/workflows/root/workflow.toml",
@ -449,7 +493,6 @@ dockerfile = { path = "Dockerfile" }
#[test]
fn rejects_dependency_cycles() {
let temp = tempfile::tempdir().unwrap();
write(temp.path(), ".fabro/project.toml", "_version = 1\n");
write(
temp.path(),
".fabro/workflows/root/workflow.toml",

View file

@ -22,6 +22,8 @@ fn automation_response_round_trips_public_json_shape() {
"revision": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"name": "Nightly dependency update",
"description": null,
"environment_id": "daytona-smoke",
"last_error": null,
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
@ -55,6 +57,7 @@ fn create_automation_request_round_trips_public_json_shape() {
"id": "nightly-deps",
"name": "Nightly dependency update",
"description": "Keep dependencies fresh",
"environment_id": "daytona-smoke",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
@ -79,6 +82,7 @@ fn replace_automation_request_round_trips_public_json_shape() {
let value = json!({
"name": "Nightly dependency update",
"description": "Keep dependencies fresh",
"environment_id": "daytona-smoke",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",

View file

@ -0,0 +1,9 @@
ALTER TABLE automations
ADD COLUMN environment_id TEXT
REFERENCES environments(id) ON DELETE RESTRICT;
ALTER TABLE automations
ADD COLUMN last_error TEXT;
CREATE INDEX automations_environment_id_idx
ON automations(environment_id);

View file

@ -14,6 +14,7 @@ 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

View file

@ -402,6 +402,21 @@ async fn automations_schema_enforces_aggregate_constraints() -> anyhow::Result<(
.is_err()
);
insert_minimal_environment(database.pool(), "automation-env", "docker", "allow_all").await?;
sqlx::query("UPDATE automations SET environment_id = 'automation-env' WHERE id = 'valid'")
.execute(database.pool())
.await?;
assert!(
sqlx::query("DELETE FROM environments WHERE id = 'automation-env'")
.execute(database.pool())
.await
.is_err(),
"an environment referenced by an automation must be protected by a foreign key"
);
sqlx::query("UPDATE automations SET environment_id = NULL WHERE id = 'valid'")
.execute(database.pool())
.await?;
sqlx::query("DELETE FROM automations WHERE id = ?")
.bind("valid")
.execute(database.pool())
@ -577,6 +592,15 @@ async fn unsupported_automation_targets_abort_before_schema_changes() -> anyhow:
}
async fn rewind_automation_target_migration(database: &fabro_db::Database) -> anyhow::Result<()> {
sqlx::query("DROP INDEX automations_environment_id_idx")
.execute(database.pool())
.await?;
sqlx::query("ALTER TABLE automations DROP COLUMN last_error")
.execute(database.pool())
.await?;
sqlx::query("ALTER TABLE automations DROP COLUMN environment_id")
.execute(database.pool())
.await?;
sqlx::query("ALTER TABLE automations DROP COLUMN target_sha")
.execute(database.pool())
.await?;
@ -586,7 +610,7 @@ async fn rewind_automation_target_migration(database: &fabro_db::Database) -> an
sqlx::query("ALTER TABLE automations RENAME COLUMN target_branch TO target_ref")
.execute(database.pool())
.await?;
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = 2026082601")
sqlx::query("DELETE FROM _sqlx_migrations WHERE version IN (2026082601, 2026082801)")
.execute(database.pool())
.await?;
Ok(())

View file

@ -31,6 +31,14 @@ export interface Automation {
'revision': string;
'name': string;
'description': string | null;
/**
* Server-managed Docker or Daytona environment selected when the automation fires. Null only for an incomplete definition migrated from a release that predated environment selection.
*/
'environment_id': string | null;
/**
* Most recent scheduled-run failure, cleared after a scheduled run is queued successfully.
*/
'last_error': string | null;
'target': RunTarget;
/**
* Workflow slug or path resolved in the selected repository checkout.

View file

@ -27,6 +27,10 @@ export interface CreateAutomationRequest {
'id': string;
'name': string;
'description'?: string | null;
/**
* Server-managed Docker or Daytona environment selected when the automation fires.
*/
'environment_id': string;
'target': RunTarget;
/**
* Workflow slug or path resolved in the selected repository checkout.

View file

@ -26,6 +26,10 @@ import type { RunTarget } from './run-target';
export interface ReplaceAutomationRequest {
'name': string;
'description'?: string | null;
/**
* Server-managed Docker or Daytona environment selected when the automation fires.
*/
'environment_id': string;
'target': RunTarget;
/**
* Workflow slug or path resolved in the selected repository checkout.