Merge remote-tracking branch 'origin/main' into feat/settings-workflows-nav-section

This commit is contained in:
Bryan Helmkamp 2026-05-30 11:15:43 -04:00
commit 4abce34ba4
No known key found for this signature in database
47 changed files with 3686 additions and 196 deletions

2
Cargo.lock generated
View file

@ -1726,6 +1726,7 @@ dependencies = [
"chrono",
"fabro-automation",
"fabro-config",
"fabro-environment",
"fabro-model",
"fabro-types",
"openapiv3",
@ -2366,6 +2367,7 @@ dependencies = [
"chrono",
"clap",
"cookie",
"croner",
"dirs",
"fabro-agent",
"fabro-api",

View file

@ -1,15 +1,21 @@
import { useRef, type ReactNode } from "react";
import { Switch } from "@headlessui/react";
import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client";
import type {
Automation,
AutomationTrigger,
Run,
WorkflowSettings,
} from "@qltysh/fabro-api-client";
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
import { Panel, Row } from "./settings-panel";
import { INPUT_CLASS } from "./ui";
import { sandboxRuntime } from "../lib/run-sandbox-lifecycle";
export interface AutomationFormValues {
id: string;
name: string;
description: string;
enabled: boolean;
repository: string;
ref: string;
workflow: string;
@ -22,7 +28,6 @@ export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
id: "",
name: "",
description: "",
enabled: true,
repository: "",
ref: "main",
workflow: "",
@ -39,13 +44,12 @@ const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
];
export function automationToFormValues(automation: Automation): AutomationFormValues {
const apiTrigger = automation.triggers.find((t) => t.type === "api");
const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule");
const apiTrigger = findApiTrigger(automation);
const scheduleTrigger = findScheduleTrigger(automation);
return {
id: automation.id,
name: automation.name,
description: automation.description ?? "",
enabled: automation.enabled,
repository: automation.target.repository,
ref: automation.target.ref,
workflow: automation.target.workflow,
@ -55,6 +59,37 @@ export function automationToFormValues(automation: Automation): AutomationFormVa
};
}
export function automationFormValuesFromRun(
run: Run,
settings?: WorkflowSettings | null,
): AutomationFormValues {
const name = firstPresentString(
run.title,
run.workflow.name,
run.workflow.graph_name,
run.workflow.slug,
"New automation",
);
const workflowName = firstPresentString(
run.workflow.name,
run.workflow.graph_name,
name,
);
const repository = githubRepositoryFromSettings(settings)
?? githubRepositoryName(run.repository?.name)
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
?? "";
const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch;
return {
...EMPTY_AUTOMATION_FORM,
id: kebabify(name),
name,
repository,
ref: cloneBranch ?? EMPTY_AUTOMATION_FORM.ref,
workflow: run.workflow.slug?.trim() || snakeify(workflowName),
};
}
export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] {
const triggers: AutomationTrigger[] = [];
if (values.manualEnabled) {
@ -77,8 +112,7 @@ export function isFormValid(values: AutomationFormValues): boolean {
values.name.trim() !== "" &&
values.repository.trim() !== "" &&
values.ref.trim() !== "" &&
values.workflow.trim() !== "" &&
(values.manualEnabled || values.scheduleEnabled)
values.workflow.trim() !== ""
);
}
@ -98,6 +132,54 @@ export function snakeify(value: string): string {
.replace(/^_|_$/g, "");
}
function firstPresentString(...values: Array<string | null | undefined>): string {
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) return trimmed;
}
return "";
}
function githubRepositoryFromSettings(
settings?: WorkflowSettings | null,
): string | null {
const owner = settings?.run?.scm?.owner;
const repository = settings?.run?.scm?.repository;
if (!owner || !repository) return null;
return githubRepositoryName(`${owner}/${repository}`);
}
function githubRepositoryName(value: string | null | undefined): string | null {
const trimmed = value?.trim().replace(/\.git$/i, "");
if (!trimmed) return null;
const match = trimmed.match(/^([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+)$/);
if (!match) return null;
return `${match[1]}/${match[2]}`;
}
function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
const scpLikeMatch = trimmed.match(
/^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i,
);
if (scpLikeMatch) {
return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`);
}
try {
const url = new URL(trimmed);
if (url.hostname.toLowerCase() !== "github.com") return null;
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length !== 2) return null;
return githubRepositoryName(`${parts[0]}/${parts[1]}`);
} catch {
return null;
}
}
function describeCron(expression: string): string {
const trimmed = expression.trim();
const preset = CRON_PRESETS.find((p) => p.value === trimmed);
@ -187,13 +269,6 @@ export function AutomationFormFields({
className={`${INPUT_CLASS} resize-y`}
/>
</Row>
<Row title="Enabled" help="Disabled automations skip scheduled triggers and reject API runs.">
<ToggleSwitch
checked={values.enabled}
onChange={(enabled) => patch({ enabled })}
label="Enable automation"
/>
</Row>
</Panel>
<Panel title="Source">

View file

@ -0,0 +1,20 @@
import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client";
type TriggerOfType<K extends AutomationTrigger["type"]> = Extract<
AutomationTrigger,
{ type: K }
>;
export function findApiTrigger(automation: Automation): TriggerOfType<"api"> | undefined {
return automation.triggers.find((t): t is TriggerOfType<"api"> => t.type === "api");
}
export function findScheduleTrigger(
automation: Automation,
): TriggerOfType<"schedule"> | undefined {
return automation.triggers.find((t): t is TriggerOfType<"schedule"> => t.type === "schedule");
}
export function hasEnabledApiTrigger(automation: Automation): boolean {
return findApiTrigger(automation)?.enabled === true;
}

View file

@ -18,6 +18,7 @@ import type {
import { toRunWithStatus } from "../data/runs";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { findApiTrigger, findScheduleTrigger } from "../lib/automation";
import { useAutomation, useAutomationRuns } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { useDataUpdatedAt } from "../hooks/use-data-updated-at";
@ -90,9 +91,9 @@ function AutomationHeader({ automation }: { automation: Automation }) {
const toast = useToast();
const [running, setRunning] = useState(false);
const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule");
const apiTrigger = automation.triggers.find((t) => t.type === "api");
const canRun = apiTrigger?.enabled === true && automation.enabled;
const scheduleTrigger = findScheduleTrigger(automation);
const apiTrigger = findApiTrigger(automation);
const canRun = apiTrigger?.enabled === true;
async function onRun() {
if (!canRun || running) return;
@ -137,7 +138,6 @@ function AutomationHeader({ automation }: { automation: Automation }) {
<span className="font-mono text-xs text-fg-muted">{automation.id}</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<StatusChip enabled={automation.enabled} />
<Chip icon={FolderIcon}>
{automation.target.repository}
<span className="text-fg-muted/70"> · {automation.target.ref}</span>
@ -165,13 +165,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
type="button"
onClick={onRun}
disabled={!canRun || running}
title={
!automation.enabled
? "Enable the automation to run it"
: !apiTrigger?.enabled
? "Enable the API trigger to run it"
: undefined
}
title={canRun ? undefined : "Enable the API trigger to run it"}
className={PRIMARY_BUTTON_CLASS}
>
<PlayIcon className="size-4" aria-hidden="true" />
@ -183,19 +177,6 @@ function AutomationHeader({ automation }: { automation: Automation }) {
);
}
function StatusChip({ enabled }: { enabled: boolean }) {
return (
<span className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${enabled ? "bg-teal-500" : "bg-fg-muted"}`}
/>
<span className={`font-medium ${enabled ? "text-teal-500" : "text-fg-muted"}`}>
{enabled ? "Enabled" : "Disabled"}
</span>
</span>
);
}
function Chip({
icon: Icon,
children,

View file

@ -85,7 +85,6 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
automationsApi.replaceAutomation(automation.id, automation.revision, {
name: trimmedName,
description: values.description.trim() || null,
enabled: values.enabled,
target: {
repository: values.repository.trim(),
ref: values.ref.trim(),

View file

@ -0,0 +1,300 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { createMemoryRouter, RouterProvider } from "react-router";
import { ToastProvider } from "../components/toast";
import { setupReactTestEnv } from "../lib/test-utils";
let currentRun: any = null;
let currentRunError: unknown = null;
let currentRunLoading = false;
let currentRunSettings: any = null;
const queryCalls: Array<{ hook: string; id: string | undefined }> = [];
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
let teardownReactEnv: (() => void) | undefined;
const createAutomationMock = mock((_payload: unknown) =>
Promise.resolve({ data: {} }),
);
const swrMutateMock = mock((_key: unknown) => Promise.resolve(undefined));
mock.module("@headlessui/react", () => ({
Dialog: ({ open, children }: any) =>
open ? createElement("div", { role: "dialog" }, children) : null,
DialogPanel: ({ children, ...props }: any) =>
createElement("div", props, children),
DialogTitle: ({ children, ...props }: any) =>
createElement("h2", props, children),
Switch: ({ checked, onChange, children, ...props }: any) =>
createElement(
"button",
{
...props,
type: "button",
role: "switch",
"aria-checked": checked,
onClick: () => onChange(!checked),
},
children,
),
}));
mock.module("../lib/queries", () => ({
useRun: (id: string | undefined) => {
queryCalls.push({ hook: "useRun", id });
return {
data: currentRun,
error: currentRunError,
isLoading: currentRunLoading,
};
},
useRunSettings: (id: string | undefined) => {
queryCalls.push({ hook: "useRunSettings", id });
return {
data: currentRunSettings,
error: null,
isLoading: false,
};
},
}));
mock.module("../lib/api-client", () => ({
ApiError: class ApiError extends Error {
readonly status: number;
readonly requestId: string | null;
readonly body: unknown;
constructor({
status,
message,
requestId,
body,
}: {
status: number;
message: string;
requestId: string | null;
body: unknown;
}) {
super(message);
this.name = "ApiError";
this.status = status;
this.requestId = requestId;
this.body = body;
}
},
apiData: async function apiData<T>(
call: () => Promise<{ data: T }>,
): Promise<T> {
const response = await call();
return response.data;
},
automationsApi: {
createAutomation: createAutomationMock,
},
}));
mock.module("swr", () => ({
useSWRConfig: () => ({ mutate: swrMutateMock }),
}));
const { default: AutomationsNew } = await import("./automations-new");
mock.restore();
function makeRun(overrides: Record<string, unknown> = {}) {
return {
id: "run_1",
children_count: 0,
goal: "Fix CI",
title: "Fix failing tests",
workflow: {
slug: "fix_ci",
name: "Fix CI",
graph_name: "ci_graph",
node_count: 0,
edge_count: 0,
},
automation: null,
repository: {
name: "fallback/repo",
origin_url: "https://github.com/fallback/repo.git",
provider: "github",
},
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: { kind: "succeeded", reason: "completed" },
approval: null,
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
sandbox: {
kind: "ready",
plan: { provider: "docker", image: null, snapshot: null },
instance: {
provider: "docker",
image: null,
snapshot: null,
runtime: {
id: "container_1",
working_directory: "/workspace",
repo_cloned: true,
clone_origin_url: "https://github.com/qltysh/fabro.git",
clone_branch: "feature/from-run",
},
},
},
models: [],
source_directory: null,
timestamps: {
created_at: "2026-04-20T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
},
timing: null,
billing: null,
size: "XS",
ask_fabro: {
available: false,
unavailable_reason: "no_sandbox",
default_model: null,
},
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
retried_from: null,
links: { web: null },
...overrides,
};
}
function makeRunSettings() {
return {
run: {
scm: {
provider: "github",
owner: "qltysh",
repository: "fabro",
github: null,
},
},
};
}
async function renderAutomationsNew(initialEntry: string) {
const router = createMemoryRouter(
[
{
path: "/automations",
element: <div data-route="automations">Automations</div>,
},
{
path: "/automations/new",
element: <AutomationsNew />,
},
],
{ initialEntries: [initialEntry] },
);
let renderer!: TestRenderer.ReactTestRenderer;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>,
);
});
mountedRenderers.push(renderer);
return { renderer, router };
}
function fieldValue(renderer: TestRenderer.ReactTestRenderer, label: string) {
return renderer.root.findByProps({ "aria-label": label }).props.value;
}
function switchChecked(renderer: TestRenderer.ReactTestRenderer, label: string) {
const props = renderer.root.findByProps({ "aria-label": label }).props;
return props["aria-checked"] ?? props.checked;
}
function textFromNode(
node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>,
): string {
if (!node) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(textFromNode).join(" ");
return (node.children ?? []).map(textFromNode).join(" ");
}
beforeEach(() => {
teardownReactEnv = setupReactTestEnv();
currentRun = null;
currentRunError = null;
currentRunLoading = false;
currentRunSettings = null;
queryCalls.length = 0;
createAutomationMock.mockClear();
swrMutateMock.mockClear();
});
afterEach(() => {
for (const renderer of mountedRenderers.splice(0)) {
act(() => renderer.unmount());
}
teardownReactEnv?.();
teardownReactEnv = undefined;
});
describe("AutomationsNew", () => {
test("/automations/new renders empty form values", async () => {
const { renderer } = await renderAutomationsNew("/automations/new");
expect(fieldValue(renderer, "Automation name")).toBe("");
expect(fieldValue(renderer, "Automation slug")).toBe("");
expect(fieldValue(renderer, "Repository")).toBe("");
expect(fieldValue(renderer, "Default branch")).toBe("main");
expect(fieldValue(renderer, "Workflow slug")).toBe("");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
});
test("/automations/new?from_run=run_1 pre-populates from run and settings data", async () => {
currentRun = makeRun();
currentRunSettings = makeRunSettings();
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
expect(fieldValue(renderer, "Automation name")).toBe("Fix failing tests");
expect(fieldValue(renderer, "Automation slug")).toBe("fix-failing-tests");
expect(fieldValue(renderer, "Repository")).toBe("qltysh/fabro");
expect(fieldValue(renderer, "Default branch")).toBe("feature/from-run");
expect(fieldValue(renderer, "Workflow slug")).toBe("fix_ci");
expect(switchChecked(renderer, "Enable manual and API triggers")).toBe(true);
expect(switchChecked(renderer, "Enable scheduled triggers")).toBe(false);
expect(
renderer.root.findAllByProps({ "aria-label": "Cron expression" }),
).toHaveLength(0);
expect(queryCalls).toContainEqual({ hook: "useRun", id: "run_1" });
expect(queryCalls).toContainEqual({ hook: "useRunSettings", id: "run_1" });
});
test("missing source run data renders an editable empty form with a non-blocking error", async () => {
currentRun = null;
currentRunError = new Error("not found");
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
expect(textFromNode(renderer.toJSON())).toContain("could not be loaded");
expect(textFromNode(renderer.toJSON())).toContain("fill it out manually");
expect(fieldValue(renderer, "Automation name")).toBe("");
expect(fieldValue(renderer, "Repository")).toBe("");
expect(fieldValue(renderer, "Default branch")).toBe("main");
expect(fieldValue(renderer, "Workflow slug")).toBe("");
});
});

View file

@ -1,13 +1,15 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import { useRun, useRunSettings } from "../lib/queries";
import {
AutomationFormFields,
EMPTY_AUTOMATION_FORM,
automationFormValuesFromRun,
isFormValid,
triggersFromFormValues,
type AutomationFormValues,
@ -26,10 +28,69 @@ export function meta() {
export const handle = { hideHeader: true };
export default function AutomationsNew() {
const [searchParams] = useSearchParams();
const fromRunId = searchParams.get("from_run")?.trim() || undefined;
const runQuery = useRun(fromRunId);
const settingsQuery = useRunSettings(fromRunId);
if (!fromRunId) {
return (
<AutomationCreateForm
key="blank"
initialValues={EMPTY_AUTOMATION_FORM}
/>
);
}
// Wait for both queries to settle before mounting the form, so the user's
// edits aren't blown away when settings arrive after the run.
const runPending = runQuery.isLoading && !runQuery.data;
const settingsPending = settingsQuery.isLoading && !settingsQuery.data;
if (runPending || settingsPending) {
return (
<div className="space-y-6">
<PageHeader />
<p className="rounded-lg bg-panel-alt px-4 py-3 text-sm text-fg-3">
Loading source run…
</p>
</div>
);
}
if (!runQuery.data) {
return (
<AutomationCreateForm
key={`missing:${fromRunId}`}
initialValues={EMPTY_AUTOMATION_FORM}
sourceError="The source run could not be loaded. You can still fill it out manually."
/>
);
}
const initialValues = automationFormValuesFromRun(
runQuery.data,
settingsQuery.data ?? null,
);
return (
<AutomationCreateForm
key={`from-run:${fromRunId}`}
initialValues={initialValues}
/>
);
}
function AutomationCreateForm({
initialValues,
sourceError = null,
}: {
initialValues: AutomationFormValues;
sourceError?: string | null;
}) {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
const [values, setValues] = useState<AutomationFormValues>(EMPTY_AUTOMATION_FORM);
const [values, setValues] = useState<AutomationFormValues>(initialValues);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -47,7 +108,6 @@ export default function AutomationsNew() {
id: values.id.trim(),
name: trimmedName,
description: values.description.trim() || null,
enabled: values.enabled,
target: {
repository: values.repository.trim(),
ref: values.ref.trim(),
@ -75,6 +135,7 @@ export default function AutomationsNew() {
<AutomationFormFields values={values} onChange={setValues} />
{sourceError ? <ErrorMessage message={sourceError} /> : null}
{error ? <ErrorMessage message={error} /> : null}
<FormFooter

View file

@ -18,6 +18,7 @@ import { FilterButton } from "../components/runs-list/filter-button";
import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-client";
import { Link, useNavigate } from "react-router";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { findScheduleTrigger, hasEnabledApiTrigger } from "../lib/automation";
import { useAutomations } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { ConfirmDialog } from "../components/ui";
@ -48,6 +49,7 @@ interface AutomationRow {
workflow: string;
repository: string;
schedule?: string;
apiEnabled: boolean;
icon: ComponentType<{ className?: string }>;
color: string;
}
@ -76,11 +78,6 @@ const MENU_ITEM_CLASS =
const MENU_ITEM_DANGER_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
function scheduleFor(automation: Automation): string | undefined {
const schedule = automation.triggers.find((t) => t.type === "schedule");
return schedule?.expression;
}
function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
const automations = result?.data ?? [];
return automations.map((a) => ({
@ -89,7 +86,8 @@ function mapAutomations(result: AutomationListResponse | undefined): AutomationR
name: a.name,
workflow: a.target.workflow,
repository: a.target.repository,
schedule: scheduleFor(a),
schedule: findScheduleTrigger(a)?.expression,
apiEnabled: hasEnabledApiTrigger(a),
icon: slugIconMap[a.target.workflow] ?? CodeBracketIcon,
color: slugColorMap[a.target.workflow] ?? "var(--color-teal-500)",
}));
@ -105,18 +103,19 @@ function PlayIcon({ className }: { className?: string }) {
function AutomationCard({
automation,
disabled,
busy,
running,
onRun,
onDelete,
}: {
automation: AutomationRow;
disabled: boolean;
busy: boolean;
running: boolean;
onRun: () => void;
onDelete: () => void;
}) {
const Icon = automation.icon;
const runDisabled = busy || !automation.apiEnabled;
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">
@ -154,9 +153,15 @@ function AutomationCard({
<button
type="button"
onClick={onRun}
disabled={running || disabled}
disabled={running || runDisabled}
aria-label={running ? "Starting run…" : "Run automation"}
title={running ? "Starting run…" : "Run automation"}
title={
running
? "Starting run..."
: automation.apiEnabled
? "Run 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"
>
{running ? (
@ -167,7 +172,7 @@ function AutomationCard({
</button>
)}
<RowMenu automation={automation} disabled={disabled} onDelete={onDelete} />
<RowMenu automation={automation} disabled={busy} onDelete={onDelete} />
</div>
);
}
@ -260,14 +265,15 @@ export default function Automations() {
}
}
const lowerQuery = query.toLowerCase();
const filtered = automations.filter(
(a) =>
(triggerFilter === "all" ||
(triggerFilter === "scheduled" && a.schedule != null) ||
(triggerFilter === "manual" && a.schedule == null)) &&
(a.name.toLowerCase().includes(query.toLowerCase()) ||
a.workflow.toLowerCase().includes(query.toLowerCase()) ||
a.repository.toLowerCase().includes(query.toLowerCase())),
(a.name.toLowerCase().includes(lowerQuery) ||
a.workflow.toLowerCase().includes(lowerQuery) ||
a.repository.toLowerCase().includes(lowerQuery)),
);
async function confirmDelete() {
@ -322,7 +328,7 @@ export default function Automations() {
<AutomationCard
key={automation.id}
automation={automation}
disabled={deleting || (runningId !== null && runningId !== automation.id)}
busy={deleting || (runningId !== null && runningId !== automation.id)}
running={runningId === automation.id}
onRun={() => runAutomation(automation)}
onDelete={() => setPendingDelete(automation)}

View file

@ -50,6 +50,10 @@ mock.module("../lib/queries", () => ({
data: currentRunSummary,
isLoading: false,
}),
useRunSettings: () => ({
data: null,
isLoading: false,
}),
useRunQuestions: () => ({
data: currentQuestions,
}),
@ -184,13 +188,21 @@ type RunDetailActionResult = import("./run-detail/lifecycle-toasts").RunDetailAc
const h = createElement;
function makeRunSummary(
function makeRunSummary({
status = "succeeded",
diffSummary: any = null,
pullRequest: any = null,
diffSummary = null as any,
pullRequest = null as any,
title = "Run 1",
askFabro: any = null,
) {
askFabro = null as any,
automation = null as any,
}: {
status?: string;
diffSummary?: any;
pullRequest?: any;
title?: string;
askFabro?: any;
automation?: any;
} = {}) {
const apiStatus =
status === "succeeded"
? { kind: "succeeded", reason: "completed" }
@ -207,7 +219,7 @@ function makeRunSummary(
goal: "Run 1",
title,
workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
automation,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
@ -269,6 +281,7 @@ async function renderRunDetailHarness({
pullRequest = null,
title,
askFabro = null,
automation = null,
}: {
initialEntry: string;
status?: string;
@ -277,8 +290,9 @@ async function renderRunDetailHarness({
pullRequest?: any;
title?: string;
askFabro?: any;
automation?: any;
}) {
currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title, askFabro);
currentRunSummary = makeRunSummary({ status, diffSummary, pullRequest, title, askFabro, automation });
currentQuestions = questions;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@ -303,6 +317,14 @@ async function renderRunDetailHarness({
},
],
},
{
path: "/automations/new",
element: h("div", { "data-route": "automations-new" }, "New automation"),
},
{
path: "/automations/:automationId",
element: h("div", { "data-route": "automation-detail" }, "Automation detail"),
},
],
{ initialEntries: [initialEntry] },
);
@ -364,13 +386,20 @@ function textFromTestNode(node: TestRenderer.ReactTestInstance): string {
}).join("");
}
function findButtonByText(
function findButtonsByText(
renderer: TestRenderer.ReactTestRenderer,
text: string,
) {
return renderer.root.findAll(
(node) => node.type === "button" && textFromTestNode(node).includes(text),
)[0];
);
}
function findButtonByText(
renderer: TestRenderer.ReactTestRenderer,
text: string,
) {
return findButtonsByText(renderer, text)[0];
}
function deferred<T>() {
@ -474,7 +503,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: makeRunSummary("failed"),
run: makeRunSummary({ status: "failed" }),
};
result.run.lifecycle.status = { kind: "failed", reason: "cancelled" };
@ -495,7 +524,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: makeRunSummary("running"),
run: makeRunSummary({ status: "running" }),
};
handleLifecycleToastResult("cancel", result, initialState, api);
@ -508,7 +537,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "archive",
ok: true,
run: makeRunSummary("archived"),
run: makeRunSummary({ status: "archived" }),
};
const firstState = handleLifecycleToastResult("archive", result, initialState, api);
@ -528,7 +557,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "unarchive",
ok: true,
run: makeRunSummary("succeeded"),
run: makeRunSummary({ status: "succeeded" }),
};
const stateWithActiveToast: LifecycleToastState = {
activeArchiveToastId: "toast-9",
@ -614,7 +643,7 @@ describe("RunDetail full-height child routes", () => {
intent: "retry",
ok: true,
run: {
...makeRunSummary("runnable"),
...makeRunSummary({ status: "runnable" }),
id: "run_retry",
retried_from: "run_1",
},
@ -782,6 +811,38 @@ describe("RunDetail full-height child routes", () => {
expect(tabCountBadges(renderer)).toHaveLength(0);
});
test("ordinary runs can navigate to create an automation from the run", async () => {
const { renderer, router } = await renderRunDetailHarness({
initialEntry: "/runs/run_1",
});
expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(1);
expect(findButtonsByText(renderer, "View automation")).toHaveLength(0);
await act(async () => {
findButtonByText(renderer, "Create automation from run")!.props.onClick();
});
expect(router.state.location.pathname).toBe("/automations/new");
expect(router.state.location.search).toBe("?from_run=run_1");
});
test("automation-created runs navigate to the existing automation instead of duplicate creation", async () => {
const { renderer, router } = await renderRunDetailHarness({
initialEntry: "/runs/run_1",
automation: { id: "fix build", name: "Fix Build", trigger_id: "manual" },
});
expect(findButtonsByText(renderer, "View automation")).toHaveLength(1);
expect(findButtonsByText(renderer, "Create automation from run")).toHaveLength(0);
await act(async () => {
findButtonByText(renderer, "View automation")!.props.onClick();
});
expect(router.state.location.pathname).toBe("/automations/fix%20build");
});
test("confirms deleting an archived run and navigates back to runs", async () => {
const deletion = deferred<unknown>();
deleteRunApiResult = deletion.promise;

View file

@ -200,6 +200,18 @@ export default function RunDetail({ params }: { params: { id: string } }) {
}
};
const hasPendingQuestions = isBlocked && pendingQuestions.length > 0;
const automationId = summary.automation?.id ?? null;
const automationAction = automationId
? {
key: "view-automation",
label: "View automation",
onSelect: () => navigate(`/automations/${encodeURIComponent(automationId)}`),
}
: {
key: "create-automation",
label: "Create automation from run",
onSelect: () => navigate(`/automations/new?from_run=${encodeURIComponent(params.id)}`),
};
const actionGroups: ActionGroups = {
operations: [
...(hasSandbox
@ -211,6 +223,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
onSelect: () => void handlePreview(),
}]
: []),
automationAction,
{
key: "interrupt",
label: "Send interrupt",

View file

@ -17,6 +17,8 @@ tags:
description: Run management operations
- name: Automations
description: Server-managed automation definitions and automation-triggered runs
- name: Environments
description: Server-managed execution environment catalog
- name: Sandboxes
description: Provider-backed sandbox inventory
- name: Sessions
@ -4206,7 +4208,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Automation is disabled or has no enabled API trigger
description: Automation has no enabled API trigger
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
@ -4224,6 +4226,272 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Environments ─────────────────────────────────────────────────────
/api/v1/environments:
get:
operationId: listEnvironments
tags: [Environments]
summary: List environments
description: Returns all server-managed environment definitions, sorted by id.
responses:
"200":
description: Environment definitions
content:
application/json:
schema:
$ref: "#/components/schemas/EnvironmentListResponse"
"500":
description: Environment store operation failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
post:
operationId: createEnvironment
tags: [Environments]
summary: Create environment
description: |
Creates a server-owned environment definition in the environment catalog.
REST environment requests only accept inline Dockerfile content; local
Dockerfile paths are supported by workflow/settings files but rejected
by this API.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateEnvironmentRequest"
responses:
"201":
description: Environment created
content:
application/json:
schema:
$ref: "#/components/schemas/Environment"
"400":
description: Malformed JSON request body
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Environment id already exists
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Environment failed domain validation
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Environment store operation failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/environments/{id}:
get:
operationId: retrieveEnvironment
tags: [Environments]
summary: Retrieve environment
description: Returns one server-managed environment definition by id.
parameters:
- $ref: "#/components/parameters/EnvironmentId"
responses:
"200":
description: Environment definition
headers:
ETag:
$ref: "#/components/headers/ETag"
content:
application/json:
schema:
$ref: "#/components/schemas/Environment"
"400":
description: Invalid environment id
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Environment not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Environment store operation failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
put:
operationId: replaceEnvironment
tags: [Environments]
summary: Replace environment
description: |
Replaces an environment definition when `If-Match` matches the current
environment revision. The path id is authoritative; the request body
omits `id`.
parameters:
- $ref: "#/components/parameters/EnvironmentId"
- $ref: "#/components/parameters/IfMatch"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ReplaceEnvironmentRequest"
responses:
"200":
description: Environment replaced
headers:
ETag:
$ref: "#/components/headers/ETag"
content:
application/json:
schema:
$ref: "#/components/schemas/Environment"
"400":
description: Malformed JSON request body, invalid environment id, or invalid revision header
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Environment not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Environment revision mismatch or protected environment conflict
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Environment failed domain validation
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"428":
description: Missing required `If-Match` header
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Environment store operation failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: deleteEnvironment
tags: [Environments]
summary: Delete environment
description: Deletes a non-default environment definition when `If-Match` matches the current environment revision.
parameters:
- $ref: "#/components/parameters/EnvironmentId"
- $ref: "#/components/parameters/IfMatch"
responses:
"204":
description: Environment deleted
"400":
description: Invalid environment id or revision header
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Environment not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Environment revision mismatch or protected environment conflict
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"428":
description: Missing required `If-Match` header
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Environment store operation failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Workflows ────────────────────────────────────────────────────────
/api/v1/workflows:
@ -5104,6 +5372,16 @@ components:
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
example: nightly-deps
EnvironmentId:
name: id
in: path
required: true
description: Unique environment identifier.
schema:
type: string
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
example: docker
IfMatch:
name: If-Match
in: header
@ -5841,7 +6119,6 @@ components:
- revision
- name
- description
- enabled
- target
- triggers
properties:
@ -5860,9 +6137,6 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
enabled:
type: boolean
example: true
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
@ -5970,9 +6244,6 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
enabled:
type: boolean
default: true
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
@ -5986,7 +6257,6 @@ components:
additionalProperties: false
required:
- name
- enabled
- target
- triggers
properties:
@ -5996,8 +6266,6 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
enabled:
type: boolean
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
@ -6033,6 +6301,181 @@ components:
minimum: 0
description: Total number of configured automation definitions.
# ── Environments ─────────────────────────────────────────────────────
Environment:
description: Public server-managed environment definition.
type: object
additionalProperties: false
required:
- id
- revision
- provider
- image
- resources
- network
- lifecycle
- labels
- volumes
- env
properties:
id:
type: string
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
example: docker
revision:
type: string
pattern: "^[0-9a-f]{64}$"
description: Stable revision used with `If-Match` for optimistic concurrency.
example: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
provider:
$ref: "#/components/schemas/EnvironmentProvider"
image:
$ref: "#/components/schemas/EnvironmentApiImageSettings"
resources:
$ref: "#/components/schemas/EnvironmentResourcesSettings"
network:
$ref: "#/components/schemas/EnvironmentNetworkSettings"
lifecycle:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
$ref: "#/components/schemas/InterpString"
CreateEnvironmentRequest:
description: Request body for creating a server-managed environment.
type: object
additionalProperties: false
required:
- id
- provider
- image
- resources
- network
- lifecycle
- labels
- volumes
- env
properties:
id:
type: string
pattern: "^[a-z0-9][a-z0-9-]{0,62}$"
example: docker
provider:
$ref: "#/components/schemas/EnvironmentProvider"
image:
$ref: "#/components/schemas/EnvironmentApiImageSettings"
resources:
$ref: "#/components/schemas/EnvironmentResourcesSettings"
network:
$ref: "#/components/schemas/EnvironmentNetworkSettings"
lifecycle:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
$ref: "#/components/schemas/InterpString"
ReplaceEnvironmentRequest:
description: Request body for replacing a server-managed environment. The path id is authoritative.
type: object
additionalProperties: false
required:
- provider
- image
- resources
- network
- lifecycle
- labels
- volumes
- env
properties:
provider:
$ref: "#/components/schemas/EnvironmentProvider"
image:
$ref: "#/components/schemas/EnvironmentApiImageSettings"
resources:
$ref: "#/components/schemas/EnvironmentResourcesSettings"
network:
$ref: "#/components/schemas/EnvironmentNetworkSettings"
lifecycle:
$ref: "#/components/schemas/EnvironmentLifecycleSettings"
labels:
$ref: "#/components/schemas/StringMap"
volumes:
type: array
items:
$ref: "#/components/schemas/EnvironmentVolumeSettings"
env:
type: object
additionalProperties:
$ref: "#/components/schemas/InterpString"
EnvironmentApiImageSettings:
description: REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.
type: object
additionalProperties: false
required: [docker, dockerfile]
properties:
docker:
type: ["string", "null"]
dockerfile:
oneOf:
- $ref: "#/components/schemas/EnvironmentApiDockerfileSourceInline"
- type: "null"
EnvironmentApiDockerfileSourceInline:
type: object
additionalProperties: false
required: [type, value]
properties:
type:
type: string
enum: [inline]
value:
type: string
EnvironmentListResponse:
description: List envelope for environment definitions.
type: object
additionalProperties: false
required:
- data
- meta
properties:
data:
type: array
items:
$ref: "#/components/schemas/Environment"
meta:
$ref: "#/components/schemas/EnvironmentListMeta"
EnvironmentListMeta:
description: Metadata for environment list responses.
type: object
additionalProperties: false
required:
- total
properties:
total:
type: integer
format: int64
minimum: 0
description: Total number of server-managed environment definitions.
# ── Pagination ───────────────────────────────────────────────────────
PaginationMeta:

View file

@ -17,6 +17,7 @@ wildcard_imports = "warn"
chrono = { workspace = true, features = ["serde"] }
fabro-automation = { path = "../fabro-automation" }
fabro-config = { path = "../fabro-config" }
fabro-environment.workspace = true
fabro-model = { path = "../fabro-model" }
fabro-types = { path = "../fabro-types" }
progenitor-client = "0.13"

View file

@ -635,6 +635,7 @@ fn main() {
"fabro_automation::AutomationReplace",
&[],
),
("Environment", "fabro_environment::Environment", &[]),
("SessionId", "fabro_types::SessionId", &[]),
("TurnId", "fabro_types::TurnId", &[]),
("SessionStatus", "fabro_types::SessionStatus", &[]),

View file

@ -18,6 +18,7 @@ pub mod types {
Automation, AutomationDraft as CreateAutomationRequest,
AutomationReplace as ReplaceAutomationRequest, AutomationTarget, AutomationTrigger,
};
pub use fabro_environment::Environment;
pub use fabro_model::{
Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,
Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed,

View file

@ -26,7 +26,6 @@ fn automation_response_round_trips_public_json_shape() {
"revision": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"name": "Nightly dependency update",
"description": null,
"enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@ -57,7 +56,6 @@ fn create_automation_request_round_trips_public_json_shape() {
"id": "nightly-deps",
"name": "Nightly dependency update",
"description": "Keep dependencies fresh",
"enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@ -81,7 +79,6 @@ fn replace_automation_request_round_trips_public_json_shape() {
let value = json!({
"name": "Nightly dependency update",
"description": "Keep dependencies fresh",
"enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",

View file

@ -0,0 +1,88 @@
use fabro_api::types::{
CreateEnvironmentRequest as ApiCreateEnvironmentRequest, Environment as ApiEnvironment,
ReplaceEnvironmentRequest as ApiReplaceEnvironmentRequest,
};
use fabro_environment::Environment;
use serde_json::json;
// Compile-time witness that the generated API response type resolves to the
// same type as the `fabro-environment` domain type via `with_replacement(...)`.
// Request types intentionally stay API-specific so REST Dockerfile sources can
// remain inline-only without changing workflow/settings schemas.
const _: fn(ApiEnvironment) -> Environment = |value| value;
fn environment_settings_json() -> serde_json::Value {
json!({
"provider": "docker",
"image": {
"docker": null,
"dockerfile": {
"type": "inline",
"value": "FROM alpine\n"
}
},
"resources": {
"cpu": null,
"memory": null,
"disk": null
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": null
},
"labels": {},
"volumes": [],
"env": {}
})
}
#[test]
fn environment_response_round_trips_public_json_shape() {
let mut value = environment_settings_json();
value["id"] = json!("docker-inline");
value["revision"] = json!("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
let api: ApiEnvironment = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn create_environment_request_round_trips_inline_dockerfile_json_shape() {
let mut value = environment_settings_json();
value["id"] = json!("docker-inline");
let api: ApiCreateEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn replace_environment_request_round_trips_inline_dockerfile_json_shape() {
let value = environment_settings_json();
let api: ApiReplaceEnvironmentRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn environment_request_schema_rejects_dockerfile_path_sources() {
let mut value = environment_settings_json();
value["id"] = json!("docker-path");
value["image"]["dockerfile"] = json!({
"type": "path",
"path": "Dockerfile"
});
let err = serde_json::from_value::<ApiCreateEnvironmentRequest>(value)
.expect_err("generated REST request type should reject Dockerfile path sources");
assert!(
err.to_string().contains("dockerfile")
|| err.to_string().contains("type")
|| err.to_string().contains("path"),
"unexpected error: {err}"
);
}

View file

@ -7,6 +7,6 @@ pub use error::{AutomationStoreError, AutomationValidationError};
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
pub use model::{
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTarget,
AutomationTrigger, ScheduleTrigger,
AutomationTrigger, ScheduleTrigger, parse_schedule_expression,
};
pub use store::AutomationStore;

View file

@ -1,5 +1,8 @@
use std::collections::HashSet;
use std::sync::LazyLock;
use croner::Cron;
use croner::errors::CronError;
use croner::parser::{CronParser, Seconds, Year};
use serde::{Deserialize, Serialize};
@ -8,6 +11,23 @@ use crate::{
AutomationValidationError,
};
/// Shared cron parser used to validate and evaluate automation schedule trigger
/// expressions. Schedule triggers use the same five-field UTC cron grammar as
/// validation, so both sites must share configuration.
static SCHEDULE_CRON_PARSER: LazyLock<CronParser> = LazyLock::new(|| {
CronParser::builder()
.seconds(Seconds::Disallowed)
.year(Year::Disallowed)
.build()
});
/// Parse an automation schedule trigger expression with the canonical
/// configuration (no seconds, no year). Returned `Cron` instances can be cached
/// and used to find next occurrences.
pub fn parse_schedule_expression(expression: &str) -> Result<Cron, CronError> {
SCHEDULE_CRON_PARSER.parse(expression)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Automation {
@ -15,7 +35,6 @@ pub struct Automation {
pub revision: AutomationRevision,
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec<AutomationTrigger>,
}
@ -54,7 +73,6 @@ impl Automation {
PersistedAutomation {
name: self.name.clone(),
description: self.description.clone(),
enabled: self.enabled,
target: self.target.clone(),
triggers: self.triggers.clone(),
}
@ -64,20 +82,26 @@ impl Automation {
toml::to_string_pretty(&self.to_persisted()).map_err(AutomationStoreError::from)
}
/// Returns the enabled API trigger if the automation itself is enabled and
/// has one. Returns `None` when the automation is disabled or has no
/// enabled API trigger.
/// Returns the enabled API trigger if the automation has one.
/// Returns `None` when the automation has no enabled API trigger.
#[must_use]
pub fn enabled_api_trigger(&self) -> Option<&ApiTrigger> {
if !self.enabled {
return None;
}
self.triggers.iter().find_map(|trigger| match trigger {
AutomationTrigger::Api(trigger) if trigger.enabled => Some(trigger),
_ => None,
})
}
/// Iterate the enabled schedule triggers.
pub fn enabled_schedule_triggers(&self) -> impl Iterator<Item = &ScheduleTrigger> {
self.triggers
.iter()
.filter_map(move |trigger| match trigger {
AutomationTrigger::Schedule(trigger) if trigger.enabled => Some(trigger),
_ => None,
})
}
fn from_persisted(
id: AutomationId,
revision: AutomationRevision,
@ -98,7 +122,6 @@ impl Automation {
revision,
name: replace.name,
description: replace.description,
enabled: replace.enabled,
target: replace.target,
triggers: replace.triggers,
}
@ -161,8 +184,6 @@ pub struct AutomationDraft {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec<AutomationTrigger>,
}
@ -172,7 +193,6 @@ impl From<AutomationDraft> for (AutomationId, AutomationReplace) {
(value.id, AutomationReplace {
name: value.name,
description: value.description,
enabled: value.enabled,
target: value.target,
triggers: value.triggers,
})
@ -185,7 +205,6 @@ pub struct AutomationReplace {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec<AutomationTrigger>,
}
@ -196,8 +215,6 @@ pub(crate) struct PersistedAutomation {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default = "default_true")]
enabled: bool,
target: AutomationTarget,
#[serde(default)]
triggers: Vec<AutomationTrigger>,
@ -208,7 +225,6 @@ impl From<AutomationReplace> for PersistedAutomation {
Self {
name: value.name,
description: value.description,
enabled: value.enabled,
target: value.target,
triggers: value.triggers,
}
@ -220,7 +236,6 @@ impl From<PersistedAutomation> for AutomationReplace {
Self {
name: value.name,
description: value.description,
enabled: value.enabled,
target: value.target,
triggers: value.triggers,
}
@ -352,10 +367,6 @@ fn has_lock_suffix(value: &str) -> bool {
fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationValidationError> {
let mut seen = HashSet::new();
let mut has_api_trigger = false;
let cron_parser = CronParser::builder()
.seconds(Seconds::Disallowed)
.year(Year::Disallowed)
.build();
for trigger in triggers {
let id = trigger.id().as_str();
@ -376,7 +387,7 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
expression: trigger.expression.clone(),
});
}
cron_parser.parse(&trigger.expression).map_err(|source| {
parse_schedule_expression(&trigger.expression).map_err(|source| {
AutomationValidationError::InvalidCronExpression {
trigger_id: id.to_string(),
expression: trigger.expression.clone(),
@ -390,10 +401,6 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
Ok(())
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use crate::{
@ -417,9 +424,13 @@ mod tests {
}
fn schedule_trigger(id: &str, cron: &str) -> AutomationTrigger {
schedule_trigger_with_enabled(id, cron, true)
}
fn schedule_trigger_with_enabled(id: &str, cron: &str, enabled: bool) -> AutomationTrigger {
AutomationTrigger::Schedule(ScheduleTrigger {
id: AutomationTriggerId::new(id).unwrap(),
enabled: true,
id: AutomationTriggerId::new(id).unwrap(),
enabled,
expression: cron.to_string(),
})
}
@ -450,30 +461,72 @@ expression = "0 0 * * *"
Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), bytes).unwrap();
assert_eq!(automation.description, None);
assert!(automation.enabled);
assert!(automation.triggers.iter().all(AutomationTrigger::enabled));
let toml = automation.to_toml_string().unwrap();
assert!(!top_level_lines(&toml).any(|line| line.starts_with("id = ")));
assert!(!top_level_lines(&toml).any(|line| line.starts_with("revision = ")));
assert!(toml.contains("enabled = true"));
assert!(!top_level_lines(&toml).any(|line| line.starts_with("enabled = ")));
assert!(toml.contains("type = \"api\""));
}
#[test]
fn persisted_toml_rejects_legacy_top_level_enabled() {
let bytes = br#"
name = "Legacy"
enabled = false
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "release"
[[triggers]]
type = "api"
id = "manual"
enabled = true
"#;
let result = Automation::from_toml_bytes(AutomationId::new("legacy").unwrap(), bytes);
assert!(result.is_err());
}
#[test]
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(),
triggers: vec![
api_trigger("manual"),
schedule_trigger_with_enabled("nightly", "0 0 * * *", true),
schedule_trigger_with_enabled("disabled", "0 1 * * *", false),
],
})
.unwrap();
let trigger_ids = automation
.enabled_schedule_triggers()
.map(|trigger| trigger.id.as_str())
.collect::<Vec<_>>();
assert_eq!(trigger_ids, vec!["nightly"]);
}
#[test]
fn validation_rejects_invalid_inputs() {
let cases = [
AutomationReplace {
name: " ".to_string(),
description: None,
enabled: true,
target: target(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad repo".to_string(),
description: None,
enabled: true,
target: AutomationTarget {
repository: "not/github/slug".to_string(),
ref_selector: "main".to_string(),
@ -484,7 +537,6 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Bad ref".to_string(),
description: None,
enabled: true,
target: AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main;rm".to_string(),
@ -495,7 +547,6 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Bad workflow".to_string(),
description: None,
enabled: true,
target: AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
@ -506,7 +557,6 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Duplicate trigger".to_string(),
description: None,
enabled: true,
target: target(),
triggers: vec![
api_trigger("manual"),
@ -516,21 +566,18 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Two API triggers".to_string(),
description: None,
enabled: true,
target: target(),
triggers: vec![api_trigger("one"), api_trigger("two")],
},
AutomationReplace {
name: "Six field cron".to_string(),
description: None,
enabled: true,
target: target(),
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
},
AutomationReplace {
name: "Bad cron".to_string(),
description: None,
enabled: true,
target: target(),
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
},

View file

@ -290,7 +290,6 @@ mod tests {
id: AutomationId::new(id).unwrap(),
name: name.to_string(),
description: None,
enabled: true,
target: target(),
triggers: vec![
AutomationTrigger::Api(ApiTrigger {
@ -310,7 +309,6 @@ mod tests {
AutomationReplace {
name: name.to_string(),
description: Some("updated".to_string()),
enabled: false,
target: target(),
triggers: vec![AutomationTrigger::Api(ApiTrigger {
id: AutomationTriggerId::new("manual").unwrap(),

View file

@ -52,6 +52,7 @@ impl Environment {
) -> Result<(Self, Vec<u8>), EnvironmentStoreError> {
let settings = inline_dense_dockerfile(settings, dockerfile_base_dir).await?;
let persisted = environment_settings_to_layer(&settings);
let settings = resolve_environment(&persisted)?;
let bytes = canonical_bytes(&persisted).into_bytes();
let revision = EnvironmentRevision::from_bytes(&bytes);
Ok((

View file

@ -404,8 +404,8 @@ mod tests {
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
EnvironmentSettings,
EnvironmentNetworkMode, EnvironmentNetworkSettings, EnvironmentProvider,
EnvironmentResourcesSettings, EnvironmentSettings,
};
use tokio::fs;
@ -572,6 +572,28 @@ path = "Dockerfile"
assert!(matches!(err, EnvironmentStoreError::AlreadyExists { .. }));
}
#[tokio::test]
async fn create_invalid_settings_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let mut settings = settings(EnvironmentProvider::Local);
settings.network.mode = EnvironmentNetworkMode::Block;
let err = store
.create(EnvironmentDraft {
id: EnvironmentId::new("invalid").unwrap(),
settings,
})
.await
.unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(
err.to_string()
.contains("local environments cannot enforce")
);
}
#[tokio::test]
async fn replace_stale_revision_is_rejected() {
let dir = tempfile::tempdir().unwrap();

View file

@ -52,6 +52,7 @@ fabro-http.workspace = true
fabro-redact.workspace = true
fabro-static.workspace = true
chrono.workspace = true
croner.workspace = true
futures-util.workspace = true
axum = { workspace = true, features = ["ws"] }
axum-extra.workspace = true

View file

@ -36,7 +36,7 @@ use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECR
use crate::server::{
AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
spawn_scheduler,
spawn_automation_scheduler, spawn_scheduler,
};
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
use crate::startup::{prepare_startup_vault, resolve_startup, validate_startup_configuration};
@ -789,6 +789,7 @@ where
);
}
spawn_scheduler(Arc::clone(&state));
spawn_automation_scheduler(Arc::clone(&state));
let router = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions {
web_enabled,
#[cfg(debug_assertions)]

View file

@ -168,10 +168,12 @@ use crate::{
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
};
mod automation_scheduler;
mod handler;
mod resource_sampler;
mod session_runtime;
pub(crate) use automation_scheduler::spawn_automation_scheduler;
pub(crate) use handler::events::EventListParams;
#[cfg(test)]
pub(in crate::server) use handler::events::filtered_global_events;
@ -1073,6 +1075,7 @@ pub struct AppState {
pub(crate) worker_control_bus: Arc<dyn WorkerControlBus>,
pub(crate) worker_runtime: Arc<dyn WorkerRuntime>,
scheduler_notify: Notify,
automation_scheduler_notify: Notify,
global_event_tx: broadcast::Sender<EventEnvelope>,
/// Per-run coalescing registry for `GET /runs/{id}/files`. Concurrent
/// callers for the same run share one materialization; different runs
@ -1136,6 +1139,16 @@ impl AppState {
.materialize(input)
.await
}
pub(crate) fn notify_automation_scheduler(&self) {
self.automation_scheduler_notify.notify_one();
}
pub(crate) fn automation_scheduler_notified(
&self,
) -> impl std::future::Future<Output = ()> + '_ {
self.automation_scheduler_notify.notified()
}
}
pub(crate) struct AskFabroReadiness {
@ -1317,6 +1330,18 @@ impl AppState {
.clone()
}
pub(crate) fn refresh_manifest_run_settings_from_environment_catalog(&self) {
let manifest_run_defaults = self.manifest_run_defaults();
let manifest_run_settings = resolve_manifest_run_settings_with_catalog(
manifest_run_defaults.as_ref(),
&self.environment_store,
);
*self
.manifest_run_settings
.write()
.expect("manifest run settings lock poisoned") = manifest_run_settings;
}
fn http_client(&self) -> Result<fabro_http::HttpClient, fabro_http::HttpClientBuildError> {
match &self.http_client {
Some(client) => Ok(client.clone()),
@ -1331,6 +1356,14 @@ impl AppState {
)
}
/// Scratch directory used by the automation materializer when staging
/// per-run manifests. Shared by API-triggered and scheduled fires.
pub(crate) fn automation_temp_root(&self) -> PathBuf {
Storage::new(self.server_storage_dir())
.scratch_dir()
.join("automations")
}
/// Snapshotted at create-time so attach replays surface the same link
/// even if `server.web.url` is later changed. `None` when the UI is
/// turned off or `server.web.url` is unset/invalid.
@ -1518,6 +1551,7 @@ impl AppState {
fn begin_shutdown(&self) {
self.shutting_down.store(true, Ordering::Relaxed);
self.scheduler_notify.notify_waiters();
self.automation_scheduler_notify.notify_waiters();
}
pub(crate) fn shutdown_token(&self) -> CancellationToken {
@ -2413,6 +2447,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
worker_control_bus,
worker_runtime,
scheduler_notify: Notify::new(),
automation_scheduler_notify: Notify::new(),
global_event_tx,
files_in_flight: new_files_in_flight(),
pull_request_create_locks: Arc::new(Mutex::new(HashMap::new())),

View file

@ -0,0 +1,702 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::http::HeaderMap;
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 tokio::time::sleep;
use tracing::{Instrument, info, info_span, warn};
use super::{AppState, handler};
use crate::automation_materializer::AutomationRunMaterializeInput;
const AUTOMATION_SCHEDULER_MAX_SLEEP: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ScheduleTriggerKey {
automation_id: AutomationId,
trigger_id: AutomationTriggerId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ScheduleCursor {
automation_revision: AutomationRevision,
expression: String,
next_due_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DueScheduleTrigger {
automation: Automation,
trigger_id: AutomationTriggerId,
due_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
struct AutomationSchedulePlanner {
cursors: HashMap<ScheduleTriggerKey, ScheduleCursor>,
}
fn next_occurrence(expression: &str, after: DateTime<Utc>) -> Result<DateTime<Utc>, CronError> {
parse_schedule_expression(expression)?.find_next_occurrence(&after, false)
}
impl AutomationSchedulePlanner {
fn reconcile(&mut self, automations: &[Automation], now: DateTime<Utc>) {
let mut reconciled = HashMap::new();
for automation in automations {
for trigger in automation.enabled_schedule_triggers() {
let key = ScheduleTriggerKey {
automation_id: automation.id.clone(),
trigger_id: trigger.id.clone(),
};
if let Some(cursor) = self.cursors.get(&key).filter(|cursor| {
cursor.automation_revision == automation.revision
&& cursor.expression == trigger.expression
}) {
reconciled.insert(key, cursor.clone());
continue;
}
let next_due_at = match next_occurrence(&trigger.expression, now) {
Ok(next_due_at) => next_due_at,
Err(err) => {
warn!(
automation_id = %automation.id,
trigger_id = %trigger.id,
error = %err,
"Skipping invalid automation schedule trigger",
);
continue;
}
};
reconciled.insert(key, ScheduleCursor {
automation_revision: automation.revision.clone(),
expression: trigger.expression.clone(),
next_due_at,
});
}
}
self.cursors = reconciled;
}
fn take_due(
&mut self,
automations: &[Automation],
now: DateTime<Utc>,
) -> Vec<DueScheduleTrigger> {
let mut due_keys = self
.cursors
.iter()
.filter(|(_, cursor)| cursor.next_due_at <= now)
.map(|(key, cursor)| (key.clone(), cursor.next_due_at))
.collect::<Vec<_>>();
// Deterministic order for spawn scheduling, log output, and tests.
due_keys.sort_by(|a, b| {
a.0.automation_id
.cmp(&b.0.automation_id)
.then_with(|| a.0.trigger_id.cmp(&b.0.trigger_id))
});
if due_keys.is_empty() {
return Vec::new();
}
let automations_by_id = automations
.iter()
.map(|automation| (&automation.id, automation))
.collect::<HashMap<_, _>>();
let mut due = Vec::with_capacity(due_keys.len());
for (key, due_at) in due_keys {
let Some(cursor) = self.cursors.get_mut(&key) else {
continue;
};
match next_occurrence(&cursor.expression, now) {
Ok(next_due_at) => {
cursor.next_due_at = next_due_at;
}
Err(err) => {
warn!(
automation_id = %key.automation_id,
trigger_id = %key.trigger_id,
error = %err,
"Removing automation schedule cursor after next occurrence failed",
);
self.cursors.remove(&key);
continue;
}
}
let Some(automation) = automations_by_id.get(&key.automation_id) else {
continue;
};
due.push(DueScheduleTrigger {
automation: (*automation).clone(),
trigger_id: key.trigger_id,
due_at,
});
}
due
}
/// Reconcile cursors against the current automation set, then drain due
/// triggers. Single entry point used by the production loop and tests.
fn tick(&mut self, automations: &[Automation], now: DateTime<Utc>) -> Vec<DueScheduleTrigger> {
self.reconcile(automations, now);
self.take_due(automations, now)
}
fn sleep_duration(&self, now: DateTime<Utc>) -> Duration {
let until_next_due = self
.cursors
.values()
.map(|cursor| cursor.next_due_at)
.min()
.map_or(AUTOMATION_SCHEDULER_MAX_SLEEP, |next_due_at| {
if next_due_at <= now {
Duration::ZERO
} else {
(next_due_at - now).to_std().unwrap_or(Duration::ZERO)
}
});
until_next_due.min(AUTOMATION_SCHEDULER_MAX_SLEEP)
}
}
pub(crate) fn spawn_automation_scheduler(state: Arc<AppState>) {
tokio::spawn(async move {
let mut planner = AutomationSchedulePlanner::default();
let shutdown = state.shutdown_token();
loop {
if state.is_shutting_down() {
break;
}
let automations = state.automation_store().list().await;
let now = Utc::now();
for due in planner.tick(&automations, now) {
let state = Arc::clone(&state);
let span = info_span!(
"automation_run",
automation_id = %due.automation.id,
trigger_id = %due.trigger_id,
);
tokio::spawn(
fire_scheduled_automation_run(
state,
due.automation,
due.trigger_id,
due.due_at,
)
.instrument(span),
);
}
let sleep_duration = planner.sleep_duration(now);
tokio::select! {
() = shutdown.cancelled() => break,
() = state.automation_scheduler_notified() => {},
() = sleep(sleep_duration) => {},
}
}
});
}
async fn fire_scheduled_automation_run(
state: Arc<AppState>,
automation: Automation,
trigger_id: AutomationTriggerId,
due_at: DateTime<Utc>,
) {
let automation_id = automation.id.clone();
let run_id = RunId::new();
let materialized = match state
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: automation_id.clone(),
target: automation.target.clone(),
run_id,
user_settings_path: state.active_config_path().to_path_buf(),
temp_root: state.automation_temp_root(),
})
.await
{
Ok(materialized) => materialized,
Err(err) => {
warn!(
due_at = %due_at,
error = %err,
"Failed to materialize scheduled automation run",
);
return;
}
};
let explicit_title_supplied = materialized.manifest.title.is_some();
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
let automation_ref = AutomationRef {
id: automation_id.to_string(),
name: Some(automation.name.clone()),
trigger_id: Some(trigger_id.to_string()),
};
// `create_run_from_manifest` produces a large future; box it to keep our
// stack frame small (matches handler/automations.rs).
let response = Box::pin(handler::runs::create_run_from_manifest(
Arc::clone(&state),
handler::runs::CreateRunFromManifestRequest {
manifest: materialized.manifest,
submitted_manifest_bytes: materialized.submitted_manifest_bytes,
explicit_run_id: Some(run_id),
explicit_title_supplied,
actor: actor.clone(),
headers: HeaderMap::new(),
automation: Some(automation_ref),
},
))
.await;
let status = response.status();
if !status.is_success() {
warn!(
run_id = %run_id,
due_at = %due_at,
status = %status,
"Failed to create scheduled automation run",
);
return;
}
if let Err(err) =
handler::lifecycle::queue_run_start(state.as_ref(), run_id, false, actor).await
{
warn!(
run_id = %run_id,
due_at = %due_at,
status = %err.status(),
code = err.code().unwrap_or(""),
"Created scheduled automation run but failed to start it",
);
return;
}
info!(
run_id = %run_id,
due_at = %due_at,
"Scheduled automation run queued",
);
}
/// Drive one tick of the scheduler from a test. Boxed so the calling test
/// future stays small (clippy `large_futures`).
#[cfg(test)]
fn run_due_schedules_once<'a>(
state: Arc<AppState>,
planner: &'a mut AutomationSchedulePlanner,
now: DateTime<Utc>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let automations = state.automation_store().list().await;
for trigger in planner.tick(&automations, now) {
Box::pin(fire_scheduled_automation_run(
Arc::clone(&state),
trigger.automation,
trigger.trigger_id,
trigger.due_at,
))
.await;
}
})
}
#[cfg(test)]
mod tests {
use fabro_api::types::RunManifest;
use fabro_automation::{AutomationDraft, AutomationTarget, AutomationTrigger, ScheduleTrigger};
use fabro_store::ListRunsQuery;
use fabro_types::RunStatus;
use serde_json::json;
use super::*;
use crate::test_support::{TestAppStateBuilder, TestAutomationRunMaterializer};
fn dt(value: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(value)
.expect("test datetime should parse")
.with_timezone(&Utc)
}
fn target() -> AutomationTarget {
AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
workflow: "workflow.fabro".to_string(),
}
}
fn schedule_trigger(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
AutomationTrigger::Schedule(ScheduleTrigger {
id: AutomationTriggerId::new(id).expect("test trigger id should be valid"),
enabled,
expression: expression.to_string(),
})
}
fn automation(id: &str, name: &str, triggers: Vec<AutomationTrigger>) -> Automation {
Automation {
id: AutomationId::new(id).expect("test automation id should be valid"),
revision: AutomationRevision::from_bytes(format!("{id}:{name}").as_bytes()),
name: name.to_string(),
description: None,
target: target(),
triggers,
}
}
async fn create_automation(
state: &AppState,
id: &str,
name: &str,
triggers: Vec<AutomationTrigger>,
) -> Automation {
state
.automation_store()
.create(AutomationDraft {
id: AutomationId::new(id).expect("test automation id should be valid"),
name: name.to_string(),
description: None,
target: target(),
triggers,
})
.await
.expect("test automation should be created")
}
fn minimal_manifest() -> RunManifest {
serde_json::from_value(json!({
"version": 1,
"cwd": "/tmp",
"target": {
"identifier": "workflow.fabro",
"path": "workflow.fabro",
},
"workflows": {
"workflow.fabro": {
"source": r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#,
"files": {},
},
},
}))
.expect("minimal manifest should deserialize")
}
fn succeeding_materializer() -> TestAutomationRunMaterializer {
let manifest = minimal_manifest();
let submitted_manifest_bytes =
serde_json::to_vec(&manifest).expect("manifest should serialize");
TestAutomationRunMaterializer::succeed(manifest, submitted_manifest_bytes)
}
fn test_state_with_materializer(materializer: TestAutomationRunMaterializer) -> Arc<AppState> {
TestAppStateBuilder::new()
.env_lookup(|_| None)
.automation_materializer(materializer)
.build()
}
async fn cached_runs(state: &AppState) -> Vec<fabro_types::Run> {
state
.store
.list_cached_runs(&ListRunsQuery::default(), Utc::now())
.await
.expect("cached runs should list")
.into_iter()
.map(|entry| entry.summary)
.collect()
}
fn prime_time() -> DateTime<Utc> {
dt("2026-05-29T00:00:30Z")
}
fn first_due_time() -> DateTime<Utc> {
dt("2026-05-29T00:01:00Z")
}
fn second_due_time() -> DateTime<Utc> {
dt("2026-05-29T00:02:00Z")
}
#[test]
fn new_cursor_starts_at_next_future_occurrence_without_backfill() {
let now = dt("2026-05-29T00:00:30Z");
let automation = automation("nightly", "Nightly", vec![schedule_trigger(
"schedule",
"* * * * *",
true,
)]);
let mut planner = AutomationSchedulePlanner::default();
planner.reconcile(&[automation], now);
assert_eq!(planner.cursors.len(), 1);
let cursor = planner.cursors.values().next().unwrap();
assert_eq!(cursor.next_due_at, dt("2026-05-29T00:01:00Z"));
}
#[test]
fn due_cursor_is_returned_once_and_advanced_beyond_now() {
let automation = automation("nightly", "Nightly", vec![schedule_trigger(
"schedule",
"* * * * *",
true,
)]);
let mut planner = AutomationSchedulePlanner::default();
planner.reconcile(std::slice::from_ref(&automation), prime_time());
let due = planner.take_due(std::slice::from_ref(&automation), first_due_time());
let second_due = planner.take_due(std::slice::from_ref(&automation), first_due_time());
assert_eq!(due.len(), 1);
assert_eq!(due[0].trigger_id.as_str(), "schedule");
assert_eq!(due[0].due_at, first_due_time());
assert!(second_due.is_empty());
let cursor = planner.cursors.values().next().unwrap();
assert_eq!(cursor.next_due_at, second_due_time());
}
#[test]
fn disabled_schedule_trigger_removes_cursor() {
let mut automation = automation("nightly", "Nightly", vec![schedule_trigger(
"schedule",
"* * * * *",
true,
)]);
let mut planner = AutomationSchedulePlanner::default();
planner.reconcile(std::slice::from_ref(&automation), prime_time());
assert_eq!(planner.cursors.len(), 1);
automation.triggers = vec![schedule_trigger("schedule", "* * * * *", false)];
planner.reconcile(std::slice::from_ref(&automation), first_due_time());
assert!(planner.cursors.is_empty());
}
#[test]
fn replacing_automation_revision_or_expression_resets_cursor() {
let mut automation = automation("nightly", "Nightly", vec![schedule_trigger(
"schedule",
"* * * * *",
true,
)]);
let mut planner = AutomationSchedulePlanner::default();
planner.reconcile(std::slice::from_ref(&automation), prime_time());
let original_due = planner.cursors.values().next().unwrap().next_due_at;
automation.revision = AutomationRevision::from_bytes(b"new revision");
planner.reconcile(std::slice::from_ref(&automation), first_due_time());
let reset_due = planner.cursors.values().next().unwrap().next_due_at;
assert_eq!(original_due, first_due_time());
assert_eq!(reset_due, second_due_time());
automation.triggers = vec![schedule_trigger("schedule", "*/5 * * * *", true)];
planner.reconcile(std::slice::from_ref(&automation), second_due_time());
let expression_reset_due = planner.cursors.values().next().unwrap().next_due_at;
assert_eq!(expression_reset_due, dt("2026-05-29T00:05:00Z"));
}
#[test]
fn multiple_schedule_triggers_on_one_automation_have_independent_cursors() {
let automation = automation("nightly", "Nightly", vec![
schedule_trigger("every_minute", "* * * * *", true),
schedule_trigger("every_five", "*/5 * * * *", true),
]);
let mut planner = AutomationSchedulePlanner::default();
planner.reconcile(std::slice::from_ref(&automation), prime_time());
let due = planner.take_due(std::slice::from_ref(&automation), first_due_time());
assert_eq!(planner.cursors.len(), 2);
assert_eq!(due.len(), 1);
assert_eq!(due[0].trigger_id.as_str(), "every_minute");
let five_minute_cursor = planner
.cursors
.iter()
.find(|(key, _)| key.trigger_id.as_str() == "every_five")
.map(|(_, cursor)| cursor)
.unwrap();
assert_eq!(five_minute_cursor.next_due_at, dt("2026-05-29T00:05:00Z"));
}
#[test]
fn sleep_duration_uses_nearest_due_time_capped_at_thirty_seconds() {
let automation = automation("nightly", "Nightly", vec![schedule_trigger(
"schedule",
"* * * * *",
true,
)]);
let mut planner = AutomationSchedulePlanner::default();
planner.reconcile(std::slice::from_ref(&automation), prime_time());
assert_eq!(
planner.sleep_duration(prime_time()),
Duration::from_secs(30)
);
assert_eq!(
planner.sleep_duration(dt("2026-05-29T00:00:45Z")),
Duration::from_secs(15)
);
}
#[tokio::test]
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![
schedule_trigger("schedule", "* * * * *", true),
])
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
let runs = cached_runs(state.as_ref()).await;
assert_eq!(runs.len(), 1);
let automation_ref = runs[0].automation.as_ref().unwrap();
assert_eq!(automation_ref.id, "nightly");
assert_eq!(automation_ref.name.as_deref(), Some("Nightly"));
assert_eq!(automation_ref.trigger_id.as_deref(), Some("schedule"));
let run_id = runs[0].id;
assert!(matches!(
state
.runs
.lock()
.expect("runs lock should not be poisoned")
.get(&run_id)
.map(|run| run.status),
Some(RunStatus::Runnable)
));
}
#[tokio::test]
async fn schedule_only_automation_fires_without_api_trigger() {
let materializer = succeeding_materializer();
let state = test_state_with_materializer(materializer);
create_automation(state.as_ref(), "schedule-only", "Schedule only", vec![
schedule_trigger("schedule", "* * * * *", true),
])
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
}
#[tokio::test]
async fn disabled_schedule_trigger_does_not_create_run() {
let materializer = succeeding_materializer();
let state = test_state_with_materializer(materializer);
create_automation(
state.as_ref(),
"disabled-trigger",
"Disabled trigger",
vec![schedule_trigger("schedule", "* * * * *", false)],
)
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
assert!(cached_runs(state.as_ref()).await.is_empty());
}
#[tokio::test]
async fn multiple_due_triggers_create_multiple_runs() {
let materializer = succeeding_materializer();
let state = test_state_with_materializer(materializer);
create_automation(state.as_ref(), "nightly", "Nightly", vec![
schedule_trigger("first", "* * * * *", true),
schedule_trigger("second", "* * * * *", true),
])
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
let mut trigger_ids = cached_runs(state.as_ref())
.await
.into_iter()
.map(|run| run.automation.unwrap().trigger_id.unwrap())
.collect::<Vec<_>>();
trigger_ids.sort();
assert_eq!(trigger_ids, ["first", "second"]);
}
#[tokio::test]
async fn queued_prior_run_does_not_suppress_new_due_run() {
let materializer = succeeding_materializer();
let state = test_state_with_materializer(materializer);
create_automation(state.as_ref(), "nightly", "Nightly", vec![
schedule_trigger("schedule", "* * * * *", true),
])
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
assert_eq!(cached_runs(state.as_ref()).await.len(), 1);
assert!(
state
.runs
.lock()
.expect("runs lock should not be poisoned")
.values()
.any(|run| run.status == RunStatus::Runnable)
);
run_due_schedules_once(Arc::clone(&state), &mut planner, second_due_time()).await;
assert_eq!(cached_runs(state.as_ref()).await.len(), 2);
}
#[tokio::test]
async fn failing_materializer_waits_until_next_cron_occurrence() {
let materializer = TestAutomationRunMaterializer::fail_invalid_target("boom");
let state = test_state_with_materializer(materializer.clone());
create_automation(state.as_ref(), "nightly", "Nightly", vec![
schedule_trigger("schedule", "* * * * *", true),
])
.await;
let mut planner = AutomationSchedulePlanner::default();
run_due_schedules_once(Arc::clone(&state), &mut planner, prime_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
run_due_schedules_once(Arc::clone(&state), &mut planner, first_due_time()).await;
assert!(cached_runs(state.as_ref()).await.is_empty());
assert_eq!(materializer.captured_inputs().len(), 1);
run_due_schedules_once(Arc::clone(&state), &mut planner, second_due_time()).await;
assert!(cached_runs(state.as_ref()).await.is_empty());
assert_eq!(materializer.captured_inputs().len(), 2);
}
}

View file

@ -1,13 +1,11 @@
use std::sync::Arc;
use axum::http::{HeaderMap, HeaderValue, header};
use axum::http::HeaderMap;
use axum_extra::extract::Query as ExtraQuery;
use chrono::Utc;
use fabro_automation::{
Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision,
AutomationStoreError,
Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationStoreError,
};
use fabro_config::Storage;
use fabro_types::{AutomationRef, RunId};
use serde::Serialize;
@ -15,7 +13,7 @@ use super::super::{
ApiError, AppState, IntoResponse, Json, PaginationParams, Path, RequiredUser, Response, Router,
State, StatusCode, get, paginate_items,
};
use super::{lifecycle, runs};
use super::{json_with_etag_response, lifecycle, parse_required_if_match, runs};
use crate::automation_materializer::AutomationRunMaterializeInput;
use crate::principal_middleware::RequiredRunToolActor;
@ -133,7 +131,7 @@ async fn create_automation_run(
let Some(api_trigger) = automation.enabled_api_trigger() else {
return ApiError::with_code(
StatusCode::CONFLICT,
"automation is disabled or has no enabled API trigger",
"automation has no enabled API trigger",
"automation_api_trigger_disabled",
)
.into_response();
@ -141,16 +139,13 @@ async fn create_automation_run(
let api_trigger_id = api_trigger.id.to_string();
let run_id = RunId::new();
let temp_root = Storage::new(state.server_storage_dir())
.scratch_dir()
.join("automations");
let materialized = match state
.materialize_automation_run(AutomationRunMaterializeInput {
automation_id: automation.id.clone(),
target: automation.target.clone(),
run_id,
user_settings_path: state.active_config_path().to_path_buf(),
temp_root,
temp_root: state.automation_temp_root(),
})
.await
{
@ -204,6 +199,7 @@ async fn create_automation(
Json(draft): Json<AutomationDraft>,
) -> Result<Response, ApiError> {
let automation = state.automation_store().create(draft).await?;
state.notify_automation_scheduler();
Ok((StatusCode::CREATED, Json(automation)).into_response())
}
@ -227,11 +223,12 @@ async fn replace_automation(
Json(replacement): Json<AutomationReplace>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, &id)?;
let expected = parse_required_if_match(&headers, "automation", &id)?;
let automation = state
.automation_store()
.replace(&id, &expected, replacement)
.await?;
state.notify_automation_scheduler();
Ok(automation_with_etag_response(StatusCode::OK, automation))
}
@ -242,8 +239,9 @@ async fn delete_automation(
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, &id)?;
let expected = parse_required_if_match(&headers, "automation", &id)?;
state.automation_store().delete(&id, &expected).await?;
state.notify_automation_scheduler();
Ok(StatusCode::NO_CONTENT.into_response())
}
@ -252,38 +250,9 @@ fn parse_path_id(id: String) -> Result<AutomationId, ApiError> {
.map_err(|err| ApiError::bad_request(format!("invalid automation id: {err}")))
}
fn parse_required_if_match(
headers: &HeaderMap,
id: &AutomationId,
) -> Result<AutomationRevision, ApiError> {
let Some(value) = headers.get(header::IF_MATCH) else {
return Err(ApiError::new(
StatusCode::PRECONDITION_REQUIRED,
format!("If-Match header is required for automation: {id}"),
));
};
let value = value
.to_str()
.map_err(|_| ApiError::bad_request("If-Match header must be visible ASCII"))?;
let value = unquote_etag(value.trim());
value.parse::<AutomationRevision>().map_err(|err| {
ApiError::bad_request(format!("invalid If-Match automation revision: {err}"))
})
}
fn unquote_etag(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|unquoted| unquoted.strip_suffix('"'))
.unwrap_or(value)
}
fn automation_with_etag_response(status: StatusCode, automation: Automation) -> Response {
let etag = HeaderValue::from_str(&format!("\"{}\"", automation.revision))
.expect("automation revisions are valid ETag header values");
let mut response = (status, Json(automation)).into_response();
response.headers_mut().insert(header::ETAG, etag);
response
let revision = automation.revision.clone();
json_with_etag_response(status, "automation", &revision, automation)
}
impl From<AutomationStoreError> for ApiError {

View file

@ -0,0 +1,260 @@
use std::collections::HashMap;
use std::sync::Arc;
use axum::http::HeaderMap;
use fabro_environment::{Environment, EnvironmentDraft, EnvironmentId, EnvironmentStoreError};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
DockerfileSource, EnvironmentImageSettings, EnvironmentLifecycleSettings,
EnvironmentNetworkSettings, EnvironmentProvider, EnvironmentResourcesSettings,
EnvironmentSettings, EnvironmentVolumeSettings,
};
use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize};
use super::super::{
ApiError, AppState, IntoResponse, Json, Path, RequiredUser, Response, Router, State,
StatusCode, get,
};
use super::{json_with_etag_response, parse_required_if_match};
#[derive(Serialize)]
struct EnvironmentListResponse {
data: Vec<Environment>,
meta: EnvironmentListMeta,
}
#[derive(Serialize)]
struct EnvironmentListMeta {
total: usize,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateEnvironmentRequest {
id: EnvironmentId,
provider: EnvironmentProvider,
image: ApiEnvironmentImageSettings,
resources: EnvironmentResourcesSettings,
network: EnvironmentNetworkSettings,
lifecycle: EnvironmentLifecycleSettings,
labels: HashMap<String, String>,
volumes: Vec<EnvironmentVolumeSettings>,
env: HashMap<String, InterpString>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ReplaceEnvironmentRequest {
provider: EnvironmentProvider,
image: ApiEnvironmentImageSettings,
resources: EnvironmentResourcesSettings,
network: EnvironmentNetworkSettings,
lifecycle: EnvironmentLifecycleSettings,
labels: HashMap<String, String>,
volumes: Vec<EnvironmentVolumeSettings>,
env: HashMap<String, InterpString>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ApiEnvironmentImageSettings {
docker: Option<String>,
dockerfile: Option<ApiDockerfileSource>,
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum ApiDockerfileSource {
Inline {
value: String,
},
// Recognized so the handler can return a 422 with bespoke guidance.
// The `path` payload is parsed and discarded — never read from disk.
Path {
#[serde(rename = "path")]
_path: IgnoredAny,
},
}
impl CreateEnvironmentRequest {
fn into_draft(self) -> Result<EnvironmentDraft, ApiError> {
Ok(EnvironmentDraft {
id: self.id,
settings: EnvironmentSettings {
provider: self.provider,
image: self.image.into_settings()?,
resources: self.resources,
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
},
})
}
}
impl ReplaceEnvironmentRequest {
fn into_settings(self) -> Result<EnvironmentSettings, ApiError> {
Ok(EnvironmentSettings {
provider: self.provider,
image: self.image.into_settings()?,
resources: self.resources,
network: self.network,
lifecycle: self.lifecycle,
labels: self.labels,
volumes: self.volumes,
env: self.env,
})
}
}
impl ApiEnvironmentImageSettings {
fn into_settings(self) -> Result<EnvironmentImageSettings, ApiError> {
Ok(EnvironmentImageSettings {
docker: self.docker,
dockerfile: self
.dockerfile
.map(ApiDockerfileSource::into_settings)
.transpose()?,
})
}
}
impl ApiDockerfileSource {
fn into_settings(self) -> Result<DockerfileSource, ApiError> {
match self {
Self::Inline { value } => Ok(DockerfileSource::Inline(value)),
Self::Path { .. } => Err(ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"Dockerfile path sources are not supported by the environments REST API; use inline Dockerfile content",
)),
}
}
}
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/environments",
get(list_environments).post(create_environment),
)
.route(
"/environments/{id}",
get(get_environment)
.put(replace_environment)
.delete(delete_environment),
)
}
async fn list_environments(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
let data = state.environment_store().list();
let total = data.len();
(
StatusCode::OK,
Json(EnvironmentListResponse {
data,
meta: EnvironmentListMeta { total },
}),
)
.into_response()
}
async fn create_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<CreateEnvironmentRequest>,
) -> Result<Response, ApiError> {
let environment = state
.environment_store()
.create(request.into_draft()?)
.await?;
state.refresh_manifest_run_settings_from_environment_catalog();
Ok((StatusCode::CREATED, Json(environment)).into_response())
}
async fn get_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
match state.environment_store().get(&id) {
Some(environment) => Ok(environment_with_etag_response(StatusCode::OK, environment)),
None => Err(ApiError::not_found(format!("environment not found: {id}"))),
}
}
async fn replace_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
Json(request): Json<ReplaceEnvironmentRequest>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, "environment", &id)?;
let environment = state
.environment_store()
.replace(&id, &expected, request.into_settings()?)
.await?;
state.refresh_manifest_run_settings_from_environment_catalog();
Ok(environment_with_etag_response(StatusCode::OK, environment))
}
async fn delete_environment(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Response, ApiError> {
let id = parse_path_id(id)?;
let expected = parse_required_if_match(&headers, "environment", &id)?;
state.environment_store().delete(&id, &expected).await?;
state.refresh_manifest_run_settings_from_environment_catalog();
Ok(StatusCode::NO_CONTENT.into_response())
}
fn parse_path_id(id: String) -> Result<EnvironmentId, ApiError> {
EnvironmentId::new(id)
.map_err(|err| ApiError::bad_request(format!("invalid environment id: {err}")))
}
fn environment_with_etag_response(status: StatusCode, environment: Environment) -> Response {
let revision = environment.revision.clone();
json_with_etag_response(status, "environment", &revision, environment)
}
impl From<EnvironmentStoreError> for ApiError {
fn from(err: EnvironmentStoreError) -> Self {
match err {
EnvironmentStoreError::NotFound { id } => {
Self::not_found(format!("environment not found: {id}"))
}
EnvironmentStoreError::AlreadyExists { id } => Self::new(
StatusCode::CONFLICT,
format!("environment already exists: {id}"),
),
EnvironmentStoreError::StaleRevision { id, .. } => Self::new(
StatusCode::CONFLICT,
format!("environment revision is stale: {id}"),
),
EnvironmentStoreError::Protected { id } => Self::new(
StatusCode::CONFLICT,
format!("environment is protected and cannot be deleted: {id}"),
),
EnvironmentStoreError::Validation { source } => {
Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())
}
EnvironmentStoreError::InvalidFilename { .. }
| EnvironmentStoreError::Parse { .. }
| EnvironmentStoreError::InvalidUtf8 { .. }
| EnvironmentStoreError::Serialize { .. }
| EnvironmentStoreError::Io { .. } => Self::new(
StatusCode::INTERNAL_SERVER_ERROR,
"environment store operation failed",
),
}
}
}

View file

@ -67,7 +67,7 @@ async fn start_run(
}
}
pub(super) async fn queue_run_start(
pub(in crate::server) async fn queue_run_start(
state: &AppState,
id: RunId,
resume: bool,

View file

@ -1,17 +1,20 @@
use std::sync::Arc;
use axum::Router;
use axum::http::{HeaderMap, HeaderValue, header};
use axum::routing::{get, post};
use serde::Serialize;
use super::{ApiError, AppState, IntoResponse, Response, StatusCode, demo};
use super::{ApiError, AppState, IntoResponse, Json, Response, StatusCode, demo};
mod artifacts;
mod automations;
mod billing;
mod completions;
mod environments;
pub(in crate::server) mod events;
pub(in crate::server) mod graph;
mod lifecycle;
pub(in crate::server) mod lifecycle;
mod models;
mod pair;
mod pull_requests;
@ -31,6 +34,53 @@ async fn not_implemented() -> Response {
ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response()
}
fn parse_required_if_match<R>(
headers: &HeaderMap,
resource: &str,
id: &impl std::fmt::Display,
) -> Result<R, ApiError>
where
R: std::str::FromStr,
R::Err: std::fmt::Display,
{
let Some(value) = headers.get(header::IF_MATCH) else {
return Err(ApiError::new(
StatusCode::PRECONDITION_REQUIRED,
format!("If-Match header is required for {resource}: {id}"),
));
};
let value = value
.to_str()
.map_err(|_| ApiError::bad_request("If-Match header must be visible ASCII"))?;
let value = unquote_etag(value.trim());
value.parse::<R>().map_err(|err| {
ApiError::bad_request(format!("invalid If-Match {resource} revision: {err}"))
})
}
fn unquote_etag(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|unquoted| unquoted.strip_suffix('"'))
.unwrap_or(value)
}
fn json_with_etag_response<T>(
status: StatusCode,
resource: &str,
revision: &impl std::fmt::Display,
body: T,
) -> Response
where
T: Serialize,
{
let etag = HeaderValue::from_str(&format!("\"{revision}\""))
.unwrap_or_else(|_| panic!("{resource} revisions are valid ETag header values"));
let mut response = (status, Json(body)).into_response();
response.headers_mut().insert(header::ETAG, etag);
response
}
pub(super) fn demo_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs", get(demo::list_runs).post(demo::create_run_stub))
@ -158,6 +208,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(pull_requests::routes())
.merge(artifacts::routes())
.merge(automations::routes())
.merge(environments::routes())
.merge(sandbox::routes())
.merge(sandboxes::routes())
.merge(lifecycle::routes())

View file

@ -19,7 +19,6 @@ fn automation_body(id: &str, name: &str) -> Value {
"id": id,
"name": name,
"description": "Runs on a schedule.",
"enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@ -45,7 +44,6 @@ fn replacement_body(name: &str) -> Value {
json!({
"name": name,
"description": null,
"enabled": false,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@ -289,6 +287,7 @@ async fn schedule_trigger_round_trips_through_create_list_get_and_toml() {
assert_persisted_schedule_trigger(&persisted, "0 3 * * *", true);
assert!(persisted.get("id").is_none());
assert!(persisted.get("revision").is_none());
assert!(persisted.get("enabled").is_none());
}
#[tokio::test]
@ -692,21 +691,6 @@ async fn automations_routes_require_authenticated_user() {
.await;
}
#[tokio::test]
async fn disabled_automation_run_endpoint_returns_conflict_code() {
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
let mut body = automation_body("nightly", "Nightly");
body["enabled"] = json!(false);
create_automation_with_body(&app, &body).await;
let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;
assert_eq!(
error["errors"][0]["code"],
"automation_api_trigger_disabled"
);
}
#[tokio::test]
async fn missing_automation_run_endpoint_returns_not_found() {
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();

View file

@ -0,0 +1,602 @@
use std::path::{Path, PathBuf};
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use fabro_config::{RunEnvironmentLayer, RunLayer};
use fabro_server::server::build_router;
use fabro_server::test_support::{
TestAppStateBuilder, build_test_router, default_test_server_settings, test_auth_mode,
};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::helpers::{api, checked_response, response_json, response_status};
fn environment_settings(provider: &str) -> Value {
json!({
"provider": provider,
"image": {
"docker": if provider == "docker" { json!("alpine:3.20") } else { Value::Null },
"dockerfile": null
},
"resources": {
"cpu": null,
"memory": null,
"disk": null
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": null
},
"labels": {},
"volumes": [],
"env": {}
})
}
fn environment_body(id: &str, provider: &str) -> Value {
let mut body = environment_settings(provider);
body["id"] = json!(id);
body
}
fn environment_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let active_config_path = temp_dir.path().join("settings.toml");
let environment_dir = temp_dir.path().join("environments");
let state = TestAppStateBuilder::new()
.active_config_path(active_config_path)
.build();
(build_test_router(state), temp_dir, environment_dir)
}
fn environment_app_with_default_environment(
environment_id: &str,
) -> (axum::Router, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let active_config_path = temp_dir.path().join("settings.toml");
let manifest_run_defaults = RunLayer {
environment: Some(RunEnvironmentLayer {
id: Some(environment_id.to_string()),
..RunEnvironmentLayer::default()
}),
..RunLayer::default()
};
let state = TestAppStateBuilder::new()
.runtime_settings(default_test_server_settings(), manifest_run_defaults)
.active_config_path(active_config_path)
.build();
(build_test_router(state), temp_dir)
}
fn json_request(method: Method, path: &str, body: &Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(body).expect("environment fixture should serialize"),
))
.expect("environment JSON request should build")
}
fn empty_request(method: Method, path: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.body(Body::empty())
.expect("environment request should build")
}
fn request_with_if_match(
method: Method,
path: &str,
revision: &str,
body: Option<Value>,
) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(api(path))
.header(header::IF_MATCH, revision);
let body = match body {
Some(value) => {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(serde_json::to_vec(&value).expect("environment fixture should serialize"))
}
None => Body::empty(),
};
builder
.body(body)
.expect("environment If-Match request should build")
}
async fn create_environment(app: &axum::Router, id: &str, provider: &str) -> Value {
create_environment_with_body(app, &environment_body(id, provider)).await
}
async fn create_environment_with_body(app: &axum::Router, body: &Value) -> Value {
let response = app
.clone()
.oneshot(json_request(Method::POST, "/environments", body))
.await
.expect("create environment should respond");
response_json(response, StatusCode::CREATED, "POST /api/v1/environments").await
}
fn revision_from(body: &Value) -> &str {
body["revision"]
.as_str()
.expect("environment response should include a revision")
}
async fn persisted_environment_toml(environment_dir: &Path, id: &str) -> toml::Value {
let persisted = tokio::fs::read_to_string(environment_dir.join(format!("{id}.toml")))
.await
.expect("persisted environment TOML should be readable");
toml::from_str(&persisted).expect("persisted environment TOML should parse")
}
async fn system_info(app: &axum::Router) -> Value {
let response = app
.clone()
.oneshot(empty_request(Method::GET, "/system/info"))
.await
.expect("system info should respond");
response_json(response, StatusCode::OK, "GET /api/v1/system/info").await
}
#[tokio::test]
async fn list_environments_returns_seeded_catalog_sorted_by_id() {
let (app, _temp_dir, _environment_dir) = environment_app();
let response = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let body = response_json(response, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(body["meta"]["total"], 4);
assert_eq!(
body["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.map(|environment| environment["id"]
.as_str()
.expect("environment should have id"))
.collect::<Vec<_>>(),
vec!["daytona", "default", "docker", "local"]
);
}
#[tokio::test]
async fn create_environment_persists_sibling_toml_and_is_visible() {
let (app, _temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "custom-env", "docker").await;
assert_eq!(created["id"], "custom-env");
assert_eq!(created["provider"], "docker");
assert!(environment_dir.join("custom-env.toml").exists());
let retrieved = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/custom-env"))
.await
.expect("get environment should respond");
let retrieved = response_json(
retrieved,
StatusCode::OK,
"GET /api/v1/environments/custom-env",
)
.await;
assert_eq!(retrieved["id"], "custom-env");
let list = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(list["meta"]["total"], 5);
assert!(
list["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.any(|environment| environment["id"] == "custom-env")
);
let persisted = persisted_environment_toml(&environment_dir, "custom-env").await;
assert_eq!(
persisted.get("provider").and_then(toml::Value::as_str),
Some("docker")
);
assert!(persisted.get("id").is_none());
assert!(persisted.get("revision").is_none());
}
#[tokio::test]
async fn get_environment_returns_current_etag() {
let (app, _temp_dir, _environment_dir) = environment_app();
let created = create_environment(&app, "etag-env", "local").await;
let revision = revision_from(&created);
let response = app
.oneshot(empty_request(Method::GET, "/environments/etag-env"))
.await
.expect("get environment should respond");
let response = checked_response(
response,
StatusCode::OK,
"GET /api/v1/environments/etag-env",
)
.await;
assert_eq!(
response
.headers()
.get(header::ETAG)
.expect("GET environment should include ETag"),
&format!("\"{revision}\"")
);
let body = crate::helpers::body_json(response.into_body()).await;
assert_eq!(body["revision"], revision);
}
#[tokio::test]
async fn replace_environment_updates_file_and_returns_new_etag() {
let (app, _temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "replace-env", "docker").await;
let revision = revision_from(&created);
let mut replacement = environment_settings("local");
replacement["labels"] = json!({ "tier": "dev" });
let response = app
.oneshot(request_with_if_match(
Method::PUT,
"/environments/replace-env",
revision,
Some(replacement),
))
.await
.expect("replace environment should respond");
let response = checked_response(
response,
StatusCode::OK,
"PUT /api/v1/environments/replace-env",
)
.await;
let etag = response
.headers()
.get(header::ETAG)
.expect("PUT environment should include ETag")
.to_str()
.expect("ETag should be ASCII")
.to_string();
let body = crate::helpers::body_json(response.into_body()).await;
assert_eq!(body["provider"], "local");
assert_eq!(body["labels"]["tier"], "dev");
assert_ne!(body["revision"], revision);
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
let persisted = persisted_environment_toml(&environment_dir, "replace-env").await;
assert_eq!(
persisted
.get("labels")
.and_then(toml::Value::as_table)
.and_then(|labels| labels.get("tier"))
.and_then(toml::Value::as_str),
Some("dev")
);
}
#[tokio::test]
async fn replace_and_delete_environment_require_if_match() {
let (app, _temp_dir, _environment_dir) = environment_app();
create_environment(&app, "match-env", "local").await;
let replace_response = app
.clone()
.oneshot(json_request(
Method::PUT,
"/environments/match-env",
&environment_settings("docker"),
))
.await
.expect("replace without If-Match should respond");
response_status(
replace_response,
StatusCode::PRECONDITION_REQUIRED,
"PUT /api/v1/environments/match-env without If-Match",
)
.await;
let delete_response = app
.oneshot(empty_request(Method::DELETE, "/environments/match-env"))
.await
.expect("delete without If-Match should respond");
response_status(
delete_response,
StatusCode::PRECONDITION_REQUIRED,
"DELETE /api/v1/environments/match-env without If-Match",
)
.await;
}
#[tokio::test]
async fn stale_environment_replace_and_delete_return_conflict() {
let (app, _temp_dir, _environment_dir) = environment_app();
let created = create_environment(&app, "stale-env", "docker").await;
let stale_revision = revision_from(&created).to_string();
let replaced = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/stale-env",
&stale_revision,
Some(environment_settings("local")),
))
.await
.expect("first replace should respond");
response_status(
replaced,
StatusCode::OK,
"PUT /api/v1/environments/stale-env first replace",
)
.await;
let stale_replace = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/stale-env",
&stale_revision,
Some(environment_settings("docker")),
))
.await
.expect("stale replace should respond");
response_status(
stale_replace,
StatusCode::CONFLICT,
"PUT /api/v1/environments/stale-env stale",
)
.await;
let stale_delete = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/stale-env",
&stale_revision,
None,
))
.await
.expect("stale delete should respond");
response_status(
stale_delete,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/stale-env stale",
)
.await;
}
#[tokio::test]
async fn duplicate_environment_create_returns_conflict() {
let (app, _temp_dir, _environment_dir) = environment_app();
create_environment(&app, "duplicate-env", "local").await;
let response = app
.oneshot(json_request(
Method::POST,
"/environments",
&environment_body("duplicate-env", "docker"),
))
.await
.expect("duplicate create should respond");
response_status(
response,
StatusCode::CONFLICT,
"POST /api/v1/environments duplicate",
)
.await;
}
#[tokio::test]
async fn invalid_environment_id_and_if_match_return_bad_request() {
let (app, _temp_dir, _environment_dir) = environment_app();
let invalid_id = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/Bad!"))
.await
.expect("invalid id request should respond");
response_status(
invalid_id,
StatusCode::BAD_REQUEST,
"GET /api/v1/environments/Bad!",
)
.await;
create_environment(&app, "header-env", "local").await;
let invalid_header = app
.oneshot(request_with_if_match(
Method::PUT,
"/environments/header-env",
"not-a-revision",
Some(environment_settings("docker")),
))
.await
.expect("invalid If-Match request should respond");
response_status(
invalid_header,
StatusCode::BAD_REQUEST,
"PUT /api/v1/environments/header-env invalid If-Match",
)
.await;
}
#[tokio::test]
async fn invalid_environment_settings_return_unprocessable_entity() {
let (app, _temp_dir, _environment_dir) = environment_app();
let mut body = environment_body("invalid-env", "local");
body["network"]["mode"] = json!("block");
let response = app
.oneshot(json_request(Method::POST, "/environments", &body))
.await
.expect("invalid environment create should respond");
response_status(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/environments invalid settings",
)
.await;
}
#[tokio::test]
async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_contents() {
let (app, temp_dir, environment_dir) = environment_app();
tokio::fs::write(
temp_dir.path().join("Dockerfile"),
"FROM private.example/secret\n",
)
.await
.expect("secret Dockerfile fixture should be written");
let mut body = environment_body("path-env", "docker");
body["image"]["docker"] = Value::Null;
body["image"]["dockerfile"] = json!({
"type": "path",
"path": "Dockerfile"
});
let response = app
.clone()
.oneshot(json_request(Method::POST, "/environments", &body))
.await
.expect("path Dockerfile create should respond");
let error = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/environments Dockerfile path",
)
.await;
assert!(!environment_dir.join("path-env.toml").exists());
assert!(
!serde_json::to_string(&error)
.expect("error body should serialize")
.contains("private.example/secret")
);
let list = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
assert!(
!list["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.any(|environment| environment["id"] == "path-env")
);
}
#[tokio::test]
async fn delete_environment_removes_non_default_and_default_is_protected() {
let (app, _temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "delete-env", "local").await;
let revision = revision_from(&created);
let response = app
.clone()
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/delete-env",
&format!("\"{revision}\""),
None,
))
.await
.expect("delete environment should respond");
response_status(
response,
StatusCode::NO_CONTENT,
"DELETE /api/v1/environments/delete-env",
)
.await;
assert!(!environment_dir.join("delete-env.toml").exists());
let missing = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/delete-env"))
.await
.expect("get deleted environment should respond");
response_status(
missing,
StatusCode::NOT_FOUND,
"GET /api/v1/environments/delete-env after delete",
)
.await;
let default = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/default"))
.await
.expect("get default environment should respond");
let default = response_json(default, StatusCode::OK, "GET /api/v1/environments/default").await;
let protected = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/default",
revision_from(&default),
None,
))
.await
.expect("delete default environment should respond");
response_status(
protected,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/default",
)
.await;
}
#[tokio::test]
async fn environment_routes_require_authenticated_user() {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let state = TestAppStateBuilder::new()
.active_config_path(temp_dir.path().join("settings.toml"))
.build();
let app = build_router(state, test_auth_mode());
let response = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("unauthenticated environment list should respond");
response_status(
response,
StatusCode::UNAUTHORIZED,
"GET /api/v1/environments without auth",
)
.await;
}
#[tokio::test]
async fn create_environment_refreshes_cached_manifest_run_settings() {
let (app, _temp_dir) = environment_app_with_default_environment("api-default");
let before = system_info(&app).await;
assert_eq!(before["sandbox_provider"], "local");
create_environment(&app, "api-default", "daytona").await;
let after = system_info(&app).await;
assert_eq!(after["sandbox_provider"], "daytona");
}

View file

@ -2,6 +2,7 @@ mod auth_sessions;
mod automations;
mod cli_auth_token;
mod docs;
mod environments;
mod events;
mod install;
mod install_openai_compatible;

View file

@ -141,6 +141,54 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() {
);
}
#[test]
fn environment_spec_and_sdk_expose_crud_without_dockerfile_paths() {
let spec = load_spec();
let paths = spec
.get("paths")
.and_then(Value::as_mapping)
.expect("spec is missing `paths`");
for path in ["/api/v1/environments", "/api/v1/environments/{id}"] {
assert!(
paths.contains_key(Value::String(path.to_string())),
"OpenAPI spec should expose {path}"
);
}
let generated_api = read_repo_file("lib/packages/fabro-api-client/src/api/environments-api.ts");
assert!(
generated_api.contains("export class EnvironmentsApi"),
"generated TypeScript client should expose EnvironmentsApi"
);
for operation in [
"createEnvironment",
"deleteEnvironment",
"listEnvironments",
"replaceEnvironment",
"retrieveEnvironment",
] {
assert!(
generated_api.contains(operation),
"generated EnvironmentsApi should expose {operation}"
);
}
let generated_image = read_repo_file(
"lib/packages/fabro-api-client/src/models/environment-api-image-settings.ts",
);
assert!(
!generated_image.contains("DockerfileSourcePath") && !generated_image.contains("'path'"),
"generated REST environment image model should not expose Dockerfile path sources"
);
let workflow_dockerfile =
read_repo_file("lib/packages/fabro-api-client/src/models/dockerfile-source.ts");
assert!(
workflow_dockerfile.contains("DockerfileSourcePath"),
"workflow/settings Dockerfile schema should keep exposing path sources"
);
}
#[tokio::test]
async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() {
let secret = "test-webhook-secret";

View file

@ -4,6 +4,7 @@ api/automations-api.ts
api/billing-api.ts
api/completions-api.ts
api/discovery-api.ts
api/environments-api.ts
api/human-in-the-loop-api.ts
api/insights-api.ts
api/install-api.ts
@ -94,6 +95,7 @@ models/completion-usage.ts
models/conclusion.ts
models/create-automation-request.ts
models/create-completion-request.ts
models/create-environment-request.ts
models/create-run-pull-request-request.ts
models/create-run-session-request.ts
models/create-secret-request.ts
@ -118,14 +120,19 @@ models/disk-usage-summary-row.ts
models/dockerfile-source-inline.ts
models/dockerfile-source-path.ts
models/dockerfile-source.ts
models/environment-api-dockerfile-source-inline.ts
models/environment-api-image-settings.ts
models/environment-image-settings.ts
models/environment-lifecycle-settings.ts
models/environment-list-meta.ts
models/environment-list-response.ts
models/environment-network-mode.ts
models/environment-network-settings.ts
models/environment-provider.ts
models/environment-resources-settings.ts
models/environment-settings.ts
models/environment-volume-settings.ts
models/environment.ts
models/error-response-entry.ts
models/error-response.ts
models/event-envelope.ts
@ -300,6 +307,7 @@ models/render-workflow-graph-direction.ts
models/render-workflow-graph-format.ts
models/render-workflow-graph-request.ts
models/replace-automation-request.ts
models/replace-environment-request.ts
models/repo-check-response-permissions.ts
models/repo-check-response.ts
models/repository-ref.ts

View file

@ -19,6 +19,7 @@ export * from './api/automations-api';
export * from './api/billing-api';
export * from './api/completions-api';
export * from './api/discovery-api';
export * from './api/environments-api';
export * from './api/human-in-the-loop-api';
export * from './api/insights-api';
export * from './api/install-api';

View file

@ -0,0 +1,453 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { CreateEnvironmentRequest } from '../models';
// @ts-ignore
import type { Environment } from '../models';
// @ts-ignore
import type { EnvironmentListResponse } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { ReplaceEnvironmentRequest } from '../models';
/**
* EnvironmentsApi - axios parameter creator
*/
export const EnvironmentsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createEnvironment: async (createEnvironmentRequest: CreateEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createEnvironmentRequest' is not null or undefined
assertParamExists('createEnvironment', 'createEnvironmentRequest', createEnvironmentRequest)
const localVarPath = `/api/v1/environments`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createEnvironmentRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteEnvironment: async (id: string, ifMatch: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('deleteEnvironment', 'id', id)
// verify required parameter 'ifMatch' is not null or undefined
assertParamExists('deleteEnvironment', 'ifMatch', ifMatch)
const localVarPath = `/api/v1/environments/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
if (ifMatch != null) {
localVarHeaderParameter['If-Match'] = String(ifMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns all server-managed environment definitions, sorted by id.
* @summary List environments
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listEnvironments: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/environments`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
* @summary Replace environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
replaceEnvironment: async (id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('replaceEnvironment', 'id', id)
// verify required parameter 'ifMatch' is not null or undefined
assertParamExists('replaceEnvironment', 'ifMatch', ifMatch)
// verify required parameter 'replaceEnvironmentRequest' is not null or undefined
assertParamExists('replaceEnvironment', 'replaceEnvironmentRequest', replaceEnvironmentRequest)
const localVarPath = `/api/v1/environments/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
if (ifMatch != null) {
localVarHeaderParameter['If-Match'] = String(ifMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(replaceEnvironmentRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns one server-managed environment definition by id.
* @summary Retrieve environment
* @param {string} id Unique environment identifier.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveEnvironment: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('retrieveEnvironment', 'id', id)
const localVarPath = `/api/v1/environments/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* EnvironmentsApi - functional programming interface
*/
export const EnvironmentsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = EnvironmentsApiAxiosParamCreator(configuration)
return {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createEnvironment(createEnvironmentRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.createEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteEnvironment(id, ifMatch, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.deleteEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns all server-managed environment definitions, sorted by id.
* @summary List environments
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listEnvironments(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<EnvironmentListResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listEnvironments(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.listEnvironments']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
* @summary Replace environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.replaceEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns one server-managed environment definition by id.
* @summary Retrieve environment
* @param {string} id Unique environment identifier.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Environment>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveEnvironment(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['EnvironmentsApi.retrieveEnvironment']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* EnvironmentsApi - factory interface
*/
export const EnvironmentsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = EnvironmentsApiFp(configuration)
return {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
return localVarFp.createEnvironment(createEnvironmentRequest, options).then((request) => request(axios, basePath));
},
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteEnvironment(id, ifMatch, options).then((request) => request(axios, basePath));
},
/**
* Returns all server-managed environment definitions, sorted by id.
* @summary List environments
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listEnvironments(options?: RawAxiosRequestConfig): AxiosPromise<EnvironmentListResponse> {
return localVarFp.listEnvironments(options).then((request) => request(axios, basePath));
},
/**
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
* @summary Replace environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
return localVarFp.replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(axios, basePath));
},
/**
* Returns one server-managed environment definition by id.
* @summary Retrieve environment
* @param {string} id Unique environment identifier.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveEnvironment(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Environment> {
return localVarFp.retrieveEnvironment(id, options).then((request) => request(axios, basePath));
},
};
};
/**
* EnvironmentsApi - object-oriented interface
*/
export class EnvironmentsApi extends BaseAPI {
/**
* Creates a server-owned environment definition in the environment catalog. REST environment requests only accept inline Dockerfile content; local Dockerfile paths are supported by workflow/settings files but rejected by this API.
* @summary Create environment
* @param {CreateEnvironmentRequest} createEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createEnvironment(createEnvironmentRequest: CreateEnvironmentRequest, options?: RawAxiosRequestConfig) {
return EnvironmentsApiFp(this.configuration).createEnvironment(createEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Deletes a non-default environment definition when `If-Match` matches the current environment revision.
* @summary Delete environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public deleteEnvironment(id: string, ifMatch: string, options?: RawAxiosRequestConfig) {
return EnvironmentsApiFp(this.configuration).deleteEnvironment(id, ifMatch, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns all server-managed environment definitions, sorted by id.
* @summary List environments
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listEnvironments(options?: RawAxiosRequestConfig) {
return EnvironmentsApiFp(this.configuration).listEnvironments(options).then((request) => request(this.axios, this.basePath));
}
/**
* Replaces an environment definition when `If-Match` matches the current environment revision. The path id is authoritative; the request body omits `id`.
* @summary Replace environment
* @param {string} id Unique environment identifier.
* @param {string} ifMatch Current resource revision used for optimistic concurrency, as returned in the &#x60;ETag&#x60; response header.
* @param {ReplaceEnvironmentRequest} replaceEnvironmentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public replaceEnvironment(id: string, ifMatch: string, replaceEnvironmentRequest: ReplaceEnvironmentRequest, options?: RawAxiosRequestConfig) {
return EnvironmentsApiFp(this.configuration).replaceEnvironment(id, ifMatch, replaceEnvironmentRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns one server-managed environment definition by id.
* @summary Retrieve environment
* @param {string} id Unique environment identifier.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public retrieveEnvironment(id: string, options?: RawAxiosRequestConfig) {
return EnvironmentsApiFp(this.configuration).retrieveEnvironment(id, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -31,7 +31,6 @@ export interface Automation {
'revision': string;
'name': string;
'description': string | null;
'enabled': boolean;
'target': AutomationTarget;
'triggers': Array<AutomationTrigger>;
}

View file

@ -27,7 +27,6 @@ export interface CreateAutomationRequest {
'id': string;
'name': string;
'description'?: string | null;
'enabled'?: boolean;
'target': AutomationTarget;
'triggers': Array<AutomationTrigger>;
}

View file

@ -0,0 +1,48 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
/**
* Request body for creating a server-managed environment.
*/
export interface CreateEnvironmentRequest {
'id': string;
'provider': EnvironmentProvider;
'image': EnvironmentApiImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface EnvironmentApiDockerfileSourceInline {
'type': EnvironmentApiDockerfileSourceInlineTypeEnum;
'value': string;
}
export const EnvironmentApiDockerfileSourceInlineTypeEnum = {
INLINE: 'inline'
} as const;
export type EnvironmentApiDockerfileSourceInlineTypeEnum = typeof EnvironmentApiDockerfileSourceInlineTypeEnum[keyof typeof EnvironmentApiDockerfileSourceInlineTypeEnum];

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiDockerfileSourceInline } from './environment-api-dockerfile-source-inline';
/**
* REST-safe environment image settings. Dockerfile sources are inline-only; local paths are rejected by the REST API.
*/
export interface EnvironmentApiImageSettings {
'docker': string | null;
'dockerfile': EnvironmentApiDockerfileSourceInline | null;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Metadata for environment list responses.
*/
export interface EnvironmentListMeta {
/**
* Total number of server-managed environment definitions.
*/
'total': number;
}

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { Environment } from './environment';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentListMeta } from './environment-list-meta';
/**
* List envelope for environment definitions.
*/
export interface EnvironmentListResponse {
'data': Array<Environment>;
'meta': EnvironmentListMeta;
}

View file

@ -0,0 +1,52 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
/**
* Public server-managed environment definition.
*/
export interface Environment {
'id': string;
/**
* Stable revision used with `If-Match` for optimistic concurrency.
*/
'revision': string;
'provider': EnvironmentProvider;
'image': EnvironmentApiImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}

View file

@ -68,6 +68,7 @@ export * from './completion-usage';
export * from './conclusion';
export * from './create-automation-request';
export * from './create-completion-request';
export * from './create-environment-request';
export * from './create-run-pull-request-request';
export * from './create-run-session-request';
export * from './create-secret-request';
@ -92,8 +93,13 @@ export * from './disk-usage-summary-row';
export * from './dockerfile-source';
export * from './dockerfile-source-inline';
export * from './dockerfile-source-path';
export * from './environment';
export * from './environment-api-dockerfile-source-inline';
export * from './environment-api-image-settings';
export * from './environment-image-settings';
export * from './environment-lifecycle-settings';
export * from './environment-list-meta';
export * from './environment-list-response';
export * from './environment-network-mode';
export * from './environment-network-settings';
export * from './environment-provider';
@ -273,6 +279,7 @@ export * from './render-workflow-graph-direction';
export * from './render-workflow-graph-format';
export * from './render-workflow-graph-request';
export * from './replace-automation-request';
export * from './replace-environment-request';
export * from './repo-check-response';
export * from './repo-check-response-permissions';
export * from './repository-ref';

View file

@ -26,7 +26,6 @@ import type { AutomationTrigger } from './automation-trigger';
export interface ReplaceAutomationRequest {
'name': string;
'description'?: string | null;
'enabled': boolean;
'target': AutomationTarget;
'triggers': Array<AutomationTrigger>;
}

View file

@ -0,0 +1,47 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentApiImageSettings } from './environment-api-image-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentLifecycleSettings } from './environment-lifecycle-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentNetworkSettings } from './environment-network-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentProvider } from './environment-provider';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentResourcesSettings } from './environment-resources-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { EnvironmentVolumeSettings } from './environment-volume-settings';
/**
* Request body for replacing a server-managed environment. The path id is authoritative.
*/
export interface ReplaceEnvironmentRequest {
'provider': EnvironmentProvider;
'image': EnvironmentApiImageSettings;
'resources': EnvironmentResourcesSettings;
'network': EnvironmentNetworkSettings;
'lifecycle': EnvironmentLifecycleSettings;
'labels': { [key: string]: string; };
'volumes': Array<EnvironmentVolumeSettings>;
'env': { [key: string]: string; };
}