From 8d74742d0f5ce384363f367d70ca73d140c643c7 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Fri, 30 Oct 2026 21:31:42 +0000 Subject: [PATCH] Scope all-runs-complete jobs to project --- .../app/src/features/jobs/editor-queries.ts | 4 +-- packages/app/src/features/jobs/editor.tsx | 2 +- .../src/features/jobs/lifecycle-selector.tsx | 4 +-- .../app/src/features/jobs/trigger-editor.tsx | 4 +-- packages/app/test/editor.browser.test.mjs | 31 +++++++++++++++++++ 5 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/app/src/features/jobs/editor-queries.ts b/packages/app/src/features/jobs/editor-queries.ts index 23f15ad0..d1e97e91 100644 --- a/packages/app/src/features/jobs/editor-queries.ts +++ b/packages/app/src/features/jobs/editor-queries.ts @@ -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 }); diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx index 0f520278..6630e038 100644 --- a/packages/app/src/features/jobs/editor.tsx +++ b/packages/app/src/features/jobs/editor.tsx @@ -95,7 +95,7 @@ function EditorForm({ initial }: { initial?: Job }) {

Skills

Ordered global instructions are composed before this job context. Bodies remain literal.

{(draft.skillIds ?? []).map((id, index) => { const skill = skills.data?.items.find(item => item.id === id); return
{index + 1}. {skill?.key ?? "Unavailable skill"}
; })}
Create or edit skills

Prompt and context

Write instructions using context supplied by your triggers.

update("promptTemplate", value)} triggers={draft.triggers} /> update("runNameTemplate", value)} triggers={draft.triggers} /> Template variables{metadata.error &&

{metadata.error.message}

}{Object.entries(metadata.data ?? {}).map(([kind, fields]) =>

{kind}

    {fields.map(field =>
  • {field.path} — {field.description}
  • )}
)}
-

Triggers

Choose the events that start this job.

update("triggers", value)} onValidityChange={setInvalidForm} /> +

Triggers

Choose the events that start this job.

update("triggers", value)} onValidityChange={setInvalidForm} /> {initial?.triggers.map(trigger => trigger.kind === "webhook" && trigger.config.provider === "cloudflareTail" && trigger.config.destination ?

{trigger.slug} · Tail webhook destination

{trigger.config.destination}
: null)}
{save.error &&

{save.error.message}

} {!initial && } diff --git a/packages/app/src/features/jobs/lifecycle-selector.tsx b/packages/app/src/features/jobs/lifecycle-selector.tsx index 742c00ec..6f2628f6 100644 --- a/packages/app/src/features/jobs/lifecycle-selector.tsx +++ b/packages/app/src/features/jobs/lifecycle-selector.tsx @@ -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(); - const jobs = useQuery(selectorQuery(q, cursor)); + const jobs = useQuery(selectorQuery(q, cursor, projectId)); return
Source jobs {jobs.isPending &&

Loading source jobs…

}{jobs.error &&

{jobs.error.message}

} diff --git a/packages/app/src/features/jobs/trigger-editor.tsx b/packages/app/src/features/jobs/trigger-editor.tsx index b36be3d1..306790b3 100644 --- a/packages/app/src/features/jobs/trigger-editor.tsx +++ b/packages/app/src/features/jobs/trigger-editor.tsx @@ -24,7 +24,7 @@ function ScheduleFields({ trigger, onChange }: { trigger: Extract preview.mutate()}>Preview schedule {preview.data &&

Next run: {preview.data.nextRunAt}

}{preview.error &&

{preview.error.message}

}; } -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("schedule"); const replace = (index: number, value: Trigger) => onChange(triggers.map((item, i) => i === index ? value : item)); return @@ -35,7 +35,7 @@ export function TriggerEditor({ triggers, onChange, onValidityChange }: { trigge {trigger.kind === "webhook" && replace(index, { ...trigger, config })} onValidityChange={onValidityChange} />} {trigger.kind === "jobLifecycle" && <> replace(index, { ...trigger, config: { ...trigger.config, sourceJobIds } })} />
Lifecycle states{(["succeeded", "failed", "stopped", "edited"] as const).map(state => )}
} - {trigger.kind === "jobIdle" && <> replace(index, { ...trigger, config: { ...trigger.config, sourceJobIds } })} />

Starts when the selected job has no queued or active runs.

} + {trigger.kind === "jobIdle" && <> replace(index, { ...trigger, config: { ...trigger.config, sourceJobIds } })} />

Starts when the selected job has no queued or active runs.

} {trigger.kind !== "manual" && }
)} diff --git a/packages/app/test/editor.browser.test.mjs b/packages/app/test/editor.browser.test.mjs index 9fe3a399..f71eb8ee 100644 --- a/packages/app/test/editor.browser.test.mjs +++ b/packages/app/test/editor.browser.test.mjs @@ -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";