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
4 changes: 2 additions & 2 deletions packages/app/src/features/jobs/editor-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const availabilityQuery = queryOptions({ queryKey: ["editor", "availabili
const { data, error } = await api.GET("/api/v1/job-trigger-availability", { signal });
if (!data || error) throw new Error(messageOf(error)); return data;
}, staleTime: 60_000 });
export const selectorQuery = (q: string, cursor?: string) => queryOptions({ queryKey: ["jobs", "selector", q, cursor], queryFn: async ({ signal }) => {
const { data, error } = await api.GET("/api/v1/job-selector", { signal, params: { query: { q, cursor, limit: 30 } } });
export const selectorQuery = (q: string, cursor?: string, projectId?: string) => queryOptions({ queryKey: ["jobs", "selector", projectId, q, cursor], queryFn: async ({ signal }) => {
const { data, error } = await api.GET("/api/v1/job-selector", { signal, params: { query: { q, cursor, limit: 30, projectId } } });
if (!data || error) throw new Error(messageOf(error)); return data;
}, staleTime: 30_000 });
2 changes: 1 addition & 1 deletion packages/app/src/features/jobs/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ function EditorForm({ initial }: { initial?: Job }) {
</div></Card><Card className="space-y-4"><h2>Skills</h2><p className="text-sm text-slate-600 dark:text-slate-400">Ordered global instructions are composed before this job context. Bodies remain literal.</p><div className="grid gap-2">{(draft.skillIds ?? []).map((id, index) => { const skill = skills.data?.items.find(item => item.id === id); return <div key={id} className="flex flex-wrap items-center gap-2 rounded-lg border border-stone-200 p-3 dark:border-slate-700"><span className="font-semibold">{index + 1}. {skill?.key ?? "Unavailable skill"}</span><Button type="button" onClick={() => update("skillIds", (draft.skillIds ?? []).filter(value => value !== id))}>Remove</Button><Button type="button" disabled={index === 0} onClick={() => { const next = [...(draft.skillIds ?? [])]; [next[index - 1], next[index]] = [next[index], next[index - 1]]; update("skillIds", next); }}>Up</Button><Button type="button" disabled={index === (draft.skillIds ?? []).length - 1} onClick={() => { const next = [...(draft.skillIds ?? [])]; [next[index], next[index + 1]] = [next[index + 1], next[index]]; update("skillIds", next); }}>Down</Button></div>; })}</div><div className="flex flex-wrap gap-2"><Select aria-label="Add skill" value="" onChange={e => { if (e.target.value) update("skillIds", [...(draft.skillIds ?? []), e.target.value]); }}><option value="">Add skill…</option>{skills.data?.items.filter(skill => !(draft.skillIds ?? []).includes(skill.id)).map(skill => <option key={skill.id} value={skill.id}>{skill.key}</option>)}</Select><Link to="/skills" className="self-center text-sm font-semibold underline">Create or edit skills</Link></div></Card><Card className="space-y-5"><h2>Prompt and context</h2><p className="text-sm text-slate-600 dark:text-slate-400">Write instructions using context supplied by your triggers.</p><TemplateField label="Prompt template" multiline value={draft.promptTemplate} onChange={value => update("promptTemplate", value)} triggers={draft.triggers} />
<TemplateField label="Run name template" value={draft.runNameTemplate ?? ""} onChange={value => update("runNameTemplate", value)} triggers={draft.triggers} />
<Disclosure><Summary>Template variables</Summary>{metadata.error && <p role="alert">{metadata.error.message}</p>}{Object.entries(metadata.data ?? {}).map(([kind, fields]) => <Card key={kind}><h3>{kind}</h3><ul>{fields.map(field => <li key={field.path}><code>{field.path}</code> — {field.description}</li>)}</ul></Card>)}</Disclosure>
</Card><Card><h2>Triggers</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Choose the events that start this job.</p><TriggerEditor triggers={draft.triggers} onChange={value => update("triggers", value)} onValidityChange={setInvalidForm} />
</Card><Card><h2>Triggers</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Choose the events that start this job.</p><TriggerEditor triggers={draft.triggers} projectId={selectedProjectId} onChange={value => update("triggers", value)} onValidityChange={setInvalidForm} />
{initial?.triggers.map(trigger => trigger.kind === "webhook" && trigger.config.provider === "cloudflareTail" && trigger.config.destination ? <div key={trigger.id} className="mt-4 text-sm"><p>{trigger.slug} · Tail webhook destination</p><code className="break-all">{trigger.config.destination}</code></div> : null)}
</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>}
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/features/jobs/lifecycle-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Button, Input, Label } from "../../shared/ui";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { selectorQuery } from "./editor-queries";
export function LifecycleSelector({ selected, onChange }: { selected: string[]; onChange: (ids: string[]) => void }) {
export function LifecycleSelector({ selected, onChange, projectId }: { selected: string[]; onChange: (ids: string[]) => void; projectId?: string }) {
const [q, setQ] = useState(""), [cursor, setCursor] = useState<string>();
const jobs = useQuery(selectorQuery(q, cursor));
const jobs = useQuery(selectorQuery(q, cursor, projectId));
return <fieldset><legend>Source jobs</legend>
<Label>Search source jobs <Input value={q} maxLength={120} onKeyDown={e => { if (e.key === "Enter") e.preventDefault(); }} onChange={e => { setQ(e.target.value); setCursor(undefined); }} /></Label>
{jobs.isPending && <p role="status">Loading source jobs…</p>}{jobs.error && <p role="alert">{jobs.error.message}</p>}
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/features/jobs/trigger-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function ScheduleFields({ trigger, onChange }: { trigger: Extract<Trigger, { kin
<Button type="button" disabled={preview.isPending} onClick={() => preview.mutate()}>Preview schedule</Button>
{preview.data && <p role="status">Next run: {preview.data.nextRunAt}</p>}{preview.error && <p role="alert">{preview.error.message}</p>}</>;
}
export function TriggerEditor({ triggers, onChange, onValidityChange }: { triggers: Trigger[]; onChange: (value: Trigger[]) => void; onValidityChange?: (invalid: boolean) => void }) {
export function TriggerEditor({ triggers, onChange, onValidityChange, projectId }: { triggers: Trigger[]; onChange: (value: Trigger[]) => void; onValidityChange?: (invalid: boolean) => void; projectId?: string }) {
const availability = useQuery(availabilityQuery), [kind, setKind] = useState<Trigger["kind"]>("schedule");
const replace = (index: number, value: Trigger) => onChange(triggers.map((item, i) => i === index ? value : item));
return <Card>
Expand All @@ -35,7 +35,7 @@ export function TriggerEditor({ triggers, onChange, onValidityChange }: { trigge
{trigger.kind === "webhook" && <ProviderFields config={trigger.config} onChange={config => replace(index, { ...trigger, config })} onValidityChange={onValidityChange} />}
{trigger.kind === "jobLifecycle" && <><LifecycleSelector selected={trigger.config.sourceJobIds} onChange={sourceJobIds => replace(index, { ...trigger, config: { ...trigger.config, sourceJobIds } })} />
<fieldset><legend>Lifecycle states</legend>{(["succeeded", "failed", "stopped", "edited"] as const).map(state => <Label key={state}><Input type="checkbox" checked={trigger.config.states.includes(state)} onChange={e => replace(index, { ...trigger, config: { ...trigger.config, states: e.target.checked ? [...trigger.config.states, state] : trigger.config.states.filter(s => s !== state) } })} />{state} </Label>)}</fieldset></>}
{trigger.kind === "jobIdle" && <><LifecycleSelector selected={trigger.config.sourceJobIds} onChange={sourceJobIds => replace(index, { ...trigger, config: { ...trigger.config, sourceJobIds } })} /><p>Starts when the selected job has no queued or active runs.</p></>}
{trigger.kind === "jobIdle" && <><LifecycleSelector selected={trigger.config.sourceJobIds} projectId={projectId} onChange={sourceJobIds => replace(index, { ...trigger, config: { ...trigger.config, sourceJobIds } })} /><p>Starts when the selected job has no queued or active runs.</p></>}
{trigger.kind !== "manual" && <Button type="button" onClick={() => onChange(triggers.filter((_, i) => i !== index))}>Remove trigger</Button>}
</fieldset>)}
<Label>New trigger kind <Select value={kind} onChange={e => setKind(e.target.value as Trigger["kind"])}><option value="schedule">Schedule</option><option value="jobLifecycle">Job lifecycle</option><option value="jobIdle">All runs complete</option><option value="webhook">Provider webhook</option></Select></Label>
Expand Down
31 changes: 31 additions & 0 deletions packages/app/test/editor.browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,37 @@ test("stale editor reconciles non-overlapping fields, requires explicit conflict
await page.getByRole("status").filter({ hasText: "Job settings saved." }).waitFor();
assert.equal(writes[0].expectedUpdatedAt, initial.updatedAt); assert.equal(writes[1].expectedUpdatedAt, "2026-10-09T13:00:00.000Z"); assert.equal(writes[1].triggers[0].id, triggerId); assert.equal(writes[1].triggers[0].slug, "trigger-1"); assert.equal(writes[1].promptTemplate, "My prompt"); await context.close();
});
test("All runs complete source jobs stay within the edited job project during search and save", async () => {
const { context, page } = await setup();
const projectA = "00000000-0000-4000-8000-000000000020", projectB = "00000000-0000-4000-8000-000000000021";
const sameProjectJob = "00000000-0000-4000-8000-000000000022", otherProjectJob = "00000000-0000-4000-8000-000000000023";
let current = { ...structuredClone(initial), projectId: projectA, triggers: [{ ...initial.triggers[0], kind: "jobIdle", config: { sourceJobIds: [] } }] }, saved;
const selectorRequests = [];
await page.route(`**/api/v1/jobs/${id}`, route => {
if (route.request().method() === "GET") return route.fulfill({ json: current });
saved = route.request().postDataJSON(); current = { ...current, ...saved }; return route.fulfill({ json: current });
});
await page.route("**/api/v1/job-selector?**", route => {
const url = new URL(route.request().url()); selectorRequests.push(url);
const projectId = url.searchParams.get("projectId"), q = url.searchParams.get("q") ?? "";
const allJobs = [
{ id: sameProjectJob, projectId: projectA, name: "Alpha source", slug: "alpha-source", enabled: true },
{ id: otherProjectJob, projectId: projectB, name: "Alpha other", slug: "alpha-other", enabled: true },
];
return route.fulfill({ json: { items: allJobs.filter(job => job.projectId === projectId && job.name.toLowerCase().includes(q.toLowerCase())).map(({ projectId: _projectId, ...job }) => job), nextCursor: null } });
});
await page.goto(origin + `/jobs/${id}/edit`);
await page.getByLabel("Search source jobs").fill("Alpha");
await page.getByLabel("Alpha source", { exact: true }).check();
assert.equal(await page.getByLabel("Alpha other", { exact: true }).count(), 0);
await page.getByRole("status").filter({ hasText: "Job settings saved." }).waitFor();
assert.ok(selectorRequests.length > 0);
assert.ok(selectorRequests.every(url => url.searchParams.get("projectId") === projectA));
assert.ok(selectorRequests.some(url => url.searchParams.get("q") === "Alpha"));
assert.deepEqual(saved.triggers[0].config.sourceJobIds, [sameProjectJob]);
assert.equal(projectA, current.projectId); assert.notEqual(projectA, projectB);
await context.close();
});
test("create uses bounded lifecycle selector, lazy provider reads, schedule preview and typed form submission on mobile", async () => {
const { context, page } = await setup({ viewport: { width: 390, height: 844 } }); let body, providerReads = 0; const selectors = [];
const projectId = "00000000-0000-4000-8000-000000000010";
Expand Down
Loading