feat(automations): wire UI to API and auto-start runs from API trigger

Make the Automations area in the web UI functional end-to-end against the
real Automation API, and fix the backend so runs created by an automation's
API trigger actually start instead of sitting in Submitted forever.

Web:
- Reveal the Automations nav tab outside demo mode; drop the now-empty
  demoOnly mechanism.
- List page: render via listAutomations (was workflows mock data); wire
  ellipsis menu to Edit and Delete, with ConfirmDialog + If-Match revision.
  Move Create Automation into the toolbar, switch the trigger select to a
  shared FilterButton, hide the redundant page-header title via a new
  hideTitle handle flag.
- Play button on each card fires createAutomationRun with spinner + toast
  and navigates to the new run.
- New automation form: drop the dead Goal panel and hardcoded repository
  list, post to createAutomation with real triggers.
- Edit automation: new /automations/:id/edit route reusing a shared
  AutomationFormFields component, PUT via replaceAutomation with If-Match.
- Show page: rebuild like a run detail page — breadcrumb, title, chips
  (enabled status, repo+ref, workflow, schedule), Edit + Run actions
  (Run hits createAutomationRun), and a Runs panel using RunsListView
  with URL-driven search/sort/pagination/column-picker like the Children
  sub-tab. Drop the obsolete Definition/Diagram/Runs child routes.

Backend (fabro-server):
- create_automation_run now calls lifecycle::queue_run_start after the
  run is persisted, so the run transitions Submitted → Runnable and the
  scheduler picks it up. Logs a warn and returns the created response if
  start fails (no worse than the prior always-stuck behavior).
- queue_run_start in lifecycle.rs is promoted to pub(super) so sibling
  handlers can reuse it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-28 20:24:09 -04:00
parent 0106711170
commit 87516c25ce
No known key found for this signature in database
17 changed files with 1225 additions and 1237 deletions

View file

@ -0,0 +1,356 @@
import { useRef, type ReactNode } from "react";
import { Switch } from "@headlessui/react";
import type { Automation, AutomationTrigger } from "@qltysh/fabro-api-client";
import { Panel, Row } from "./settings-panel";
import { INPUT_CLASS } from "./ui";
export interface AutomationFormValues {
id: string;
name: string;
description: string;
enabled: boolean;
repository: string;
ref: string;
workflow: string;
manualEnabled: boolean;
scheduleEnabled: boolean;
cron: string;
}
export const EMPTY_AUTOMATION_FORM: AutomationFormValues = {
id: "",
name: "",
description: "",
enabled: true,
repository: "",
ref: "main",
workflow: "",
manualEnabled: true,
scheduleEnabled: false,
cron: "0 9 * * 1-5",
};
const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
{ label: "Every hour", value: "0 * * * *" },
{ label: "Daily 9:00 UTC", value: "0 9 * * *" },
{ label: "Weekdays 9:00", value: "0 9 * * 1-5" },
{ label: "Mondays 8:00", value: "0 8 * * 1" },
];
export function automationToFormValues(automation: Automation): AutomationFormValues {
const apiTrigger = automation.triggers.find((t) => t.type === "api");
const scheduleTrigger = automation.triggers.find((t) => t.type === "schedule");
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,
manualEnabled: apiTrigger?.enabled ?? false,
scheduleEnabled: scheduleTrigger?.enabled ?? false,
cron: scheduleTrigger?.expression ?? "0 9 * * 1-5",
};
}
export function triggersFromFormValues(values: AutomationFormValues): AutomationTrigger[] {
const triggers: AutomationTrigger[] = [];
if (values.manualEnabled) {
triggers.push({ id: "manual", type: "api", enabled: true });
}
if (values.scheduleEnabled) {
triggers.push({
id: "schedule",
type: "schedule",
enabled: true,
expression: values.cron.trim(),
});
}
return triggers;
}
export function isFormValid(values: AutomationFormValues): boolean {
return (
values.id.trim() !== "" &&
values.name.trim() !== "" &&
values.repository.trim() !== "" &&
values.ref.trim() !== "" &&
values.workflow.trim() !== "" &&
(values.manualEnabled || values.scheduleEnabled)
);
}
export function kebabify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export function snakeify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
}
function describeCron(expression: string): string {
const trimmed = expression.trim();
const preset = CRON_PRESETS.find((p) => p.value === trimmed);
if (preset) return preset.label;
if (!/^[\d*/,\-\s]+$/.test(trimmed) || trimmed.split(/\s+/).length !== 5) {
return "Waiting for a valid expression…";
}
return "Computed when saved";
}
interface AutomationFormFieldsProps {
values: AutomationFormValues;
onChange: (values: AutomationFormValues) => void;
lockIdAndTarget?: boolean;
}
export function AutomationFormFields({
values,
onChange,
lockIdAndTarget = false,
}: AutomationFormFieldsProps) {
const slugTouchedRef = useRef(values.id.length > 0);
function patch(partial: Partial<AutomationFormValues>) {
onChange({ ...values, ...partial });
}
function onNameChange(next: string) {
if (slugTouchedRef.current || lockIdAndTarget) {
patch({ name: next });
} else {
patch({ name: next, id: kebabify(next) });
}
}
function onSlugChange(next: string) {
slugTouchedRef.current = true;
patch({ id: kebabify(next) });
}
return (
<>
<Panel title="Basics">
<Row title={<Label required>Name</Label>} help="Shown wherever this automation is listed.">
<input
type="text"
name="name"
aria-label="Automation name"
value={values.name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Fix Build"
autoComplete="off"
className={INPUT_CLASS}
/>
</Row>
<Row
title={<Label required>Slug</Label>}
help={
lockIdAndTarget ? (
"The slug cannot be changed after creation."
) : (
<>
Identifier used in the URL:{" "}
<span className="font-mono text-fg-2">/automation/{values.id || "<slug>"}</span>
</>
)
}
>
{lockIdAndTarget ? (
<div className="font-mono text-sm text-fg">{values.id}</div>
) : (
<input
type="text"
name="slug"
aria-label="Automation slug"
value={values.id}
onChange={(e) => onSlugChange(e.target.value)}
placeholder="fix-build"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
)}
</Row>
<Row title={<Label optional>Description</Label>} help="A short summary teammates will see when browsing automations.">
<textarea
name="description"
aria-label="Automation description"
value={values.description}
onChange={(e) => patch({ description: e.target.value })}
rows={2}
placeholder="Diagnose and fix CI build failures by analyzing logs and applying targeted patches."
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">
<Row title={<Label required>Repository</Label>} help="GitHub repository in owner/repo form.">
<input
type="text"
name="repository"
aria-label="Repository"
value={values.repository}
onChange={(e) => patch({ repository: e.target.value })}
placeholder="acme/orders-api"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row title={<Label required>Branch</Label>} help="Default branch to run against.">
<input
type="text"
name="branch"
aria-label="Default branch"
value={values.ref}
onChange={(e) => patch({ ref: e.target.value })}
placeholder="main"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title={<Label required>Workflow slug</Label>}
help="Snake-case identifier used in the workflow file name (e.g. fix_build.fabro)."
>
<input
type="text"
name="workflow_slug"
aria-label="Workflow slug"
value={values.workflow}
onChange={(e) => patch({ workflow: snakeify(e.target.value) })}
placeholder="fix_build"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
</Panel>
<Panel title="Triggers">
<Row title="Manual / API" help="Start a run by clicking Run in the UI or calling the API.">
<ToggleSwitch
checked={values.manualEnabled}
onChange={(manualEnabled) => patch({ manualEnabled })}
label="Enable manual and API triggers"
/>
</Row>
<Row title="Schedule" help="Start runs automatically on a recurring cron schedule.">
<ToggleSwitch
checked={values.scheduleEnabled}
onChange={(scheduleEnabled) => patch({ scheduleEnabled })}
label="Enable scheduled triggers"
/>
</Row>
{values.scheduleEnabled ? (
<Row
title="Cron expression"
help={
<>
Five-field POSIX cron in UTC. Next run:{" "}
<span className="text-fg-2">{describeCron(values.cron)}</span>
</>
}
>
<div className="space-y-2">
<input
type="text"
name="cron"
aria-label="Cron expression"
value={values.cron}
onChange={(e) => patch({ cron: e.target.value })}
placeholder="0 9 * * 1-5"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<div className="flex flex-wrap gap-1.5">
{CRON_PRESETS.map((preset) => {
const active = preset.value === values.cron;
return (
<button
key={preset.value}
type="button"
onClick={() => patch({ cron: preset.value })}
aria-pressed={active}
className={`rounded-full px-2.5 py-1 text-xs transition-colors ${
active
? "bg-teal-500/15 text-teal-300 outline-1 -outline-offset-1 outline-teal-500/40"
: "bg-overlay text-fg-3 hover:bg-overlay-strong hover:text-fg-2"
}`}
>
{preset.label}
</button>
);
})}
</div>
</div>
</Row>
) : null}
</Panel>
</>
);
}
function Label({
children,
required,
optional,
}: {
children: ReactNode;
required?: boolean;
optional?: boolean;
}) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span>{children}</span>
{required ? (
<span aria-label="required" className="text-coral">
*
</span>
) : null}
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
</span>
);
}
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
}) {
return (
<Switch
checked={checked}
onChange={onChange}
aria-label={label}
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
>
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
</Switch>
);
}

View file

@ -1,18 +0,0 @@
import { describe, expect, test } from "bun:test";
import { getVisibleNavigation } from "./navigation";
describe("getVisibleNavigation", () => {
test("shows Automations first in demo mode followed by Runs and Settings", () => {
const items = getVisibleNavigation(true);
const names = items.map((i) => i.name);
expect(names).toEqual(["Automations", "Runs", "Settings"]);
});
test("hides Automations in production mode", () => {
const items = getVisibleNavigation(false);
const names = items.map((i) => i.name);
expect(names).not.toContain("Automations");
expect(names).toContain("Runs");
expect(names).toContain("Settings");
});
});

View file

@ -17,9 +17,9 @@ import { ErrorState } from "../components/state";
import { TooltipProvider } from "../components/ui";
import { DemoModeProvider } from "../lib/demo-mode";
import { useAuthMe } from "../lib/queries";
import { allNavigation, getVisibleNavigation } from "./navigation";
import { navigation } from "./navigation";
function activeFor(item: (typeof allNavigation)[number], pathname: string): boolean {
function activeFor(item: (typeof navigation)[number], pathname: string): boolean {
return pathname.startsWith(item.href);
}
@ -48,13 +48,13 @@ export default function AppShell() {
}
const { user, provider, demoMode } = auth;
const navigation = getVisibleNavigation(demoMode);
const currentNav = navigation.find((item) => activeFor(item, pathname));
const title = currentNav?.name ?? "";
const lastMatch = matches[matches.length - 1];
const handle = lastMatch?.handle as { headerExtra?: React.ReactNode } | undefined;
const headerExtra = handle?.headerExtra;
const hideHeader = matches.some((m) => (m.handle as { hideHeader?: boolean } | undefined)?.hideHeader);
const hideTitle = matches.some((m) => (m.handle as { hideTitle?: boolean } | undefined)?.hideTitle);
const wide = matches.some((m) => (m.handle as { wide?: boolean } | undefined)?.wide);
const fullHeight = matches.some(
(m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight,
@ -236,9 +236,11 @@ export default function AppShell() {
>
<div className={`mx-auto ${maxWidth} px-4 py-4 sm:px-6 lg:px-8`}>
<div className="flex items-center">
<h1 className="text-xl font-semibold tracking-tight text-fg">
{title}
</h1>
{!hideTitle && (
<h1 className="text-xl font-semibold tracking-tight text-fg">
{title}
</h1>
)}
{headerExtra && <div className="ml-auto">{headerExtra}</div>}
</div>
</div>

View file

@ -4,12 +4,8 @@ import {
PlayIcon,
} from "@heroicons/react/24/outline";
export const allNavigation = [
{ name: "Automations", href: "/automations", icon: ClockIcon, demoOnly: true },
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false },
export const navigation = [
{ name: "Automations", href: "/automations", icon: ClockIcon },
{ name: "Runs", href: "/runs", icon: PlayIcon },
{ name: "Settings", href: "/settings", icon: Cog6ToothIcon },
];
export function getVisibleNavigation(demoMode: boolean) {
return allNavigation.filter((item) => !item.demoOnly || demoMode);
}

View file

@ -6,6 +6,7 @@ import axios, {
} from "axios";
import {
AuthApi,
AutomationsApi,
Configuration,
HumanInTheLoopApi,
InsightsApi,
@ -75,6 +76,11 @@ export const authApi = new AuthApi(
"",
generatedAxios,
);
export const automationsApi = new AutomationsApi(
generatedApiConfiguration,
"",
generatedAxios,
);
export const humanInTheLoopApi = new HumanInTheLoopApi(
generatedApiConfiguration,
"",

View file

@ -4,6 +4,8 @@ import type {
AuthConfigResponse,
AuthMeResponse,
AuthSessionsResponse,
Automation,
AutomationListResponse,
BoardColumn,
CommandLogResponse,
EventEnvelope,
@ -42,6 +44,7 @@ import {
apiNullableData,
apiResponse,
authApi,
automationsApi,
fetchAllPages,
fetchAllStageEvents,
generatedAxios,
@ -397,6 +400,33 @@ export function useRunStageLog(
);
}
export function useAutomations() {
return useSWR<AutomationListResponse>(
queryKeys.automations.list(),
() => apiData(() => automationsApi.listAutomations()),
);
}
export function useAutomation(id: string | undefined) {
return useSWR<Automation | null>(
id ? queryKeys.automations.detail(id) : null,
() => apiNullableData(() => automationsApi.retrieveAutomation(id!)),
);
}
export interface AutomationRunsPageOptions {
limit?: number;
offset?: number;
}
export function useAutomationRuns(id: string | undefined, opts: AutomationRunsPageOptions = {}) {
return useSWR<PaginatedRunList | null>(
id ? queryKeys.automations.runs(id, opts) : null,
() => apiNullableData(() => automationsApi.listAutomationRuns(id!, opts.limit, opts.offset)),
{ keepPreviousData: true },
);
}
export function useWorkflows() {
return useSWR<PaginatedWorkflowListResponse | null>(
queryKeys.workflows.list(),

View file

@ -90,6 +90,12 @@ export const queryKeys = {
detail: (name: string) => ["workflows", "detail", name] as const,
runs: (name: string) => ["workflows", "runs", name] as const,
},
automations: {
list: () => ["automations", "list"] as const,
detail: (id: string) => ["automations", "detail", id] as const,
runs: (id: string, opts: { limit?: number; offset?: number } = {}) =>
["automations", "runs", id, opts.limit ?? null, opts.offset ?? null] as const,
},
insights: {
queries: () => ["insights", "queries"] as const,
history: () => ["insights", "history"] as const,

View file

@ -12,10 +12,8 @@ import * as ChatsDetail from "./routes/chats-detail";
import * as AskFabro from "./routes/ask-fabro";
import * as Automations from "./routes/automations";
import * as AutomationsNew from "./routes/automations-new";
import * as AutomationsEdit from "./routes/automations-edit";
import * as AutomationDetail from "./routes/automation-detail";
import * as AutomationDefinition from "./routes/automation-definition";
import * as AutomationDiagram from "./routes/automation-diagram";
import * as AutomationRuns from "./routes/automation-runs";
import * as Runs from "./routes/runs";
import * as RunDetail from "./routes/run-detail";
import * as RunOverview from "./routes/run-overview";
@ -114,13 +112,8 @@ export const routes: RouteObject[] = [
route("ask-fabro", AskFabro),
route("automations", Automations),
route("automations/new", AutomationsNew),
route("automation/:name", AutomationDetail, {
children: [
indexRoute(AutomationDefinition),
route("diagram", AutomationDiagram),
route("runs", AutomationRuns),
],
}),
route("automations/:id/edit", AutomationsEdit),
route("automation/:id", AutomationDetail),
route("runs", Runs),
route("runs/:id", RunDetail, {
children: [

View file

@ -1,34 +0,0 @@
import { useOutletContext, useParams } from "react-router";
import type { BundledLanguage } from "@pierre/diffs";
import { workflowData, type WorkflowEntry } from "./automation-detail";
import { CollapsibleFile } from "../components/collapsible-file";
import { useDotLanguageReady } from "../hooks/use-dot-language-ready";
export default function AutomationDefinition() {
const { name } = useParams();
const context = useOutletContext<{ workflow?: WorkflowEntry } | null>();
const workflow = context?.workflow ?? workflowData[name ?? ""];
const dotReady = useDotLanguageReady();
if (workflow == null) {
return <p className="text-sm text-fg-muted">No settings found.</p>;
}
return (
<div className="flex flex-col gap-6">
<CollapsibleFile
file={{ name: "settings.json", contents: JSON.stringify(workflow.settings, null, 2), lang: "json" }}
defaultOpen={false}
/>
{dotReady && (
<CollapsibleFile
file={{
name: workflow.filename,
contents: workflow.graph,
lang: "dot" as BundledLanguage,
}}
/>
)}
</div>
);
}

View file

@ -1,386 +1,353 @@
import { useCallback, useMemo, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
ClockIcon,
FolderIcon,
MagnifyingGlassIcon,
PlayIcon,
RectangleStackIcon,
} from "@heroicons/react/24/outline";
import type {
EnvironmentSettings,
WorkflowDetailResponse as ApiWorkflowDetail,
WorkflowSettings as WorkflowSettingsSnapshot,
Automation,
ListRunsSortEnum,
} from "@qltysh/fabro-api-client";
import { Link, Outlet, useLocation, useParams } from "react-router";
import { useWorkflow } from "../lib/queries";
export interface WorkflowEntry {
name: string;
slug: string;
description: string;
filename: string;
settings: WorkflowSettingsSnapshot;
graph: string;
}
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { useAutomation, useAutomationRuns } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { useDataUpdatedAt } from "../hooks/use-data-updated-at";
import { useTickingNow } from "../lib/time";
import { formatRelativeTime } from "../lib/format";
import { ColumnPickerButton } from "../components/runs-list/column-picker-button";
import {
hiddenColumnsFromSearchParams,
parseDirection,
parsePage,
parsePageSize,
parseSort,
} from "../components/runs-list/preferences";
import { RunsListView } from "../components/runs-list/runs-list-view";
import {
serializeHiddenColumns,
type ToggleableColumn,
} from "../components/runs-list/toggleable-column";
import { EmptyState, ErrorState } from "../components/state";
import { useToast } from "../components/toast";
import {
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
function sampleSettings({
name,
description,
goal,
inputs,
labels,
snapshot,
autoStopInterval,
cpu,
memoryGb,
diskGb,
prepareCommands = [],
}: {
name: string;
description: string;
goal: string;
inputs: Record<string, unknown>;
labels: Record<string, string>;
snapshot: string;
autoStopInterval: number;
cpu: number;
memoryGb: number;
diskGb: number;
prepareCommands?: string[];
}): WorkflowSettingsSnapshot {
const environmentId = labels.project ?? snapshot;
const environment = {
provider: "daytona",
image: { docker: null, dockerfile: null },
resources: {
cpu,
memory: `${memoryGb}GB`,
disk: `${diskGb}GB`,
},
network: { mode: "allow_all", allow: [] },
lifecycle: {
preserve: false,
stop_on_terminal: true,
auto_stop: `${autoStopInterval}m`,
},
labels,
volumes: [],
env: {},
} satisfies EnvironmentSettings;
return {
project: {
name: null,
description: null,
metadata: {},
},
workflow: {
name,
description,
graph: "",
metadata: {},
},
environments: {
[environmentId]: environment,
},
run: {
goal: { type: "inline", value: goal },
working_dir: null,
metadata: {},
inputs,
model: { provider: null, name: "claude-sonnet", fallbacks: [] },
git: { author: null },
prepare: { commands: prepareCommands, timeout_ms: 120_000 },
execution: { mode: "normal", approval: "prompt" },
checkpoint: { exclude_globs: [], skip_git_hooks: false },
clone: { enabled: true },
run_branch: { enabled: true, push: true },
meta_branch: { enabled: true, push: true },
environment: { id: environmentId, ...environment },
notifications: {},
interviews: { provider: null, slack: null },
agent: { permissions: null, mcps: {} },
hooks: [],
scm: { provider: null, owner: null, repository: null, github: null },
pull_request: null,
artifacts: { include: [] },
integrations: { github: { permissions: {} } },
},
};
}
export const workflowData: Record<string, WorkflowEntry> = {
fix_build: {
name: "Fix Build",
slug: "fix_build",
filename: "fix_build.fabro",
description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.",
settings: sampleSettings({
name: "Fix Build",
description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.",
goal: "Diagnose and fix CI build failures",
inputs: { repo_url: "https://github.com/org/service", branch: "main" },
labels: { project: "fix-build" },
snapshot: "fix-build-dev",
autoStopInterval: 60,
cpu: 4,
memoryGb: 8,
diskGb: 10,
}),
graph: `digraph fix_build {
graph [
goal="Diagnose and fix CI build failures",
label="Fix Build"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
diagnose [label="Diagnose Failure", prompt="@prompts/fix_build/diagnose.md", reasoning_effort="high"]
fix [label="Apply Fix", prompt="@prompts/fix_build/fix.md"]
validate [label="Run Build", prompt="@prompts/fix_build/validate.md", goal_gate=true]
gate [shape=diamond, label="Build passing?"]
start -> diagnose -> fix -> validate -> gate
gate -> exit [label="Yes", condition="outcome=succeeded"]
gate -> diagnose [label="No", condition="outcome!=succeeded", max_visits=3]
}
`,
},
implement: {
name: "Implement Feature",
slug: "implement",
filename: "implement.fabro",
description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.",
settings: sampleSettings({
name: "Implement Feature",
description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.",
goal: "Implement feature from technical blueprint",
inputs: { spec_path: "specs/feature.md", test_framework: "vitest" },
labels: { project: "implement", team: "engineering" },
snapshot: "implement-dev",
autoStopInterval: 120,
cpu: 4,
memoryGb: 8,
diskGb: 20,
prepareCommands: ["bun install", "bun run typecheck"],
}),
graph: `digraph implement {
graph [
goal="",
label="Implement"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
strategy [shape=hexagon, label="Choose decomposition strategy:"]
subgraph cluster_impl {
label="Implementation Loop"
node [fidelity="full", thread_id="impl"]
plan [label="Plan Implementation", prompt="@prompts/implement/plan.md", reasoning_effort="high"]
implement [label="Implement", prompt="@prompts/implement/implement.md"]
review [label="Review", prompt="@prompts/implement/review.md"]
validate [label="Validate", prompt="@prompts/implement/validate.md", goal_gate=true]
fix [label="Fix Failures", prompt="@prompts/implement/fix.md", max_visits=3]
}
start -> strategy
strategy -> plan [label="[L] Layer-by-layer"]
strategy -> plan [label="[F] Feature slice"]
strategy -> plan [label="[P] Embarrassingly parallel"]
strategy -> plan [label="[S] Sequential / linear"]
plan -> implement -> review -> validate
validate -> exit [condition="outcome=succeeded"]
validate -> fix [condition="outcome!=succeeded", label="Fix"]
fix -> validate
}
`,
},
sync_drift: {
name: "Sync Drift",
slug: "sync_drift",
filename: "sync_drift.fabro",
description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.",
settings: sampleSettings({
name: "Sync Drift",
description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.",
goal: "Detect and reconcile configuration drift across environments",
inputs: { source_env: "production", target_env: "staging", drift_threshold: "warn" },
labels: { project: "sync-drift", team: "platform" },
snapshot: "sync-drift-dev",
autoStopInterval: 120,
cpu: 2,
memoryGb: 4,
diskGb: 10,
}),
graph: `digraph sync {
graph [
goal="Detect and resolve drift between product docs, architecture docs, and code",
label="Sync"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
detect [label="Detect Drift", prompt="@prompts/sync/detect.md", reasoning_effort="high"]
propose [label="Propose Changes", prompt="@prompts/sync/propose.md"]
review [shape=hexagon, label="Review Changes"]
apply [label="Apply Changes", prompt="@prompts/sync/apply.md"]
start -> detect
detect -> exit [condition="context.drift_found=false", label="No drift"]
detect -> propose [condition="context.drift_found=true", label="Drift found"]
propose -> review
review -> apply [label="[A] Accept"]
review -> propose [label="[R] Revise"]
apply -> exit
}
`,
},
expand: {
name: "Expand Product",
slug: "expand",
filename: "expand.fabro",
description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.",
settings: sampleSettings({
name: "Expand Product",
description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.",
goal: "Propose and implement incremental product improvements",
inputs: { analytics_window: "30d", min_confidence: "0.8" },
labels: { project: "expand", team: "product" },
snapshot: "expand-dev",
autoStopInterval: 180,
cpu: 2,
memoryGb: 4,
diskGb: 10,
}),
graph: `digraph expand {
graph [
goal="",
label="Expand"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
propose [label="Propose Changes", prompt="@prompts/expand/propose.md", reasoning_effort="high"]
approve [shape=hexagon, label="Approve Changes"]
execute [label="Execute Changes", prompt="@prompts/expand/execute.md"]
start -> propose -> approve
approve -> execute [label="[A] Accept"]
approve -> propose [label="[R] Revise"]
execute -> exit
}
`,
},
};
const tabs = [
{ name: "Definition", path: "" },
{ name: "Diagram", path: "/diagram" },
{ name: "Runs", path: "/runs" },
];
export const handle = { hideHeader: true };
function resolveWorkflow(name: string | undefined, apiWorkflow: ApiWorkflowDetail | null | undefined): WorkflowEntry {
const workflowName = name ?? "";
return apiWorkflow
? {
name: apiWorkflow.name,
slug: apiWorkflow.slug,
description: apiWorkflow.description,
filename: apiWorkflow.filename,
settings: apiWorkflow.settings,
graph: apiWorkflow.graph,
}
: workflowData[workflowName] ?? {
name: workflowName,
slug: workflowName,
description: "",
filename: `${workflowName}.fabro`,
settings: sampleSettings({
name: workflowName,
description: "",
goal: "",
inputs: {},
labels: {},
snapshot: "default",
autoStopInterval: 120,
cpu: 2,
memoryGb: 4,
diskGb: 10,
}),
graph: "",
};
}
export const handle = { hideHeader: true, wide: true };
export function meta({ data }: any) {
const title = data?.workflow?.name ?? "Automation";
const title = data?.automation?.name ?? "Automation";
return [{ title: `${title} — Fabro` }];
}
export default function AutomationDetail() {
const { name } = useParams();
const workflowQuery = useWorkflow(name);
const { pathname } = useLocation();
const workflow = resolveWorkflow(name, workflowQuery.data);
const basePath = `/automation/${name}`;
const { id } = useParams<{ id: string }>();
const automationQuery = useAutomation(id);
if (automationQuery.error) {
return (
<div className="py-12">
<ErrorState
title="Couldn't load this automation"
description="It may have been deleted, or the server returned an error."
/>
</div>
);
}
if (!automationQuery.data) {
return <div className="h-1" />;
}
return (
<div>
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
<Link to="/automations" className="text-fg-3 hover:text-fg">Automations</Link>
<ChevronRightIcon className="size-3" />
<span>{workflow.name}</span>
</nav>
<div className="mb-6 flex items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-fg">{workflow.name}</h2>
<span className="font-mono text-xs text-fg-muted">{workflow.filename}</span>
</div>
<p className="mt-2 max-w-prose text-sm leading-relaxed text-fg-3">{workflow.description}</p>
</div>
<button
type="button"
title="Run automation"
className="flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="size-3.5" aria-hidden="true">
<path fillRule="evenodd" d="M4.5 5.653c0-1.427 1.529-2.33 2.779-1.643l11.54 6.347c1.295.712 1.295 2.573 0 3.286L7.28 19.99c-1.25.687-2.779-.217-2.779-1.643V5.653Z" clipRule="evenodd" />
</svg>
Run
</button>
</div>
<div className="border-b border-line">
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => {
const tabPath = `${basePath}${tab.path}`;
const isActive = pathname === tabPath;
return (
<Link
key={tab.name}
to={tabPath}
className={`border-b-2 pb-3 text-sm font-medium transition-colors ${
isActive
? "border-teal-500 text-fg"
: "border-transparent text-fg-muted hover:border-line-strong hover:text-fg-3"
}`}
>
{tab.name}
</Link>
);
})}
</nav>
</div>
<div className="mt-6">
<Outlet context={{ workflow }} />
</div>
<AutomationHeader automation={automationQuery.data} />
<AutomationRunsList automationId={automationQuery.data.id} />
</div>
);
}
function AutomationHeader({ automation }: { automation: Automation }) {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
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;
async function onRun() {
if (!canRun || running) return;
setRunning(true);
try {
const run = await apiData(() => automationsApi.createAutomationRun(automation.id));
await mutate(
(key) =>
Array.isArray(key) &&
key[0] === "automations" &&
key[1] === "runs" &&
key[2] === automation.id,
);
toast.push({ message: `Started run for “${automation.name}”.` });
navigate(`/runs/${run.id}`);
} catch (cause) {
toast.push({
tone: "error",
message:
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't start a run. Please try again.",
});
setRunning(false);
}
}
return (
<>
<nav className="mb-4 flex items-center gap-1 text-sm text-fg-muted">
<Link to="/automations" className="text-fg-3 hover:text-fg">
Automations
</Link>
<ChevronRightIcon className="size-3" aria-hidden="true" />
<span>{automation.name}</span>
</nav>
<div className="mb-6 flex flex-wrap items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-fg">{automation.name}</h2>
<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>
</Chip>
<Chip icon={RectangleStackIcon}>{automation.target.workflow}</Chip>
{scheduleTrigger ? (
<Chip icon={ClockIcon}>{scheduleTrigger.expression}</Chip>
) : null}
</div>
{automation.description ? (
<p className="mt-3 max-w-prose text-sm leading-relaxed text-fg-3">
{automation.description}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
to={`/automations/${automation.id}/edit`}
className={SECONDARY_BUTTON_CLASS}
>
Edit
</Link>
<button
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
}
className={PRIMARY_BUTTON_CLASS}
>
<PlayIcon className="size-4" aria-hidden="true" />
{running ? "Starting…" : "Run"}
</button>
</div>
</div>
</>
);
}
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,
}: {
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<span className="flex items-center gap-1.5 font-mono text-xs text-fg-muted">
<Icon className="size-3.5" aria-hidden="true" />
{children}
</span>
);
}
function AutomationRunsList({ automationId }: { automationId: string }) {
const [urlSearchParams, setSearchParams] = useSearchParams();
const query = urlSearchParams.get("search") ?? "";
const sort = parseSort(urlSearchParams.get("sort"));
const direction = parseDirection(urlSearchParams.get("direction"));
const page = parsePage(urlSearchParams.get("page"));
const pageSize = parsePageSize(urlSearchParams.get("size"));
const hiddenColumns = useMemo(
() => hiddenColumnsFromSearchParams(urlSearchParams),
[urlSearchParams],
);
const updateParams = useCallback(
(updater: (params: URLSearchParams) => void) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
updater(next);
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const setQuery = (value: string) =>
updateParams((p) => {
if (value) p.set("search", value);
else p.delete("search");
p.delete("page");
});
const setPage = (next: number) =>
updateParams((p) => {
if (next > 1) p.set("page", String(next));
else p.delete("page");
});
const setPageSize = (next: number) =>
updateParams((p) => {
p.set("size", String(next));
p.delete("page");
});
const setHiddenColumns = (next: Set<ToggleableColumn>) =>
updateParams((p) => {
const serialized = serializeHiddenColumns(next);
if (serialized) p.set("hide", serialized);
else p.set("hide", "");
});
const onSortClick = (key: ListRunsSortEnum) =>
updateParams((p) => {
if (sort === key) {
p.set("direction", direction === "asc" ? "desc" : "asc");
} else {
p.set("sort", key);
p.set("direction", "desc");
}
p.delete("page");
});
const runsQuery = useAutomationRuns(automationId, {
limit: pageSize,
offset: (page - 1) * pageSize,
});
const now = useTickingNow(true, 15_000);
const updatedAt = useDataUpdatedAt(runsQuery.data);
const handleRefresh = useCallback(() => {
void runsQuery.mutate();
}, [runsQuery]);
const apiError = runsQuery.error instanceof ApiError ? runsQuery.error : null;
if (apiError && !runsQuery.data) {
return (
<ErrorState
title="Couldn't load runs"
description={`Server returned ${apiError.status}.`}
onRetry={handleRefresh}
/>
);
}
const lowerQuery = query.toLowerCase();
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-64">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<input
type="text"
name="search"
aria-label="Search runs"
placeholder="Search runs…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
/>
</div>
<div className="ml-auto flex items-center gap-3">
{updatedAt != null ? (
<span className="font-mono text-xs text-fg-muted">
Updated {formatRelativeTime(new Date(updatedAt).toISOString(), now)}
</span>
) : null}
<button
type="button"
onClick={handleRefresh}
disabled={runsQuery.isValidating}
aria-label={runsQuery.isValidating ? "Refreshing runs" : "Refresh runs"}
title="Refresh"
className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3"
>
<ArrowPathIcon
className={`size-4 ${runsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
aria-hidden="true"
/>
</button>
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
</div>
</div>
<RunsListView
data={runsQuery.data ?? undefined}
isLoading={runsQuery.data == null && runsQuery.isLoading}
emptyState={
<EmptyState
title="No runs yet"
description="When this automation runs, the runs will appear here."
/>
}
sort={sort}
direction={direction}
page={page}
pageSize={pageSize}
hiddenColumns={hiddenColumns}
onSortClick={onSortClick}
onPageChange={setPage}
onPageSizeChange={setPageSize}
query={lowerQuery}
repoFilter="all"
workflowFilter="all"
createdCutoffMs={null}
/>
</div>
);
}

View file

@ -1,211 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
import { graphTheme } from "../lib/graph-theme";
import { useRenderedVizDiagram } from "../hooks/use-rendered-viz-diagram";
type Direction = "LR" | "TB";
function buildDot(direction: Direction) {
return `digraph fix_build {
graph [
label="Fix Build"
]
rankdir=${direction}
bgcolor="transparent"
pad=0.5
node [
fontname="ui-monospace, monospace"
fontsize=11
fontcolor="${graphTheme.nodeText}"
color="${graphTheme.edgeColor}"
fillcolor="${graphTheme.nodeFill}"
style=filled
penwidth=1.2
]
edge [
fontname="ui-monospace, monospace"
fontsize=9
fontcolor="${graphTheme.fontcolor}"
color="${graphTheme.edgeColor}"
arrowsize=0.7
penwidth=1.2
]
start [shape=Mdiamond, label="Start", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
exit [shape=Msquare, label="Exit", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"]
analyze [label="Analyze\\nBuild Errors"]
diagnose [label="Diagnose\\nRoot Cause"]
fix [label="Apply\\nFix"]
validate [label="Validate\\nBuild"]
approve [shape=hexagon, label="Review\\nChanges", fillcolor="${graphTheme.gateFill}", color="${graphTheme.gateBorder}", fontcolor="${graphTheme.gateText}"]
start -> analyze
analyze -> diagnose
diagnose -> fix
fix -> validate
validate -> exit [label="Pass"]
validate -> diagnose [label="Fail", style=dashed, color="#f87171"]
validate -> approve [label="Needs review", color="${graphTheme.gateBorder}"]
approve -> exit [label="Accept"]
approve -> fix [label="Revise", style=dashed]
}`;
}
function stripGraphTitle(svg: SVGSVGElement) {
const title = svg.querySelector(".graph > title");
if (!title) return;
let sibling = title.nextElementSibling;
while (sibling && sibling.tagName === "text") {
const next = sibling.nextElementSibling;
sibling.remove();
sibling = next;
}
title.remove();
}
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
const DEFAULT_ZOOM_INDEX = 2; // 75%
export default function AutomationDiagram() {
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
const [direction, setDirection] = useState<Direction>("LR");
const [pan, setPan] = useState({ x: 0, y: 0 });
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
const zoom = ZOOM_STEPS[zoomIndex];
const resetPan = useCallback(() => setPan({ x: 0, y: 0 }), []);
const error = useRenderedVizDiagram({
buildDot,
identity: direction,
innerRef,
onRenderStart: resetPan,
prepareSvg: stripGraphTitle,
svgRef,
});
const onPointerDown = useCallback((e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y };
}, [pan]);
const onPointerMove = useCallback((e: React.PointerEvent) => {
const drag = dragState.current;
if (!drag) return;
setPan({
x: drag.startPanX + e.clientX - drag.startX,
y: drag.startPanY + e.clientY - drag.startY,
});
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
}, []);
const fitToWindow = useCallback(() => {
const svg = svgRef.current;
const container = containerRef.current;
if (!svg || !container) return;
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
const padPx = 48; // p-6 on each side
const containerW = container.clientWidth - padPx;
const containerH = container.clientHeight - padPx;
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
// Pick the largest zoom step that fits
let best = 0;
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
if (ZOOM_STEPS[i] <= fitPct) { best = i; break; }
}
setZoomIndex(best);
setPan({ x: 0, y: 0 });
}, []);
if (error) {
return <p className="text-sm text-coral">{error}</p>;
}
return (
<div className="relative rounded-md border border-line bg-panel-alt/40">
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Left to right"
onClick={() => setDirection("LR")}
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
>
<ArrowRightIcon className="size-3.5" />
</button>
<button
type="button"
title="Top to bottom"
onClick={() => setDirection("TB")}
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
>
<ArrowDownIcon className="size-3.5" />
</button>
</div>
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Fit to window"
aria-label="Fit diagram to window"
onClick={fitToWindow}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
>
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
</svg>
</button>
</div>
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Zoom out"
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
disabled={zoomIndex === 0}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
>
<MinusIcon className="size-4" />
</button>
<button
type="button"
title="Zoom in"
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
disabled={zoomIndex === ZOOM_STEPS.length - 1}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
>
<PlusIcon className="size-4" />
</button>
</div>
</div>
<div
ref={containerRef}
className="overflow-hidden p-6"
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
ref={innerRef}
className="flex items-center justify-center"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
>
<p className="text-sm text-fg-muted">Loading diagram&hellip;</p>
</div>
</div>
</div>
);
}

View file

@ -1,122 +0,0 @@
import { useState } from "react";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Link, useParams } from "react-router";
import { InlineMarkdown } from "../components/inline-markdown";
import { PullRequestChip } from "../components/pull-request-chip";
import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunToRunItem } from "../data/runs";
import type { RunWithStatus } from "../data/runs";
import { useWorkflowRuns } from "../lib/queries";
import type { BoardColumn, PaginatedRunList } from "@qltysh/fabro-api-client";
function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithStatus[] {
const apiRuns = result?.data ?? [];
return apiRuns
.map((r) => {
const column = columnForRun(r);
if (column == null) return null;
return {
...mapRunToRunItem(r),
status: column,
statusLabel: columnStatusDisplay[column].label,
};
})
.filter((run): run is RunWithStatus => run != null);
}
function RunRow({ run }: { run: RunWithStatus }) {
const colors = columnStatusDisplay[run.status];
return (
<div className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<Link to={`/runs/${run.id}`} className="contents">
<span className="flex items-center gap-2 pr-2">
<span className={`size-2 shrink-0 rounded-full ${colors.dot}`} />
<span className={`text-xs font-medium ${colors.text}`}>{run.statusLabel}</span>
</span>
<span className="font-mono text-xs pr-2 text-fg-muted">
{run.elapsed}
</span>
<span className="flex items-center gap-2 min-w-0">
<InlineMarkdown content={run.title} className="truncate text-sm text-fg-2" />
{run.comments != null && run.comments > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-fg-muted">
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
<path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
</svg>
{run.comments}
</span>
)}
</span>
<span className="flex items-center justify-end gap-2 pr-4 font-mono text-xs tabular-nums">
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}
{run.deletions != null && <span className="text-coral">-{run.deletions.toLocaleString()}</span>}
</span>
</Link>
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
{run.pullRequestUrl && run.number != null && (
<PullRequestChip number={run.number} url={run.pullRequestUrl}>
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</PullRequestChip>
)}
</span>
</div>
);
}
export default function AutomationRuns() {
const { name } = useParams();
const runsQuery = useWorkflowRuns(name);
const runs = mapWorkflowRuns(runsQuery.data);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<BoardColumn | "all">("all");
const filtered = runs.filter(
(r) =>
(statusFilter === "all" || r.status === statusFilter) &&
(r.title.toLowerCase().includes(query.toLowerCase()) ||
r.statusLabel.toLowerCase().includes(query.toLowerCase()) ||
(r.number != null && `#${r.number}`.includes(query))),
);
return (
<div className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<input
type="text"
aria-label="Search runs"
placeholder="Search runs..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
/>
</div>
<div className="relative">
<select
aria-label="Run status filter"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as BoardColumn | "all")}
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
>
<option value="all">All statuses</option>
{(Object.entries(columnStatusDisplay) as [BoardColumn, { label: string }][]).map(([id, { label }]) => (
<option key={id} value={id}>{label}</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
</div>
</div>
<div className="grid gap-2" style={{ gridTemplateColumns: "auto 5rem 1fr 8rem auto" }}>
{filtered.map((run) => (
<RunRow key={run.id} run={run} />
))}
{filtered.length === 0 && (
<p className="py-8 text-center text-sm text-fg-muted">No runs match "{query}"</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,150 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import type { Automation } from "@qltysh/fabro-api-client";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { useAutomation } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import {
AutomationFormFields,
automationToFormValues,
isFormValid,
triggersFromFormValues,
type AutomationFormValues,
} from "../components/automation-form";
import { Panel, PanelSkeleton } from "../components/settings-panel";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
import { useToast } from "../components/toast";
export function meta() {
return [{ title: "Edit automation — Fabro" }];
}
export const handle = { hideHeader: true };
export default function AutomationsEdit() {
const { id } = useParams<{ id: string }>();
const query = useAutomation(id);
return (
<div className="space-y-6">
<PageHeader id={id ?? ""} name={query.data?.name} />
{query.data ? (
<EditAutomationForm key={query.data.id} automation={query.data} />
) : query.error ? (
<Panel title="Automation">
<div className="px-4 py-6 text-sm text-fg-2">
Couldn&apos;t load this automation. It may have been deleted.
</div>
</Panel>
) : (
<PanelSkeleton />
)}
</div>
);
}
function PageHeader({ id, name }: { id: string; name: string | undefined }) {
return (
<nav className="flex items-center gap-1 text-sm text-fg-muted">
<Link to="/automations" className="text-fg-3 hover:text-fg">
Automations
</Link>
<ChevronRightIcon className="size-3" aria-hidden="true" />
<span>{name ?? id}</span>
</nav>
);
}
function EditAutomationForm({ automation }: { automation: Automation }) {
const navigate = useNavigate();
const { mutate } = useSWRConfig();
const toast = useToast();
const [values, setValues] = useState<AutomationFormValues>(
automationToFormValues(automation),
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSubmit = isFormValid(values) && !submitting;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
const trimmedName = values.name.trim();
try {
await apiData(() =>
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(),
workflow: values.workflow.trim(),
},
triggers: triggersFromFormValues(values),
}),
);
await mutate(queryKeys.automations.list());
await mutate(queryKeys.automations.detail(automation.id));
toast.push({ message: `Automation “${trimmedName}” updated.` });
navigate("/automations");
} catch (cause) {
setError(
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't update the automation. Please try again.",
);
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<AutomationFormFields values={values} onChange={setValues} lockIdAndTarget />
{error ? <ErrorMessage message={error} /> : null}
<FormFooter
submitting={submitting}
canSubmit={canSubmit}
onCancel={() => navigate("/automations")}
/>
</form>
);
}
function FormFooter({
submitting,
canSubmit,
onCancel,
}: {
submitting: boolean;
canSubmit: boolean;
onCancel: () => void;
}) {
return (
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
{submitting ? "Saving…" : "Save changes"}
</button>
</div>
);
}

View file

@ -1,14 +1,23 @@
import { useRef, useState, type ReactNode } from "react";
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { Switch } from "@headlessui/react";
import { ChevronRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
import { useSWRConfig } from "swr";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { Panel, Row } from "../components/settings-panel";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import {
INPUT_CLASS,
AutomationFormFields,
EMPTY_AUTOMATION_FORM,
isFormValid,
triggersFromFormValues,
type AutomationFormValues,
} from "../components/automation-form";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
SECONDARY_BUTTON_CLASS,
} from "../components/ui";
import { useToast } from "../components/toast";
export function meta() {
return [{ title: "New automation — Fabro" }];
@ -16,225 +25,63 @@ export function meta() {
export const handle = { hideHeader: true };
const SAMPLE_REPOSITORIES = [
"qltysh/fabro",
"qltysh/fabro-cloud",
"acme/orders-api",
"acme/billing",
"acme/web",
];
const CRON_PRESETS: ReadonlyArray<{ label: string; value: string }> = [
{ label: "Every hour", value: "0 * * * *" },
{ label: "Daily 9:00 UTC", value: "0 9 * * *" },
{ label: "Weekdays 9:00", value: "0 9 * * 1-5" },
{ label: "Mondays 8:00", value: "0 8 * * 1" },
];
export default function AutomationsNew() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const slugTouchedRef = useRef(false);
const [description, setDescription] = useState("");
const [repository, setRepository] = useState(SAMPLE_REPOSITORIES[0]);
const [branch, setBranch] = useState("main");
const [workflowSlug, setWorkflowSlug] = useState("");
const [goal, setGoal] = useState("");
const [manualEnabled, setManualEnabled] = useState(true);
const [scheduleEnabled, setScheduleEnabled] = useState(false);
const [cron, setCron] = useState("0 9 * * 1-5");
const { mutate } = useSWRConfig();
const toast = useToast();
const [values, setValues] = useState<AutomationFormValues>(EMPTY_AUTOMATION_FORM);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function onNameChange(next: string) {
setName(next);
if (!slugTouchedRef.current) setSlug(kebabify(next));
}
const canSubmit = isFormValid(values) && !submitting;
function onSlugChange(next: string) {
setSlug(kebabify(next));
slugTouchedRef.current = true;
}
const canSubmit =
name.trim() !== "" &&
slug.trim() !== "" &&
workflowSlug.trim() !== "" &&
(manualEnabled || scheduleEnabled);
function onSubmit(event: React.FormEvent) {
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!canSubmit) return;
// Non-functional prototype — just return to the list.
navigate("/automations");
setSubmitting(true);
setError(null);
const trimmedName = values.name.trim();
try {
await apiData(() =>
automationsApi.createAutomation({
id: values.id.trim(),
name: trimmedName,
description: values.description.trim() || null,
enabled: values.enabled,
target: {
repository: values.repository.trim(),
ref: values.ref.trim(),
workflow: values.workflow.trim(),
},
triggers: triggersFromFormValues(values),
}),
);
await mutate(queryKeys.automations.list());
toast.push({ message: `Automation “${trimmedName}” created.` });
navigate("/automations");
} catch (cause) {
setError(
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't create the automation. Please try again.",
);
setSubmitting(false);
}
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<PageHeader />
<Panel title="Basics">
<Row title={<Label required>Name</Label>} help="Shown wherever this automation is listed.">
<input
type="text"
name="name"
aria-label="Automation name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="Fix Build"
autoComplete="off"
className={INPUT_CLASS}
/>
</Row>
<Row
title={<Label required>Slug</Label>}
help={
<>
Identifier used in the URL: <span className="font-mono text-fg-2">/automation/{slug || "<slug>"}</span>
</>
}
>
<input
type="text"
name="slug"
aria-label="Automation slug"
value={slug}
onChange={(e) => onSlugChange(e.target.value)}
placeholder="fix-build"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row title={<Label optional>Description</Label>} help="A short summary teammates will see when browsing automations.">
<textarea
name="description"
aria-label="Automation description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="Diagnose and fix CI build failures by analyzing logs and applying targeted patches."
className={`${INPUT_CLASS} resize-y`}
/>
</Row>
</Panel>
<AutomationFormFields values={values} onChange={setValues} />
<Panel title="Source">
<Row title={<Label required>Repository</Label>} help="The repository this automation will check out and operate on.">
<SelectInput
name="repository"
label="Repository"
value={repository}
onChange={setRepository}
options={SAMPLE_REPOSITORIES}
/>
</Row>
<Row title={<Label required>Branch</Label>} help="Default branch to run against.">
<input
type="text"
name="branch"
aria-label="Default branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
<Row
title={<Label required>Workflow slug</Label>}
help="Snake-case identifier used in the workflow file name (e.g. fix_build.fabro)."
>
<input
type="text"
name="workflow_slug"
aria-label="Workflow slug"
value={workflowSlug}
onChange={(e) => setWorkflowSlug(snakeify(e.target.value))}
placeholder="fix_build"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
</Row>
</Panel>
{error ? <ErrorMessage message={error} /> : null}
<Panel title="Goal">
<Row title={<Label optional>Goal</Label>} help="Plain-English objective the agent uses to decide when the workflow has succeeded.">
<textarea
name="goal"
aria-label="Automation goal"
value={goal}
onChange={(e) => setGoal(e.target.value)}
rows={3}
placeholder="Diagnose and fix the failing build so CI is green on the target branch."
className={`${INPUT_CLASS} resize-y`}
/>
</Row>
</Panel>
<Panel title="Triggers">
<Row title="Manual / API" help="Start a run by clicking Run in the UI or calling the API.">
<ToggleSwitch
checked={manualEnabled}
onChange={setManualEnabled}
label="Enable manual and API triggers"
/>
</Row>
<Row title="Schedule" help="Start runs automatically on a recurring cron schedule.">
<ToggleSwitch
checked={scheduleEnabled}
onChange={setScheduleEnabled}
label="Enable scheduled triggers"
/>
</Row>
{scheduleEnabled ? (
<Row
title="Cron expression"
help={
<>
Five-field POSIX cron in UTC. Next run: <span className="text-fg-2">{describeCron(cron)}</span>
</>
}
>
<div className="space-y-2">
<input
type="text"
name="cron"
aria-label="Cron expression"
value={cron}
onChange={(e) => setCron(e.target.value)}
placeholder="0 9 * * 1-5"
autoComplete="off"
spellCheck={false}
className={`${INPUT_CLASS} font-mono`}
/>
<div className="flex flex-wrap gap-1.5">
{CRON_PRESETS.map((preset) => {
const active = preset.value === cron;
return (
<button
key={preset.value}
type="button"
onClick={() => setCron(preset.value)}
aria-pressed={active}
className={`rounded-full px-2.5 py-1 text-xs transition-colors ${
active
? "bg-teal-500/15 text-teal-300 outline-1 -outline-offset-1 outline-teal-500/40"
: "bg-overlay text-fg-3 hover:bg-overlay-strong hover:text-fg-2"
}`}
>
{preset.label}
</button>
);
})}
</div>
</div>
</Row>
) : null}
</Panel>
<FormFooter canSubmit={canSubmit} onCancel={() => navigate("/automations")} />
<FormFooter
submitting={submitting}
canSubmit={canSubmit}
onCancel={() => navigate("/automations")}
/>
</form>
);
}
@ -258,126 +105,28 @@ function PageHeader() {
);
}
function Label({
children,
required,
optional,
}: {
children: ReactNode;
required?: boolean;
optional?: boolean;
}) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span>{children}</span>
{required ? (
<span aria-label="required" className="text-coral">
*
</span>
) : null}
{optional ? <span className="text-xs font-normal text-fg-muted">Optional</span> : null}
</span>
);
}
function SelectInput({
name,
label,
value,
onChange,
options,
}: {
name: string;
label: string;
value: string;
onChange: (next: string) => void;
options: ReadonlyArray<string>;
}) {
return (
<div className="relative">
<select
name={name}
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${INPUT_CLASS} appearance-none pr-9 font-mono`}
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<ChevronDownIcon
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted"
aria-hidden="true"
/>
</div>
);
}
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
}) {
return (
<Switch
checked={checked}
onChange={onChange}
aria-label={label}
className="group relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-overlay-strong outline-1 -outline-offset-1 outline-line-strong transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 data-checked:bg-teal-500"
>
<span className="pointer-events-none inline-block size-4 translate-x-0.5 rounded-full bg-fg shadow-sm transition-transform duration-150 group-data-checked:translate-x-[1.125rem]" />
</Switch>
);
}
function FormFooter({
submitting,
canSubmit,
onCancel,
}: {
submitting: boolean;
canSubmit: boolean;
onCancel: () => void;
}) {
return (
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onCancel} className={SECONDARY_BUTTON_CLASS}>
<button
type="button"
onClick={onCancel}
disabled={submitting}
className={SECONDARY_BUTTON_CLASS}
>
Cancel
</button>
<button type="submit" disabled={!canSubmit} className={PRIMARY_BUTTON_CLASS}>
Create automation
{submitting ? "Creating…" : "Create automation"}
</button>
</div>
);
}
function kebabify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function snakeify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
}
function describeCron(expression: string): string {
const trimmed = expression.trim();
const preset = CRON_PRESETS.find((p) => p.value === trimmed);
if (preset) return preset.label;
if (!/^[\d*/,\-\s]+$/.test(trimmed) || trimmed.split(/\s+/).length !== 5) {
return "Waiting for a valid expression…";
}
return "Computed when saved";
}

View file

@ -1,8 +1,10 @@
import { useState, type ComponentType } from "react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/20/solid";
import { ChevronDownIcon as ChevronDownOutline } from "@heroicons/react/24/outline";
import { useSWRConfig } from "swr";
import { PlusIcon } from "@heroicons/react/20/solid";
import { EllipsisVerticalIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
ArrowsRightLeftIcon,
ClockIcon,
CodeBracketIcon,
@ -12,68 +14,42 @@ import {
ShieldCheckIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
import type { PaginatedWorkflowListResponse } from "@qltysh/fabro-api-client";
import { Link } from "react-router";
import { useWorkflows } from "../lib/queries";
import { timeAgo, timeUntil } from "../lib/time";
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 { useAutomations } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { ConfirmDialog } from "../components/ui";
import { useToast } from "../components/toast";
export function meta({}: any) {
return [{ title: "Automations — Fabro" }];
}
export const handle = {
headerExtra: (
<div className="relative inline-flex rounded-md">
<Link
to="/automations/new"
className="inline-flex items-center gap-1.5 rounded-l-md border border-r-0 border-mint/20 px-3 py-1.5 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
>
<PlusIcon className="size-3.5" aria-hidden="true" />
Create Automation
</Link>
<Menu as="div" className="relative -ml-px flex">
<MenuButton className="inline-flex items-center rounded-r-md border border-mint/20 px-1.5 text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg">
<ChevronDownIcon className="size-3.5" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
className="absolute right-0 top-full z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<button
type="button"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
Import from file
</button>
</MenuItem>
<MenuItem>
<button
type="button"
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
>
Duplicate existing
</button>
</MenuItem>
</MenuItems>
</Menu>
</div>
),
};
export const handle = { hideHeader: true };
interface Workflow {
name: string;
slug: string;
filename: string;
lastRun: string;
icon: ComponentType<{ className?: string }>;
color: string;
schedule?: string;
nextRun?: string;
function CreateAutomationButton() {
return (
<Link
to="/automations/new"
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-mint/20 px-3 py-2 text-sm font-medium text-mint transition-colors hover:border-mint/50 hover:bg-mint/10 hover:text-fg"
>
<PlusIcon className="size-3.5" aria-hidden="true" />
Create Automation
</Link>
);
}
function getSlugIcon(slug: string): ComponentType<{ className?: string }> {
return slugIconMap[slug] ?? CodeBracketIcon;
interface AutomationRow {
id: string;
revision: string;
name: string;
workflow: string;
repository: string;
schedule?: string;
icon: ComponentType<{ className?: string }>;
color: string;
}
const slugIconMap: Record<string, ComponentType<{ className?: string }>> = {
@ -94,34 +70,28 @@ const slugColorMap: Record<string, string> = {
dep_audit: "var(--color-amber)",
};
interface WorkflowData {
name: string;
slug: string;
filename: string;
lastRun: string;
color: string;
schedule?: string;
nextRun?: string;
const MENU_ITEM_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
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 mapWorkflows(result: PaginatedWorkflowListResponse | null | undefined) {
const apiWorkflows = result?.data ?? [];
const workflows: WorkflowData[] = apiWorkflows.map((w) => ({
name: w.name,
slug: w.slug,
filename: w.filename,
lastRun: w.last_run?.ran_at ? timeAgo(w.last_run.ran_at) : "never",
color: slugColorMap[w.slug] ?? "var(--color-teal-500)",
schedule: w.schedule?.expression,
nextRun: w.schedule?.next_run ? timeUntil(w.schedule.next_run) : undefined,
}));
return workflows;
}
function enrichWorkflows(data: WorkflowData[]): Workflow[] {
return data.map((w) => ({
...w,
icon: getSlugIcon(w.slug),
function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
const automations = result?.data ?? [];
return automations.map((a) => ({
id: a.id,
revision: a.revision,
name: a.name,
workflow: a.target.workflow,
repository: a.target.repository,
schedule: scheduleFor(a),
icon: slugIconMap[a.target.workflow] ?? CodeBracketIcon,
color: slugColorMap[a.target.workflow] ?? "var(--color-teal-500)",
}));
}
@ -133,44 +103,46 @@ function PlayIcon({ className }: { className?: string }) {
);
}
function EllipsisIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path fillRule="evenodd" d="M10.5 12a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm6 0a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm-12 0a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Z" clipRule="evenodd" />
</svg>
);
}
function WorkflowCard({ workflow }: { workflow: Workflow }) {
const Icon = workflow.icon;
function AutomationCard({
automation,
disabled,
running,
onRun,
onDelete,
}: {
automation: AutomationRow;
disabled: boolean;
running: boolean;
onRun: () => void;
onDelete: () => void;
}) {
const Icon = automation.icon;
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={`/automation/${workflow.slug}`} className="flex min-w-0 flex-1 items-center gap-4">
<Link to={`/automation/${automation.id}`} className="flex min-w-0 flex-1 items-center gap-4">
<div
className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-panel-alt/60"
style={{ borderColor: `color-mix(in srgb, ${workflow.color} 20%, transparent)`, color: workflow.color }}
style={{ borderColor: `color-mix(in srgb, ${automation.color} 20%, transparent)`, color: automation.color }}
>
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-fg-2 group-hover:text-fg">{workflow.name}</span>
<span className="font-mono text-xs text-fg-muted">{workflow.filename}</span>
{workflow.schedule && (
<span className="text-sm font-medium text-fg-2 group-hover:text-fg">{automation.name}</span>
<span className="font-mono text-xs text-fg-muted">{automation.workflow}</span>
{automation.schedule && (
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/10 border border-teal-500/20 px-2 py-0.5 text-[11px] font-medium text-teal-300">
<ClockIcon className="size-3" />
{workflow.schedule}
{automation.schedule}
</span>
)}
</div>
<p className="mt-1 text-xs text-fg-muted">
{workflow.nextRun ?? `Last run ${workflow.lastRun}`}
</p>
<p className="mt-1 text-xs text-fg-muted">{automation.repository}</p>
</div>
</Link>
{workflow.schedule ? (
{automation.schedule ? (
<button
type="button"
title="Pause schedule"
@ -181,76 +153,203 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
) : (
<button
type="button"
title="Run automation"
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"
onClick={onRun}
disabled={running || disabled}
aria-label={running ? "Starting run…" : "Run automation"}
title={running ? "Starting run…" : "Run automation"}
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"
>
<PlayIcon className="size-3.5" />
{running ? (
<ArrowPathIcon className="size-3.5 animate-spin [animation-duration:450ms]" aria-hidden="true" />
) : (
<PlayIcon className="size-3.5" />
)}
</button>
)}
<button
type="button"
title="Actions"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
>
<EllipsisIcon className="size-5" />
</button>
<RowMenu automation={automation} disabled={disabled} onDelete={onDelete} />
</div>
);
}
function RowMenu({
automation,
disabled,
onDelete,
}: {
automation: AutomationRow;
disabled: boolean;
onDelete: () => void;
}) {
return (
<Menu as="div" className="relative inline-block">
<MenuButton
type="button"
disabled={disabled}
aria-label={`Actions for ${automation.name}`}
title="Actions"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:cursor-not-allowed disabled:opacity-60"
>
<EllipsisVerticalIcon className="size-5" aria-hidden="true" />
</MenuButton>
<MenuItems
transition
anchor={{ to: "bottom end", gap: 4 }}
className="z-30 w-36 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Link
to={`/automations/${encodeURIComponent(automation.id)}/edit`}
className={MENU_ITEM_CLASS}
>
Edit
</Link>
</MenuItem>
<hr className="my-1 h-px border-0 bg-line" />
<MenuItem>
<button
type="button"
onClick={onDelete}
disabled={disabled}
className={MENU_ITEM_DANGER_CLASS}
>
Delete
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}
type TriggerFilter = "all" | "scheduled" | "manual";
const TRIGGER_FILTER_OPTIONS: { value: TriggerFilter; label: string }[] = [
{ value: "all", label: "All triggers" },
{ value: "scheduled", label: "Scheduled" },
{ value: "manual", label: "Manual" },
];
export default function Automations() {
const workflowsQuery = useWorkflows();
const workflows = enrichWorkflows(mapWorkflows(workflowsQuery.data));
const { mutate } = useSWRConfig();
const toast = useToast();
const navigate = useNavigate();
const automationsQuery = useAutomations();
const automations = mapAutomations(automationsQuery.data);
const [query, setQuery] = useState("");
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");
const filtered = workflows.filter(
(w) =>
const [pendingDelete, setPendingDelete] = useState<AutomationRow | null>(null);
const [deleting, setDeleting] = useState(false);
const [runningId, setRunningId] = useState<string | null>(null);
async function runAutomation(automation: AutomationRow) {
if (runningId) return;
setRunningId(automation.id);
try {
const run = await apiData(() => automationsApi.createAutomationRun(automation.id));
toast.push({ message: `Started run for “${automation.name}”.` });
navigate(`/runs/${run.id}`);
} catch (cause) {
toast.push({
tone: "error",
message:
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't start a run. Please try again.",
});
setRunningId(null);
}
}
const filtered = automations.filter(
(a) =>
(triggerFilter === "all" ||
(triggerFilter === "scheduled" && w.schedule != null) ||
(triggerFilter === "manual" && w.schedule == null)) &&
(w.name.toLowerCase().includes(query.toLowerCase()) ||
w.filename.toLowerCase().includes(query.toLowerCase())),
(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())),
);
async function confirmDelete() {
if (!pendingDelete) return;
const { id, revision, name } = pendingDelete;
setDeleting(true);
try {
await apiData(() => automationsApi.deleteAutomation(id, revision));
await mutate(queryKeys.automations.list());
toast.push({ message: `Automation “${name}” deleted.` });
setPendingDelete(null);
} catch (cause) {
toast.push({
tone: "error",
message:
cause instanceof ApiError && cause.message
? cause.message
: "Couldn't delete the automation. Please try again.",
});
} finally {
setDeleting(false);
}
}
return (
<div className="space-y-4">
<div className="flex gap-3">
<div className="relative flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-64">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<input
type="text"
aria-label="Search automations"
placeholder="Search automations..."
placeholder="Search automations…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
/>
</div>
<div className="relative">
<select
aria-label="Automation trigger filter"
value={triggerFilter}
onChange={(e) => setTriggerFilter(e.target.value as TriggerFilter)}
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
>
<option value="all">All triggers</option>
<option value="scheduled">Scheduled</option>
<option value="manual">Manual</option>
</select>
<ChevronDownOutline className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<FilterButton<TriggerFilter>
label="Trigger"
value={triggerFilter}
allValue="all"
options={TRIGGER_FILTER_OPTIONS}
onChange={(next) => setTriggerFilter(next)}
/>
<div className="ml-auto">
<CreateAutomationButton />
</div>
</div>
<div className="space-y-3">
{filtered.map((workflow) => (
<WorkflowCard key={workflow.filename} workflow={workflow} />
{filtered.map((automation) => (
<AutomationCard
key={automation.id}
automation={automation}
disabled={deleting || (runningId !== null && runningId !== automation.id)}
running={runningId === automation.id}
onRun={() => runAutomation(automation)}
onDelete={() => setPendingDelete(automation)}
/>
))}
{filtered.length === 0 && (
<p className="py-8 text-center text-sm text-fg-muted">No automations match "{query}"</p>
)}
</div>
<ConfirmDialog
open={pendingDelete !== null}
title="Delete automation"
description={
<>
Delete{" "}
<span className="font-mono text-fg-2">{pendingDelete?.name}</span>? This
removes the automation and stops any scheduled triggers. Existing runs are not affected.
</>
}
confirmLabel="Delete"
pendingLabel="Deleting…"
pending={deleting}
onConfirm={confirmDelete}
onCancel={() => {
if (!deleting) setPendingDelete(null);
}}
/>
</div>
);
}

View file

@ -15,6 +15,7 @@ use super::super::{
ApiError, AppState, IntoResponse, Json, PaginationParams, Path, RequiredUser, Response, Router,
State, StatusCode, get, paginate_items,
};
use super::lifecycle;
use super::runs;
use crate::automation_materializer::AutomationRunMaterializeInput;
use crate::principal_middleware::RequiredRunToolActor;
@ -167,19 +168,37 @@ async fn create_automation_run(
trigger_id: Some(api_trigger_id),
};
Box::pin(runs::create_run_from_manifest(
state,
let response = Box::pin(runs::create_run_from_manifest(
Arc::clone(&state),
runs::CreateRunFromManifestRequest {
manifest: materialized.manifest,
submitted_manifest_bytes: materialized.submitted_manifest_bytes,
explicit_run_id: Some(run_id),
explicit_title_supplied,
actor,
actor: actor.clone(),
headers,
automation: Some(automation_ref),
},
))
.await
.await;
// An automation's API trigger should both create and start the run; otherwise
// the run sits in `Submitted` forever because the scheduler only claims
// `Runnable`. Mirror what the UI does for a manual create-then-start flow.
if response.status().is_success() {
if let Err(err) =
lifecycle::queue_run_start(state.as_ref(), run_id, false, actor).await
{
tracing::warn!(
%run_id,
automation_id = %automation.id,
error = ?err,
"Created automation run but failed to start it",
);
}
}
response
}
async fn create_automation(

View file

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