Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/app/src/features/jobs/editor-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.";
Expand Down
21 changes: 15 additions & 6 deletions packages/app/src/features/jobs/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -33,11 +34,16 @@ function EditorForm({ initial, section }: { initial?: Job; section?: string }) {
const [remote, setRemote] = useState<Job>(), [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"}`;
Expand All @@ -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) {
Expand All @@ -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) => {
Expand All @@ -103,10 +110,12 @@ function EditorForm({ initial, section }: { initial?: Job; section?: string }) {
<div className="grid gap-4">
{show("general") && <section id="general" aria-labelledby="general-heading"><Card><h2 id="general-heading">General</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Set the job name, description, and stable identifier.</p><div className="grid gap-5 sm:grid-cols-2">{!initial && <div><Label>Project <Select required aria-label="Project" value={selectedProjectId ?? ""} onChange={e => update("projectId", e.target.value || undefined)}><option value="">Choose a project</option>{projects.data?.map(value => <option key={value.id} value={value.id}>{value.name}</option>)}</Select></Label><Button type="button" className="mt-2" onClick={() => setProjectDialog(true)}>Create new project</Button>{projects.isPending && <p role="status">Loading projects…</p>}{projects.error && <p role="alert">{projects.error.message}</p>}</div>}<Label>Name <Input required maxLength={120} value={draft.name} onChange={e => update("name", e.target.value)} /></Label>
<Label>Slug <Input required pattern="[a-z][a-z0-9_-]{0,29}" value={draft.slug} onChange={e => update("slug", e.target.value)} /></Label><Label className="sm:col-span-2">Description <Textarea maxLength={2000} value={draft.description ?? ""} onChange={e => update("description", e.target.value)} /></Label>
</div></Card>{initial && <DeleteJob jobId={initial.id} name={initial.name} disabled={save.isPending} onDeleted={() => { savingNavigation.current = true; }} />}</section>}{show("execution") && <section id="execution" aria-labelledby="execution-heading"><Card><h2 id="execution-heading">Execution</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Choose an integration for this job, then configure its VM tags, model, effort, and concurrency.</p><div className="grid gap-5 sm:grid-cols-2"><Label>Execution type <Select value={draft.executionType ?? "agent"} onChange={e => update("executionType", e.target.value as "agent" | "code")}><option value="agent">Agent</option><option value="code">Code script</option></Select></Label><Label>Integration <Select required value={draft.executionConfigId} onChange={e => { update("executionConfigId", e.target.value); update("model", ""); update("effort", ""); }}><option value="">Choose an integration</option>{targets.data?.map(t => <option key={t.id} value={t.id}>{t.name} · {t.agentKind}</option>)}{draft.executionConfigId && !target && <option value={draft.executionConfigId}>Unavailable integration: {draft.executionConfigId}</option>}</Select></Label>
{targets.isPending && <p role="status">Loading integrations…</p>}{targets.error && <p role="alert">{targets.error.message}</p>}{targets.data?.length === 0 && <p>Connect an execution provider in settings before creating a job.</p>}
<Label>VM tags <Select multiple value={draft.tags ?? []} disabled={target?.kind === "amp"} onChange={e => update("tags", Array.from(e.target.selectedOptions, option => option.value))}>{target?.tags?.map(tag => <option key={tag}>{tag}</option>)}{(draft.tags ?? []).filter(tag => !target?.tags?.includes(tag)).map(tag => <option key={tag} value={tag}>{tag} (unavailable)</option>)}</Select></Label>
{target?.kind !== "amp" && <div className="space-y-2"><Label>VM image <Input maxLength={512} placeholder="Default exe.dev image" value={draft.vmImage ?? ""} onChange={e => update("vmImage", e.target.value)} /><span className="text-xs text-slate-500">Optional public Docker/OCI image. Blank uses the exe.dev default.</span></Label>{draft.vmImage && <p className="text-sm" role="status">{verification.data?.status === "succeeded" && activeVerificationId ? `Verified: ${verification.data?.phase}` : verification.data?.status === "failed" && activeVerificationId ? `${verification.data?.errorKind}: ${verification.data?.error}` : verification.data?.status === "cleanup_failed" && activeVerificationId ? `Cleanup unresolved: ${verification.data?.error}` : activeVerificationId ? `Verification ${verification.data?.status}: ${verification.data?.phase}` : "Not verified"}</p>}{draft.vmImage && <Button type="button" disabled={!draft.executionConfigId || !target || verifyImage.isPending || ["queued", "creating", "checking_ssh", "checking_agent", "cleaning_up"].includes(verification.data?.status ?? "")} onClick={() => verifyImage.mutate({ fingerprint: verificationFingerprint, generation: verificationGeneration.current })}>{verifyImage.isPending ? "Starting verification…" : activeVerificationId && ["queued", "creating", "checking_ssh", "checking_agent", "cleaning_up"].includes(verification.data?.status ?? "") ? "Verification in progress…" : "Verify image"}</Button>}{verifyImage.error && <p role="alert">{verifyImage.error.message}</p>}</div>}
</div></Card>{initial && <DeleteJob jobId={initial.id} name={initial.name} disabled={save.isPending} onDeleted={() => { savingNavigation.current = true; }} />}</section>}{show("execution") && <section id="execution" aria-labelledby="execution-heading"><Card><h2 id="execution-heading">Execution</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Choose an integration for this job, then configure its VM, model, effort, and concurrency.</p><div className="grid gap-5 sm:grid-cols-2"><Label>Execution type <Select value={draft.executionType ?? "agent"} onChange={e => { update("executionType", e.target.value as "agent" | "code"); if (e.target.value === "code") { update("modelAccountId", undefined); update("modelAccess", undefined); } }}><option value="agent">Agent</option><option value="code">Code script</option></Select></Label><Label>Integration <Select required value={draft.executionConfigId} onChange={e => { setFlySettingsDirty(false); update("executionConfigId", e.target.value); update("model", ""); update("effort", ""); update("vmImage", ""); update("tags", []); update("modelAccountId", undefined); update("modelAccess", undefined); }}><option value="">Choose an integration</option>{targets.data?.map(t => <option key={t.id} value={t.id}>{t.name} · {t.agentKind}</option>)}{flyConnection.data && <option value="fly-new">Fly.io · New VM configuration</option>}{flyConfigurations.data?.map(config => <option key={config.id} value={config.id}>Fly.io · {config.name}</option>)}{draft.executionConfigId && !target && !isFly && <option value={draft.executionConfigId}>Unavailable integration: {draft.executionConfigId}</option>}</Select></Label>
{targets.isPending && <p role="status">Loading integrations…</p>}{targets.error && <p role="alert">{targets.error.message}</p>}{targets.data?.length === 0 && !flyConnection.data && !flyConfigurations.data?.length && <p>Connect an execution provider in settings before creating a job.</p>}
{isFly && <><FlyExecutionFields key={draft.executionConfigId} initial={flyConfiguration} onDirty={() => setFlySettingsDirty(true)} onSaved={id => { setFlySettingsDirty(false); update("executionConfigId", id); update("vmImage", ""); }} />{(draft.executionType ?? "agent") === "agent" && <Label>ChatGPT model account <Select required value={draft.modelAccountId ?? ""} onChange={e => { update("modelAccountId", e.target.value || undefined); update("modelAccess", "portable"); }}><option value="">Choose a model account</option>{accounts.data?.map(account => <option key={account.id} value={account.id}>{account.name}</option>)}</Select><span className="text-xs">Fly.io Codex jobs require a connected ChatGPT model account.</span>{accounts.error && <span role="alert">{accounts.error.message}</span>}</Label>}</>}
{flyConnection.error && <p role="alert">Fly.io: {flyConnection.error.message}</p>}{flyConfigurations.error && <p role="alert">Fly.io: {flyConfigurations.error.message}</p>}
{!isFly && <Label>VM tags <Select multiple value={draft.tags ?? []} disabled={target?.kind === "amp"} onChange={e => update("tags", Array.from(e.target.selectedOptions, option => option.value))}>{target?.tags?.map(tag => <option key={tag}>{tag}</option>)}{(draft.tags ?? []).filter(tag => !target?.tags?.includes(tag)).map(tag => <option key={tag} value={tag}>{tag} (unavailable)</option>)}</Select></Label>}
{target?.kind !== "amp" && !isFly && <div className="space-y-2"><Label>VM image <Input maxLength={512} placeholder="Default exe.dev image" value={draft.vmImage ?? ""} onChange={e => update("vmImage", e.target.value)} /><span className="text-xs text-slate-500">Optional public Docker/OCI image. Blank uses the exe.dev default.</span></Label>{draft.vmImage && <p className="text-sm" role="status">{verification.data?.status === "succeeded" && activeVerificationId ? `Verified: ${verification.data?.phase}` : verification.data?.status === "failed" && activeVerificationId ? `${verification.data?.errorKind}: ${verification.data?.error}` : verification.data?.status === "cleanup_failed" && activeVerificationId ? `Cleanup unresolved: ${verification.data?.error}` : activeVerificationId ? `Verification ${verification.data?.status}: ${verification.data?.phase}` : "Not verified"}</p>}{draft.vmImage && <Button type="button" disabled={!draft.executionConfigId || !target || verifyImage.isPending || ["queued", "creating", "checking_ssh", "checking_agent", "cleaning_up"].includes(verification.data?.status ?? "")} onClick={() => verifyImage.mutate({ fingerprint: verificationFingerprint, generation: verificationGeneration.current })}>{verifyImage.isPending ? "Starting verification…" : activeVerificationId && ["queued", "creating", "checking_ssh", "checking_agent", "cleaning_up"].includes(verification.data?.status ?? "") ? "Verification in progress…" : "Verify image"}</Button>}{verifyImage.error && <p role="alert">{verifyImage.error.message}</p>}</div>}
{(draft.executionType ?? "agent") === "agent" && <><Label>Model <Select value={draft.model} disabled={target?.kind === "amp"} onChange={e => update("model", e.target.value)}><option value="">Harness default</option>{target?.models?.map(model => <option key={model}>{model}</option>)}{draft.model && !target?.models?.includes(draft.model) && <option value={draft.model}>{draft.model} (unavailable)</option>}</Select></Label>
<Label>Effort <Select value={draft.effort} disabled={target?.kind === "amp"} onChange={e => update("effort", e.target.value)}><option value="">Harness default</option>{target?.efforts?.map(effort => <option key={effort}>{effort}</option>)}{draft.effort && !target?.efforts?.includes(draft.effort) && <option value={draft.effort}>{draft.effort} (unavailable)</option>}</Select></Label>
<Label>Concurrency limit <Input required type="number" min={1} max={50} value={draft.concurrencyLimit} onChange={e => update("concurrencyLimit", e.target.valueAsNumber)} /></Label></>}
Expand Down
Loading
Loading