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
2 changes: 1 addition & 1 deletion packages/app/src/features/auth/account-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function AccountBar() {
<nav aria-label="Main navigation" className="mx-auto flex min-h-16 max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
<Link to="/" className="flex items-center gap-2 text-lg font-bold tracking-tight"><img src={beeMark} alt="" className="size-8 object-contain dark:invert" />Factorize</Link>
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
{authenticated && <><Link to="/projects" activeProps={{ className: "border-factorize-500 text-factorize-700 dark:text-factorize-500", "aria-current": "page" }} className="inline-flex min-h-11 items-center border-b-2 border-transparent px-2 py-2 text-sm font-semibold hover:border-stone-300 dark:hover:border-slate-600">Projects</Link><Link to="/job-runs" activeProps={{ className: "border-factorize-500 text-factorize-700 dark:text-factorize-500", "aria-current": "page" }} className="inline-flex min-h-11 items-center border-b-2 border-transparent px-2 py-2 text-sm font-semibold hover:border-stone-300 dark:hover:border-slate-600">Runs</Link><SearchLauncher /></>}
{authenticated && <><Link to="/jobs" activeProps={{ className: "border-factorize-500 text-factorize-700 dark:text-factorize-500", "aria-current": "page" }} className="inline-flex min-h-11 items-center border-b-2 border-transparent px-2 py-2 text-sm font-semibold hover:border-stone-300 dark:hover:border-slate-600">Jobs</Link><Link to="/job-runs" activeProps={{ className: "border-factorize-500 text-factorize-700 dark:text-factorize-500", "aria-current": "page" }} className="inline-flex min-h-11 items-center border-b-2 border-transparent px-2 py-2 text-sm font-semibold hover:border-stone-300 dark:hover:border-slate-600">Runs</Link><SearchLauncher /></>}
<ThemeToggle />
{authenticated ? <div ref={menu} className="relative">
<Button ref={trigger} aria-expanded={open} aria-controls="profile-menu" onClick={() => setOpen(!open)} aria-label="Your account"><span className="flex size-6 items-center justify-center rounded-full bg-factorize-100 text-xs text-slate-950">{(session.data?.user.email ?? "").slice(0, 1).toUpperCase()}</span><span className="hidden sm:inline">Account</span><span aria-hidden="true">⌄</span></Button>
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/features/jobs/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function JobDetail() {
const query = useQuery(jobQuery(jobId!));
const job = query.data;
const project = useQuery({ ...projectQuery(job?.projectId ?? ""), enabled: !!job?.projectId });
return <Page className="max-w-6xl"><Breadcrumb>{project.data && job?.projectId ? <Link to="/projects/$projectId" params={{ projectId: job.projectId }}>{project.data.name}</Link> : <Link to="/projects">Projects</Link>}</Breadcrumb>
return <Page className="max-w-6xl"><nav aria-label="Job navigation" className="mb-2 text-sm"><Link to="/jobs" className="font-semibold underline underline-offset-4">Jobs</Link></nav><Breadcrumb>{project.data && job?.projectId ? <Link to="/projects/$projectId" params={{ projectId: job.projectId }}>{project.data.name}</Link> : <Link to="/projects">Projects</Link>}</Breadcrumb>
{query.isPending && <><ResourceHeader type="Job" title="Job" /><p role="status">Loading job…</p></>}{query.error && <p role="alert">{query.error.message}</p>}
{job && <><JobHeader job={job} /><div className="mb-6"><RunHistory jobId={job.id} /></div></>}
</Page>;
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/features/jobs/editor-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type Job = operations["get_api_v1_jobs_jobId"]["responses"][200]["content
export type Trigger = JobInput["triggers"][number];
export const emptyJob = (): JobInput => ({ name: "", slug: "", promptTemplate: "", runNameTemplate: "", executionConfigId: "", concurrencyLimit: 1, tags: [], model: "", effort: "", skillIds: [], triggers: [{ kind: "manual", slug: "trigger-1", enabled: true, config: {} }] });
export function editableJob(job: Job): JobInput {
return { name: job.name, slug: job.slug, promptTemplate: job.promptTemplate, runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], model: job.model, effort: job.effort ?? "", skillIds: job.skills?.map(skill => skill.id) ?? [], triggers: job.triggers.map(trigger => {
return { projectId: job.projectId, name: job.name, slug: job.slug, promptTemplate: job.promptTemplate, runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], 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
25 changes: 19 additions & 6 deletions packages/app/src/features/jobs/editor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Disclosure, Summary, Button, Input, Select, Label, Page, Card } from "../../shared/ui";
import { Disclosure, Summary, Button, Input, Select, Label, Page, Card, Textarea } from "../../shared/ui";
import { Dialog } from "../../shared/dialog";
import { JobHeader } from "./header";
import { DeleteJob } from "./delete";
import { validationMessage } from "./validation-message";
Expand All @@ -12,7 +13,7 @@ import { editableJob, emptyJob, reconcile, validateJobInput, writeInput, type Jo
import { TemplateField } from "./template-field";
import { TriggerEditor } from "./trigger-editor";
import { messageOf } from "../auth/session";
import { projectQuery } from "../projects/queries";
import { projectQuery, projectsQuery } from "../projects/queries";
import { AutosaveStatus, useDebouncedAutosave } from "../../shared/use-debounced-autosave";
import { skillsQuery } from "../skills/queries";
export function JobEditor() {
Expand All @@ -34,8 +35,13 @@ function EditorForm({ initial }: { initial?: Job }) {
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 project = useQuery({ ...projectQuery(initial?.projectId ?? ""), enabled: !!initial?.projectId });
const projects = useQuery({ ...projectsQuery, enabled: !initial });
const target = targets.data?.find(t => t.id === draft.executionConfigId);
const projectId = new URLSearchParams(location.searchStr).get("projectId") ?? undefined;
const selectedProjectId = initial?.projectId ?? draft.projectId ?? projectId;
const [projectDialog, setProjectDialog] = useState(false);
const onlyProject = projects.data?.length === 1 ? projects.data[0] : undefined;
useEffect(() => { if (!initial && !draft.projectId && !projectId && onlyProject) setDraft(previous => ({ ...previous, projectId: onlyProject.id })); }, [initial, draft.projectId, projectId, onlyProject]);
const update = <K extends keyof JobInput>(key: K, value: JobInput[K]) => { setSaved(false); setDraft(previous => ({ ...previous, [key]: value })); };
const refreshConflict = useMutation({ retry: false, mutationFn: async () => {
if (!initial) throw new Error("No job selected");
Expand All @@ -44,7 +50,8 @@ function EditorForm({ initial }: { initial?: Job }) {
}, onSuccess: value => { setRemote(value); setReconciling(false); } });
const save = useMutation({ retry: false, mutationFn: async () => {
if (remote || conflicts.length) throw new Error("Resolve the stale edit before saving.");
const input = { ...writeInput(draft), ...(projectId ? { projectId } : {}) };
if (!initial && !selectedProjectId) throw new Error("Choose a project before creating the job.");
const input = { ...writeInput(draft), ...(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) {
if (result.error?.error.code === "stale_job") { setReconciling(true); refreshConflict.mutate(); }
Expand All @@ -61,14 +68,14 @@ function EditorForm({ initial }: { initial?: Job }) {
if (conflicts.length === 1) setRemote(undefined);
};
const [localConflictDraft, setLocalConflictDraft] = useState(draft);
return <Page className="max-w-6xl"><nav aria-label="Breadcrumb" className="mb-4 flex min-h-6 min-w-0 flex-wrap items-center gap-2 text-sm">{project.data && initial?.projectId ? <Link to="/projects/$projectId" params={{ projectId: initial.projectId }}>{project.data.name}</Link> : <Link to="/projects">Projects</Link>}</nav>{initial && <JobHeader job={initial} />}
return <Page className="max-w-6xl"><nav aria-label="Breadcrumb" className="mb-4 flex min-h-6 min-w-0 flex-wrap items-center gap-2 text-sm"><Link to="/jobs" className="font-semibold">Jobs</Link>{project.data && initial?.projectId && <><span aria-hidden="true">/</span><Link to="/projects/$projectId" params={{ projectId: initial.projectId }}>{project.data.name}</Link></>}<Link to="/projects" className="text-slate-500 underline underline-offset-4">Projects</Link></nav>{initial && <JobHeader job={initial} />}
{(remote || reconciling || refreshConflict.error) && <Card role="alert" className="my-4 border p-4"><h2>This job changed while you were editing</h2><p>Your unsaved draft is retained. Review the latest configuration before retrying.</p>
{refreshConflict.isPending && <p>Loading latest configuration…</p>}{refreshConflict.error && <><p>{refreshConflict.error.message}</p><Button onClick={() => refreshConflict.mutate()}>Retry loading latest</Button></>}
{remote && !conflicts.length && <><pre className="max-h-64 overflow-auto whitespace-pre-wrap">{JSON.stringify(editableJob(remote), null, 2)}</pre><Button onClick={acceptRemote}>Reload latest and discard my changes</Button><Button onClick={() => { setLocalConflictDraft(draft); merge(); }}>Reconcile my changes</Button></>}
{conflicts.map(field => <fieldset key={field}><legend>Both versions changed {field}</legend><pre className="max-h-40 overflow-auto">Mine: {JSON.stringify(localConflictDraft[field], null, 2)}<br />Latest: {JSON.stringify(base[field], null, 2)}</pre><Button onClick={() => resolve(field, true)}>Keep my {field}</Button><Button onClick={() => resolve(field, false)}>Keep latest {field}</Button></fieldset>)}
</Card>}
<form className="my-4 grid gap-4" onSubmit={e => { e.preventDefault(); save.mutate(); }}>
<Card><h2>Job details</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Name this job and set its stable identifier.</p><div className="grid gap-5 sm:grid-cols-2"><Label>Name <Input required maxLength={120} value={draft.name} onChange={e => update("name", e.target.value)} /></Label>
<Card><h2>Job details</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Name this job and set its 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>
</div></Card><Card><h2>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>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>}
Expand All @@ -84,6 +91,12 @@ function EditorForm({ initial }: { initial?: Job }) {
</Card>{save.error && <p role="alert">{save.error.message}</p>}
{!initial && <Button variant="primary" disabled={save.isPending || !!remote || reconciling || !!conflicts.length || targets.isPending || !!targets.error}>{save.isPending ? "Saving…" : "Create job"}</Button>}
{initial && <AutosaveStatus state={autosave.state} error={autosave.error} label="Job settings" />}
</form>{saved && !initial && <p role="status">Job settings saved.</p>}{initial && <DeleteJob jobId={initial.id} name={initial.name} disabled={save.isPending} onDeleted={() => { savingNavigation.current = true; }} />}
</form>{saved && !initial && <p role="status">Job settings saved.</p>}{initial && <DeleteJob jobId={initial.id} name={initial.name} disabled={save.isPending} onDeleted={() => { savingNavigation.current = true; }} />}{projectDialog && <ProjectDialog close={() => setProjectDialog(false)} onCreated={value => { cache.setQueryData(projectsQuery.queryKey, (current = []) => [...current, value]); void cache.invalidateQueries({ queryKey: ["projects"] }); update("projectId", value.id); setProjectDialog(false); }} />}
</Page>;
}

function ProjectDialog({ close, onCreated }: { close: () => void; onCreated: (project: { id: string; name: string; description?: string; jobCount: number; createdAt: string; updatedAt: string }) => void }) {
const [name, setName] = useState(""), [description, setDescription] = useState("");
const create = useMutation({ retry: false, mutationFn: async () => { const trimmed = name.trim(); if (!trimmed) throw new Error("Project name is required."); const { data, error } = await api.POST("/api/v1/projects", { body: { name: trimmed, description: description.trim() || undefined } }); if (!data || error) throw new Error(messageOf(error)); return data; }, onSuccess: onCreated });
return <Dialog title="Create new project" close={() => { if (!create.isPending) close(); }}><form className="space-y-5" onSubmit={event => { event.preventDefault(); if (!create.isPending) create.mutate(); }}><Label>Name<Input autoFocus required maxLength={120} value={name} onChange={event => setName(event.target.value)} /></Label><Label>Description<Textarea maxLength={2000} value={description} onChange={event => setDescription(event.target.value)} /></Label>{create.error && <p role="alert">{create.error.message}</p>}<div className="flex justify-end gap-2"><Button type="button" onClick={close} disabled={create.isPending}>Cancel</Button><Button type="submit" variant="primary" disabled={create.isPending || !name.trim()}>{create.isPending ? "Creating…" : "Create project"}</Button></div></form></Dialog>;
}
Loading
Loading