diff --git a/packages/api/src/flow-service.ts b/packages/api/src/flow-service.ts index 130fa3c..075fb11 100644 --- a/packages/api/src/flow-service.ts +++ b/packages/api/src/flow-service.ts @@ -60,7 +60,11 @@ export class ApiService { private targetRecords() { return new ExecutionTargetRepository(databaseFor(this.env), this.auth.tenantId); } private async executionTarget(id: string) { - 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; + // Target IDs created by the migration are deterministic md5-derived UUIDs, + // so they do not necessarily have RFC 4122 version/variant bits. Any + // UUID-shaped value is still safe to use in the UUID query; non-UUID values + // are the legacy connection IDs. + 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; if (record) { const value = await this.connections().get(`${record.integrationKind}:${record.integrationId}`); if (!value) throw new ServiceError(400, "invalid_request", "Execution target integration not found"); diff --git a/packages/api/test/loading.postgres.test.ts b/packages/api/test/loading.postgres.test.ts index a9d94e3..ba702f3 100644 --- a/packages/api/test/loading.postgres.test.ts +++ b/packages/api/test/loading.postgres.test.ts @@ -58,6 +58,19 @@ describe.skipIf(!url)("request loading with PostgreSQL", () => { expect(query).toHaveBeenCalledTimes(3); // membership + connections + reference counts } finally { query.mockRestore(); } }); + + it("creates a job with a deterministic migrated execution target UUID", async () => { + const target = (await db.pool.query(`INSERT INTO app.execution_targets(tenant_id,id,name,integration_kind,integration_id,agent_kind) + VALUES ($1::uuid,md5($1::text || ':target:local')::uuid,'Migrated local target','exe','local','codex') RETURNING id`, [tenantId])).rows[0].id; + try { + 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: {} }] }); + expect(job.executionTargetId).toBe(target); + expect(job.executionTarget).toMatchObject({ connectionId: "local", agentKind: "codex" }); + } finally { + await db.pool.query("DELETE FROM app.jobs WHERE execution_target->>'targetId'=$1", [target]); + await db.pool.query("DELETE FROM app.execution_targets WHERE id=$1", [target]); + } + }); it("enforces tenant and scope boundaries, email verification, cookie expiry and session revocation", async () => { await expect(new ApiService(env, { ...auth, tenantId: otherTenant }).listJobs()).rejects.toMatchObject({ status: 401 }); await expect(new ApiService(env, { ...auth, scopes: ["runs:read"] }).listJobs()).rejects.toMatchObject({ status: 403 }); diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx index 314d2bc..00e806b 100644 --- a/packages/app/src/features/jobs/editor.tsx +++ b/packages/app/src/features/jobs/editor.tsx @@ -31,6 +31,7 @@ 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); const target = targets.data?.find(t => t.id === draft.executionTargetId); + const projectId = new URLSearchParams(location.searchStr).get("projectId") ?? undefined; const update = (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"); @@ -39,7 +40,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 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) }); + const input = { ...writeInput(draft), ...(projectId ? { projectId } : {}) }; + 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(); } throw new Error(validationMessage(result.error)); diff --git a/packages/app/src/features/jobs/routes.tsx b/packages/app/src/features/jobs/routes.tsx index e3870f1..d50aa4e 100644 --- a/packages/app/src/features/jobs/routes.tsx +++ b/packages/app/src/features/jobs/routes.tsx @@ -23,7 +23,7 @@ export function jobsRoutes(root: AnyRootRoute) { loader: ({ params }) => queryClient.ensureQueryData(jobQuery(params.jobId)), component: lazyRouteComponent(() => import("./detail"), "JobDetail"), }); - const create = createRoute({ getParentRoute: () => root, path: "/jobs/new", beforeLoad: guard, component: lazyRouteComponent(() => import("./editor"), "JobEditor") }); + const create = createRoute({ getParentRoute: () => root, path: "/jobs/new", beforeLoad: guard, validateSearch: (s: Record) => ({ projectId: typeof s.projectId === "string" ? s.projectId : undefined }), component: lazyRouteComponent(() => import("./editor"), "JobEditor") }); const edit = createRoute({ getParentRoute: () => root, path: "/jobs/$jobId/edit", beforeLoad: ({ params }) => { throw redirect({ to: "/jobs/$jobId/settings", params }); } }); const settings = createRoute({ getParentRoute: () => root, path: "/jobs/$jobId/settings", beforeLoad: guard, diff --git a/packages/app/src/features/projects/detail.tsx b/packages/app/src/features/projects/detail.tsx index 8735008..6f8db21 100644 --- a/packages/app/src/features/projects/detail.tsx +++ b/packages/app/src/features/projects/detail.tsx @@ -3,4 +3,4 @@ import { useQuery } from "@tanstack/react-query"; import { Button, Card, Page } from "../../shared/ui"; import { projectQuery } from "./queries"; import { jobsQuery } from "../jobs/queries"; -export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); const jobs=useQuery(jobsQuery({q:"",projectId})); return Projects{project.data&&<>

{project.data.name}

{project.data.description}

{jobs.isPending&&

Loading jobs…

}{jobs.error&&

{jobs.error.message}

}{jobs.data?.items.map(job=>
{job.name}{job.enabled?"Enabled":"Disabled"}
)}{jobs.data?.items.length===0&&

No jobs in this project yet.

}
}
; } +export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); const jobs=useQuery(jobsQuery({q:"",projectId})); return Projects{project.data&&<>

{project.data.name}

{project.data.description}

{jobs.isPending&&

Loading jobs…

}{jobs.error&&

{jobs.error.message}

}{jobs.data?.items.map(job=>
{job.name}{job.enabled?"Enabled":"Disabled"}
)}{jobs.data?.items.length===0&&

No jobs in this project yet.

}
}
; }