Skip to content

Commit 5ce852e

Browse files
Merge pull request #253 from GenerationSoftware/fix/gen-2362-project-job-target
fix: resolve migrated execution targets for project jobs
2 parents 6c06a6c + 1eb82c0 commit 5ce852e

5 files changed

Lines changed: 23 additions & 4 deletions

File tree

‎packages/api/src/flow-service.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ export class ApiService {
6060
private targetRecords() { return new ExecutionTargetRepository(databaseFor(this.env), this.auth.tenantId); }
6161

6262
private async executionTarget(id: string) {
63-
const record = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ? await this.targetRecords().get(id) : null;
63+
// Target IDs created by the migration are deterministic md5-derived UUIDs,
64+
// so they do not necessarily have RFC 4122 version/variant bits. Any
65+
// UUID-shaped value is still safe to use in the UUID query; non-UUID values
66+
// are the legacy connection IDs.
67+
const record = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) ? await this.targetRecords().get(id) : null;
6468
if (record) {
6569
const value = await this.connections().get<any>(`${record.integrationKind}:${record.integrationId}`);
6670
if (!value) throw new ServiceError(400, "invalid_request", "Execution target integration not found");

‎packages/api/test/loading.postgres.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ describe.skipIf(!url)("request loading with PostgreSQL", () => {
5858
expect(query).toHaveBeenCalledTimes(3); // membership + connections + reference counts
5959
} finally { query.mockRestore(); }
6060
});
61+
62+
it("creates a job with a deterministic migrated execution target UUID", async () => {
63+
const target = (await db.pool.query(`INSERT INTO app.execution_targets(tenant_id,id,name,integration_kind,integration_id,agent_kind)
64+
VALUES ($1::uuid,md5($1::text || ':target:local')::uuid,'Migrated local target','exe','local','codex') RETURNING id`, [tenantId])).rows[0].id;
65+
try {
66+
const job = await new ApiService(env, auth).createJob({ name: "Migrated target job", slug: "migrated-target-job", promptTemplate: "Do work", executionTargetId: target, concurrencyLimit: 1, triggers: [{ kind: "manual", enabled: true, config: {} }] });
67+
expect(job.executionTargetId).toBe(target);
68+
expect(job.executionTarget).toMatchObject({ connectionId: "local", agentKind: "codex" });
69+
} finally {
70+
await db.pool.query("DELETE FROM app.jobs WHERE execution_target->>'targetId'=$1", [target]);
71+
await db.pool.query("DELETE FROM app.execution_targets WHERE id=$1", [target]);
72+
}
73+
});
6174
it("enforces tenant and scope boundaries, email verification, cookie expiry and session revocation", async () => {
6275
await expect(new ApiService(env, { ...auth, tenantId: otherTenant }).listJobs()).rejects.toMatchObject({ status: 401 });
6376
await expect(new ApiService(env, { ...auth, scopes: ["runs:read"] }).listJobs()).rejects.toMatchObject({ status: 403 });

‎packages/app/src/features/jobs/editor.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function EditorForm({ initial }: { initial?: Job }) {
3131
useEffect(() => { const handler = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = ""; } }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [dirty]);
3232
const targets = useQuery(targetsQuery), metadata = useQuery(metadataQuery);
3333
const target = targets.data?.find(t => t.id === draft.executionTargetId);
34+
const projectId = new URLSearchParams(location.searchStr).get("projectId") ?? undefined;
3435
const update = <K extends keyof JobInput>(key: K, value: JobInput[K]) => { setSaved(false); setDraft(previous => ({ ...previous, [key]: value })); };
3536
const refreshConflict = useMutation({ retry: false, mutationFn: async () => {
3637
if (!initial) throw new Error("No job selected");
@@ -39,7 +40,8 @@ function EditorForm({ initial }: { initial?: Job }) {
3940
}, onSuccess: value => { setRemote(value); setReconciling(false); } });
4041
const save = useMutation({ retry: false, mutationFn: async () => {
4142
if (remote || conflicts.length) throw new Error("Resolve the stale edit before saving.");
42-
const result = initial ? await api.PUT("/api/v1/jobs/{jobId}", { params: { path: { jobId: initial.id } }, body: { ...writeInput(draft), expectedUpdatedAt: revision } }) : await api.POST("/api/v1/jobs", { body: writeInput(draft) });
43+
const input = { ...writeInput(draft), ...(projectId ? { projectId } : {}) };
44+
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 });
4345
if (result.error || !result.data) {
4446
if (result.error?.error.code === "stale_job") { setReconciling(true); refreshConflict.mutate(); }
4547
throw new Error(validationMessage(result.error));

‎packages/app/src/features/jobs/routes.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function jobsRoutes(root: AnyRootRoute) {
2323
loader: ({ params }) => queryClient.ensureQueryData(jobQuery(params.jobId)),
2424
component: lazyRouteComponent(() => import("./detail"), "JobDetail"),
2525
});
26-
const create = createRoute({ getParentRoute: () => root, path: "/jobs/new", beforeLoad: guard, component: lazyRouteComponent(() => import("./editor"), "JobEditor") });
26+
const create = createRoute({ getParentRoute: () => root, path: "/jobs/new", beforeLoad: guard, validateSearch: (s: Record<string, unknown>) => ({ projectId: typeof s.projectId === "string" ? s.projectId : undefined }), component: lazyRouteComponent(() => import("./editor"), "JobEditor") });
2727
const edit = createRoute({ getParentRoute: () => root, path: "/jobs/$jobId/edit",
2828
beforeLoad: ({ params }) => { throw redirect({ to: "/jobs/$jobId/settings", params }); } });
2929
const settings = createRoute({ getParentRoute: () => root, path: "/jobs/$jobId/settings", beforeLoad: guard,

‎packages/app/src/features/projects/detail.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ import { useQuery } from "@tanstack/react-query";
33
import { Button, Card, Page } from "../../shared/ui";
44
import { projectQuery } from "./queries";
55
import { jobsQuery } from "../jobs/queries";
6-
export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); const jobs=useQuery(jobsQuery({q:"",projectId})); return <Page className="max-w-6xl"><Link to="/projects" className="text-sm font-semibold">Projects</Link>{project.data&&<><div className="mb-6 mt-3 flex items-center justify-between gap-4"><div><h1>{project.data.name}</h1><p className="text-sm text-slate-600 dark:text-slate-400">{project.data.description}</p></div><Link to="/jobs/new"><Button>Create job</Button></Link></div><Card>{jobs.isPending&&<p role="status">Loading jobs…</p>}{jobs.error&&<p role="alert">{jobs.error.message}</p>}{jobs.data?.items.map(job=><div key={job.id} className="border-b border-stone-200 py-3 last:border-0 dark:border-slate-800"><Link to="/jobs/$jobId" params={{jobId:job.id}} className="font-semibold">{job.name}</Link><span className="ml-3 text-sm text-slate-500">{job.enabled?"Enabled":"Disabled"}</span></div>)}{jobs.data?.items.length===0&&<p>No jobs in this project yet.</p>}</Card></>}</Page>; }
6+
export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); const jobs=useQuery(jobsQuery({q:"",projectId})); return <Page className="max-w-6xl"><Link to="/projects" className="text-sm font-semibold">Projects</Link>{project.data&&<><div className="mb-6 mt-3 flex items-center justify-between gap-4"><div><h1>{project.data.name}</h1><p className="text-sm text-slate-600 dark:text-slate-400">{project.data.description}</p></div><Link to="/jobs/new" search={{ projectId }}><Button>Create job</Button></Link></div><Card>{jobs.isPending&&<p role="status">Loading jobs…</p>}{jobs.error&&<p role="alert">{jobs.error.message}</p>}{jobs.data?.items.map(job=><div key={job.id} className="border-b border-stone-200 py-3 last:border-0 dark:border-slate-800"><Link to="/jobs/$jobId" params={{jobId:job.id}} className="font-semibold">{job.name}</Link><span className="ml-3 text-sm text-slate-500">{job.enabled?"Enabled":"Disabled"}</span></div>)}{jobs.data?.items.length===0&&<p>No jobs in this project yet.</p>}</Card></>}</Page>; }

0 commit comments

Comments
 (0)