From fad6cd5462ba817b0ace4da861855917ffb55019 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 13 Nov 2026 02:59:06 +0000 Subject: [PATCH] Add Fly.io integration and job execution configuration UI --- .../app/src/features/jobs/editor-state.ts | 3 +- packages/app/src/features/jobs/editor.tsx | 21 +++++-- packages/app/src/features/settings/fly.tsx | 56 +++++++++++++++++++ .../src/features/settings/integrations.tsx | 7 ++- packages/app/test/editor.browser.test.mjs | 55 ++++++++++++++++++ packages/app/test/settings.browser.test.mjs | 38 +++++++++++++ 6 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/features/settings/fly.tsx diff --git a/packages/app/src/features/jobs/editor-state.ts b/packages/app/src/features/jobs/editor-state.ts index ce5ee770..ad6f2d0d 100644 --- a/packages/app/src/features/jobs/editor-state.ts +++ b/packages/app/src/features/jobs/editor-state.ts @@ -10,7 +10,7 @@ export function canonicalizeVmImage(value: string): string { return `${name.toLowerCase()}${tag}${digest}`; } export function editableJob(job: Job): JobInput { - return { runScopes: job.runScopes ?? [...defaultRunScopes], executionType: job.executionType ?? "agent", projectId: job.projectId, name: job.name, description: job.description ?? "", slug: job.slug, promptTemplate: job.promptTemplate, script: job.script ?? "", runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], vmImage: job.executionConfig?.image ?? "", model: job.model, effort: job.effort ?? "", skillIds: job.skills?.map(skill => skill.id) ?? [], triggers: job.triggers.map(trigger => { + return { modelAccountId: job.modelAccountId, modelAccess: job.modelAccess, portableIntegrationIds: job.portableIntegrationIds, runScopes: job.runScopes ?? [...defaultRunScopes], executionType: job.executionType ?? "agent", projectId: job.projectId, name: job.name, description: job.description ?? "", slug: job.slug, promptTemplate: job.promptTemplate, script: job.script ?? "", runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], vmImage: job.executionConfig?.image ?? "", model: job.model, effort: job.effort ?? "", skillIds: job.skills?.map(skill => skill.id) ?? [], triggers: job.triggers.map(trigger => { const { id, slug, kind, enabled } = trigger; const config = trigger.kind === "webhook" ? Object.fromEntries(Object.entries(trigger.config).filter(([key]) => !["destination", "secretConfigured"].includes(key))) : trigger.config; return { id, slug, kind, enabled, config } as Trigger; @@ -39,6 +39,7 @@ export function validateJobInput(input: JobInput): string | undefined { if (input.executionType === "agent" && (!input.promptTemplate || input.promptTemplate.length > 50_000)) return "Enter a prompt template between 1 and 50,000 characters."; if (input.executionType === "code" && (!input.script?.trim() || input.script.length > 50_000)) return "Enter a script between 1 and 50,000 characters."; if (!Number.isInteger(input.concurrencyLimit) || input.concurrencyLimit < 1 || input.concurrencyLimit > 50) return "Concurrency limit must be between 1 and 50."; + if (input.executionConfigId === "fly-new") return "Save Fly VM settings before saving the job."; if (!input.executionConfigId) return "Choose an execution integration."; if ((input.tags?.length ?? 0) > 20 || input.tags?.some(tag => !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(tag))) return "VM tags are invalid."; if (input.vmImage && !/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/.test(canonicalizeVmImage(input.vmImage))) return input.vmImage.trim().startsWith("https://") ? "Enter an image reference without a URL scheme." : "Enter a public Docker/OCI image reference."; diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx index 38b71940..8e9e19d7 100644 --- a/packages/app/src/features/jobs/editor.tsx +++ b/packages/app/src/features/jobs/editor.tsx @@ -18,6 +18,7 @@ import { messageOf } from "../auth/session"; import { projectsQuery } from "../projects/queries"; import { AutosaveStatus, useDebouncedAutosave } from "../../shared/use-debounced-autosave"; import { skillsQuery } from "../skills/queries"; +import { FlyExecutionFields, flyConnectionQuery, flyConfigurationsQuery } from "../settings/fly"; import { JobNav } from "./job-nav"; export function JobEditor() { const { jobId, section } = useParams({ strict: false }); @@ -33,11 +34,16 @@ function EditorForm({ initial, section }: { initial?: Job; section?: string }) { const [remote, setRemote] = useState(), [conflicts, setConflicts] = useState<(keyof JobInput)[]>([]); const [reconciling, setReconciling] = useState(false), [saved, setSaved] = useState(false), [invalidForm, setInvalidForm] = useState(false); const savingNavigation = useRef(false); - const dirty = JSON.stringify(base) !== JSON.stringify(draft); + const [flySettingsDirty, setFlySettingsDirty] = useState(false); + const dirty = flySettingsDirty || JSON.stringify(base) !== JSON.stringify(draft); useBlocker({ shouldBlockFn: ({ next }) => dirty && !(initial && next.pathname.startsWith(`/jobs/${initial.id}/settings/`)) && !savingNavigation.current && !window.confirm("Discard unsaved job changes?"), enableBeforeUnload: dirty }); useEffect(() => { const handler = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = ""; } }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [dirty]); const targets = useQuery(targetsQuery), metadata = useQuery(metadataQuery), skills = useQuery(skillsQuery()); const projects = useQuery({ ...projectsQuery, enabled: !initial }); + const flyConnection = useQuery(flyConnectionQuery), flyConfigurations = useQuery(flyConfigurationsQuery); + const flyConfiguration = flyConfigurations.data?.find(item => item.id === draft.executionConfigId); + const isFly = draft.executionConfigId === "fly-new" || Boolean(flyConfiguration); + const accounts = useQuery({ enabled: isFly && (draft.executionType ?? "agent") === "agent", queryKey: ["settings", "model-accounts"], queryFn: async ({ signal }) => { const { data, error } = await api.GET("/api/v1/model-accounts", { signal }); if (!data || error) throw new Error(messageOf(error)); return data.accounts; } }); const target = targets.data?.find(t => t.id === draft.executionConfigId); const verificationFingerprint = JSON.stringify([draft.executionConfigId, canonicalizeVmImage(draft.vmImage ?? ""), draft.tags ?? [], target?.agentKind ?? ""]); const verificationKey = `factorize-vm-image-verification:${initial?.id ?? "new"}`; @@ -61,6 +67,7 @@ function EditorForm({ initial, section }: { initial?: Job; section?: string }) { const save = useMutation({ retry: false, mutationFn: async (snapshot: JobInput) => { if (remote || conflicts.length) throw new Error("Resolve the stale edit before saving."); if (!initial && !selectedProjectId) throw new Error("Choose a project before creating the job."); + if (flySettingsDirty || snapshot.executionConfigId === "fly-new") throw new Error("Save Fly VM settings before saving the job."); const input = { ...writeInput(snapshot), ...(selectedProjectId ? { projectId: selectedProjectId } : {}) }; const result = initial ? await api.PUT("/api/v1/jobs/{jobId}", { params: { path: { jobId: initial.id } }, body: { ...input, expectedUpdatedAt: revision } }) : await api.POST("/api/v1/jobs", { body: input }); if (result.error || !result.data) { @@ -80,7 +87,7 @@ function EditorForm({ initial, section }: { initial?: Job; section?: string }) { await cache.invalidateQueries({ queryKey: ["jobs"] }); if (!initial) void navigate({ to: "/jobs/$jobId", params: { jobId: value.id } }); } }); - const autosave = useDebouncedAutosave({ value: draft, enabled: Boolean(initial) && dirty && !invalidForm && !remote && !reconciling && !conflicts.length, validate: validateJobInput, save: async snapshot => { await save.mutateAsync(snapshot); } }); + const autosave = useDebouncedAutosave({ value: draft, enabled: Boolean(initial) && !flySettingsDirty && dirty && !invalidForm && !remote && !reconciling && !conflicts.length, validate: validateJobInput, save: async snapshot => { await save.mutateAsync(snapshot); } }); const acceptRemote = () => { if (!remote) return; const value = editableJob(remote); setDraft(value); setBase(value); setRevision(remote.updatedAt); setRemote(undefined); setConflicts([]); save.reset(); }; const merge = () => { if (!remote) return; const value = editableJob(remote), result = reconcile(base, draft, value); setDraft(result.merged); setConflicts(result.conflicts); setBase(value); setRevision(remote.updatedAt); if (!result.conflicts.length) setRemote(undefined); save.reset(); }; const resolve = (field: keyof JobInput, local: boolean) => { @@ -103,10 +110,12 @@ function EditorForm({ initial, section }: { initial?: Job; section?: string }) {
{show("general") &&

General

Set the job name, description, and stable identifier.

{!initial &&
{projects.isPending &&

Loading projects…

}{projects.error &&

{projects.error.message}

}
}