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
6 changes: 5 additions & 1 deletion packages/api/src/flow-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(`${record.integrationKind}:${record.integrationId}`);
if (!value) throw new ServiceError(400, "invalid_request", "Execution target integration not found");
Expand Down
13 changes: 13 additions & 0 deletions packages/api/test/loading.postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/features/jobs/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <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 @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/features/jobs/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({ 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,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/features/projects/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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>; }
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>; }
Loading