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
23 changes: 21 additions & 2 deletions packages/api/src/flow-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ export const triggerSchema = z.discriminatedUnion("kind", [
}).strict() }).strict(),
z.object({ id: z.string().min(1).optional(), slug: z.string().regex(/^trigger-[1-9][0-9]*$/).optional(), kind: z.literal("jobIdle"), enabled: z.boolean().default(true), config: z.object({ sourceJobIds: z.array(z.string().min(1)).min(1) }).strict() }).strict(),
]);

/** Lower registry and repository names while keeping the case-sensitive tag. */
export function canonicalizeVmImage(value: string): string {
const trimmed = value.trim();
const digestAt = trimmed.indexOf("@");
const reference = digestAt < 0 ? trimmed : trimmed.slice(0, digestAt);
const digest = digestAt < 0 ? "" : trimmed.slice(digestAt);
const slash = reference.lastIndexOf("/");
const colon = reference.lastIndexOf(":");
const tag = colon > slash ? reference.slice(colon) : "";
const name = tag ? reference.slice(0, -tag.length) : reference;
return `${name.toLowerCase()}${tag}${digest}`;
}

const imageReferencePattern = String.raw`(?:(?:[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*\/)?[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*(?:\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)`;
const publicVmImage = (allowBlank: boolean) => (allowBlank ? z.string().trim().max(512) : z.string().trim().min(1).max(512))
.regex(new RegExp(allowBlank ? `^(?:${imageReferencePattern})?$` : `^${imageReferencePattern}$`),
"Enter a public Docker/OCI image reference without a URL scheme (for example, ghcr.io/owner/image:tag).")
.transform(canonicalizeVmImage);
export const jobInputSchema = z.object({
projectId: z.guid().optional(),
name: z.string().trim().min(1).max(120),
Expand All @@ -43,15 +62,15 @@ export const jobInputSchema = z.object({
concurrencyLimit: z.number().int().min(1).max(50).default(1),
executionConfigId: z.string().min(1),
tags: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/)).max(20).optional(),
vmImage: z.string().trim().max(512).regex(/^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?)?$/, "Enter a public Docker/OCI image reference.").optional(),
vmImage: publicVmImage(true).optional(),
model: z.string().trim().max(120).optional(),
effort: z.string().trim().max(40).optional(),
triggers: z.array(triggerSchema).max(50).default([]),
skillIds: z.array(z.guid()).max(50).optional(),
}).strict();
export const vmImageVerificationInput = z.object({
connectionId: z.string().min(1).max(200),
image: z.string().trim().min(1).max(512).regex(/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/),
image: publicVmImage(false),
tags: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/)).max(20),
agentKind: z.enum(["codex", "claude", "pi"]),
}).strict();
Expand Down
8 changes: 5 additions & 3 deletions packages/api/src/flow-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from "zod";
import { runPageResponse, fullRunResponse } from "./run-read-contracts";
import { triggerContextCatalog } from "./trigger-context";
import type { Env, OAuthProps } from "./types";
import { vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas";
import { canonicalizeVmImage, vmImageVerificationInput, type JobInput, type ManualInvocationInput } from "./flow-schemas";
import { nextOccurrence, validateScheduleConfig } from "./schedule";
import { databaseFor } from "./postgres/database";
import { IdentityRepository } from "./postgres/identity-repository";
Expand Down Expand Up @@ -228,19 +228,21 @@ export class ApiService {
async testJobConditions(input: unknown) { await this.authorize("jobs:write"); const parsed = jobConditionsTestSchema.parse(input); return evaluateWebhookConditions(parsed.conditions, parsed.webhook); }
async createJob(input: JobInput) {
await this.authorize("jobs:write"); const timestamp = new Date().toISOString(), id = crypto.randomUUID();
const vmImage = input.vmImage ? canonicalizeVmImage(input.vmImage) : input.vmImage;
await this.validateExecutionOptions(input.executionConfigId, input.model ?? "", input.effort ?? "", input.tags ?? []);
const triggers = this.normalizedTriggers(input.triggers).map(trigger => ({ ...trigger, jobId: id }));
const projectId = input.projectId ?? await this.projects().defaultProjectId();
const selectedSkills = input.skillIds ? await this.skillSelections(input.skillIds) : [];
const job: Job = { id, projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", ...(input.effort ? { effort: input.effort } : {}), executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(input.vmImage ? { image: input.vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, enabled: true, triggers, selectedSkills, createdAt: timestamp, updatedAt: timestamp };
const job: Job = { id, projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", ...(input.effort ? { effort: input.effort } : {}), executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(vmImage ? { image: vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, enabled: true, triggers, selectedSkills, createdAt: timestamp, updatedAt: timestamp };
try { return this.presentJob(await this.jobs().create(job)); } catch (error: any) { if (error?.code === "23505") throw new ServiceError(409, "conflict", "Job slug is already in use"); throw error; }
}
async updateJob(jobId: string, input: JobInput & { expectedUpdatedAt?: string }) {
await this.authorize("jobs:write"); const repository = this.jobs(), current = await repository.getJob(jobId); if (!current) throw new ServiceError(404, "not_found", "Job not found");
const vmImage = input.vmImage ? canonicalizeVmImage(input.vmImage) : input.vmImage;
await this.validateExecutionOptions(input.executionConfigId, input.model ?? "", input.effort ?? "", input.tags ?? []);
const triggers = this.normalizedTriggers(input.triggers, current.triggers).map(trigger => ({ ...trigger, jobId }));
const selectedSkills = input.skillIds === undefined ? current.selectedSkills : await this.skillSelections(input.skillIds);
const updated = await repository.update({ ...current, projectId: input.projectId ?? current.projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", effort: input.effort, executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(input.vmImage ? { image: input.vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, triggers, selectedSkills, updatedAt: new Date().toISOString() }, input.expectedUpdatedAt).catch(error => { if (error instanceof StaleJobEdit) throw new ServiceError(409, "stale_job", "This job changed. Reload its configuration before saving."); throw error; });
const updated = await repository.update({ ...current, projectId: input.projectId ?? current.projectId, name: input.name, slug: input.slug, promptTemplate: input.promptTemplate, runNameTemplate: input.runNameTemplate ?? "", tags: input.tags ?? [], model: input.model ?? "", effort: input.effort, executionConfig: { ...await this.executionConfig(input.executionConfigId), ...(vmImage ? { image: vmImage } : {}) }, concurrencyLimit: input.concurrencyLimit, triggers, selectedSkills, updatedAt: new Date().toISOString() }, input.expectedUpdatedAt).catch(error => { if (error instanceof StaleJobEdit) throw new ServiceError(409, "stale_job", "This job changed. Reload its configuration before saving."); throw error; });
await this.wakeScheduler();
return this.presentJob(updated);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/api/src/protected-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ function errorResponse(error: unknown): Response {
if (error instanceof ExeIntegrationError) return Response.json({ error: { code: "invalid_request", message: error.message } }, { status: 400 });
if (error instanceof URIError) return Response.json({ error: { code: "invalid_request", message: "Invalid path encoding" } }, { status: 400 });
if (error instanceof InvocationError && error.code === "queue_full") return Response.json({ error: { code: error.code, message: error.message } }, { status: 409 });
if (error instanceof z.ZodError) return Response.json({ error: { code: "invalid_request", message: "Request validation failed", details: error.issues } }, { status: 400 });
if (error instanceof z.ZodError) {
const imageIssue = error.issues.find(issue => issue.path.at(-1) === "vmImage" || issue.path.at(-1) === "image");
const field = imageIssue?.path.join(".");
const message = imageIssue && field ? `Invalid ${field}: ${imageIssue.message}` : "Request validation failed";
return Response.json({ error: { code: "invalid_request", message, details: error.issues } }, { status: 400 });
}
if (error instanceof ServiceError) {
const headers = error.code === "insufficient_scope" ? { "WWW-Authenticate": `Bearer error="insufficient_scope"` } : undefined;
return Response.json({ error: { code: error.code, message: error.message } }, { status: error.status, headers });
Expand Down
11 changes: 9 additions & 2 deletions packages/api/test/api-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,15 @@ describe("versioned API contract", () => {
it("accepts public image references and publishes tenant-scoped verification operations", () => {
const job = { name: "Image job", slug: "image-job", promptTemplate: "Run", executionConfigId: "exe", vmImage: "ghcr.io/org/image:tag", triggers: [] };
expect(jobInputSchema.safeParse(job).success).toBe(true);
expect(jobInputSchema.safeParse({ ...job, vmImage: "https://private.example/image" }).success).toBe(false);
expect(vmImageVerificationInput.safeParse({ connectionId: "exe", image: "ghcr.io/org/image:tag", tags: [], agentKind: "codex" }).success).toBe(true);
expect(jobInputSchema.parse({ ...job, vmImage: "ghcr.io/GenerationSoftware/anarack:ReleaseA" }).vmImage).toBe("ghcr.io/generationsoftware/anarack:ReleaseA");
expect(jobInputSchema.parse({ ...job, vmImage: "" }).vmImage).toBe("");
const digest = `ghcr.io/GenerationSoftware/anarack:ReleaseA@sha256:${"a".repeat(64)}`;
expect(jobInputSchema.parse({ ...job, vmImage: digest }).vmImage).toBe(`ghcr.io/generationsoftware/anarack:ReleaseA@sha256:${"a".repeat(64)}`);
const invalid = jobInputSchema.safeParse({ ...job, vmImage: "https://private.example/image" });
expect(invalid.success).toBe(false);
if (!invalid.success) expect(invalid.error.issues[0].message).toContain("without a URL scheme");
expect(vmImageVerificationInput.parse({ connectionId: "exe", image: "ghcr.io/GenerationSoftware/anarack:ReleaseA", tags: [], agentKind: "codex" }).image).toBe("ghcr.io/generationsoftware/anarack:ReleaseA");
expect(vmImageVerificationInput.safeParse({ connectionId: "exe", image: "bad image", tags: [], agentKind: "codex" }).success).toBe(false);
const post = API_ROUTES.find(route => route.path === "/api/v1/vm-image-verifications" && route.method === "POST");
const get = API_ROUTES.find(route => route.path === "/api/v1/vm-image-verifications/{verificationId}" && route.method === "GET");
expect(post?.scope).toBe("jobs:write"); expect(get?.scope).toBe("jobs:read");
Expand Down
3 changes: 2 additions & 1 deletion packages/api/test/loading.postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ describe.skipIf(!url)("request loading with PostgreSQL", () => {
it("creates a job with a deterministic migrated execution integration UUID", async () => {
const target = "local";
try {
const job = await new ApiService(env, auth).createJob({ name: "Migrated target job", slug: "migrated-target-job", promptTemplate: "Do work", executionConfigId: target, concurrencyLimit: 1, triggers: [{ kind: "manual", enabled: true, config: {} }] });
const job = await new ApiService(env, auth).createJob({ name: "Migrated target job", slug: "migrated-target-job", promptTemplate: "Do work", executionConfigId: target, vmImage: "ghcr.io/GenerationSoftware/anarack:ReleaseA", concurrencyLimit: 1, triggers: [{ kind: "manual", enabled: true, config: {} }] });
expect(job.executionConfigId).toBe(target);
expect(job.executionConfig).toMatchObject({ connectionId: "local", agentKind: "codex" });
expect(job.executionConfig.image).toBe("ghcr.io/generationsoftware/anarack:ReleaseA");
} finally {
await db.pool.query("DELETE FROM app.jobs WHERE execution_config->>'targetId'=$1", [target]);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/features/jobs/editor-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ export type JobInput = components["schemas"]["JobInput"];
export type Job = operations["get_api_v1_jobs_jobId"]["responses"][200]["content"]["application/json"];
export type Trigger = JobInput["triggers"][number];
export const emptyJob = (): JobInput => ({ name: "", slug: "", promptTemplate: "", runNameTemplate: "", executionConfigId: "", concurrencyLimit: 1, tags: [], vmImage: "", model: "", effort: "", skillIds: [], triggers: [{ kind: "manual", slug: "trigger-1", enabled: true, config: {} }] });
export function canonicalizeVmImage(value: string): string {
const trimmed = value.trim(), digestAt = trimmed.indexOf("@"), reference = digestAt < 0 ? trimmed : trimmed.slice(0, digestAt), digest = digestAt < 0 ? "" : trimmed.slice(digestAt);
const slash = reference.lastIndexOf("/"), colon = reference.lastIndexOf(":"), tag = colon > slash ? reference.slice(colon) : "", name = tag ? reference.slice(0, -tag.length) : reference;
return `${name.toLowerCase()}${tag}${digest}`;
}
export function editableJob(job: Job): JobInput {
return { projectId: job.projectId, name: job.name, slug: job.slug, promptTemplate: job.promptTemplate, runNameTemplate: job.runNameTemplate, executionConfigId: job.executionConfigId, concurrencyLimit: job.concurrencyLimit, tags: job.tags ?? [], vmImage: job.executionConfig?.image ?? "", model: job.model, effort: job.effort ?? "", skillIds: job.skills?.map(skill => skill.id) ?? [], triggers: job.triggers.map(trigger => {
const { id, slug, kind, enabled } = trigger;
Expand Down Expand Up @@ -34,7 +39,7 @@ export function validateJobInput(input: JobInput): string | undefined {
if (!Number.isInteger(input.concurrencyLimit) || input.concurrencyLimit < 1 || input.concurrencyLimit > 50) return "Concurrency limit must be between 1 and 50.";
if (!input.executionConfigId) return "Choose an execution integration.";
if ((input.tags?.length ?? 0) > 20 || input.tags?.some(tag => !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(tag))) return "VM tags are invalid.";
if (input.vmImage && !/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/.test(input.vmImage.trim())) return "Enter a public Docker/OCI image reference.";
if (input.vmImage && !/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/.test(canonicalizeVmImage(input.vmImage))) return input.vmImage.trim().startsWith("https://") ? "Enter an image reference without a URL scheme." : "Enter a public Docker/OCI image reference.";
if (input.triggers.length > 50) return "A job can have at most 50 triggers.";
for (const trigger of input.triggers) {
if (!trigger.slug || !/^trigger-[1-9][0-9]*$/.test(trigger.slug)) return "Each trigger needs a valid stable identifier.";
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/features/jobs/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "factorize-api-client";
import { jobQuery } from "./queries";
import { metadataQuery, targetsQuery } from "./editor-queries";
import { editableJob, emptyJob, reconcile, validateJobInput, writeInput, type Job, type JobInput } from "./editor-state";
import { canonicalizeVmImage, editableJob, emptyJob, reconcile, validateJobInput, writeInput, type Job, type JobInput } from "./editor-state";
import { TemplateField } from "./template-field";
import { TriggerEditor } from "./trigger-editor";
import { messageOf } from "../auth/session";
Expand Down Expand Up @@ -37,7 +37,7 @@ function EditorForm({ initial }: { initial?: Job }) {
const project = useQuery({ ...projectQuery(initial?.projectId ?? ""), enabled: !!initial?.projectId });
const projects = useQuery({ ...projectsQuery, enabled: !initial });
const target = targets.data?.find(t => t.id === draft.executionConfigId);
const verificationFingerprint = JSON.stringify([draft.executionConfigId, draft.vmImage?.trim() ?? "", draft.tags ?? [], target?.agentKind ?? ""]);
const verificationFingerprint = JSON.stringify([draft.executionConfigId, canonicalizeVmImage(draft.vmImage ?? ""), draft.tags ?? [], target?.agentKind ?? ""]);
const verificationKey = `factorize-vm-image-verification:${initial?.id ?? "new"}`;
const [verificationRef, setVerificationRef] = useState<{ fingerprint: string; id: string } | null>(() => { try { return JSON.parse(localStorage.getItem(verificationKey) ?? "null"); } catch { return null; } });
const currentVerificationFingerprint = useRef(verificationFingerprint); currentVerificationFingerprint.current = verificationFingerprint;
Expand All @@ -50,7 +50,7 @@ function EditorForm({ initial }: { initial?: Job }) {
const [projectDialog, setProjectDialog] = useState(false);
const onlyProject = projects.data?.length === 1 ? projects.data[0] : undefined;
useEffect(() => { if (!initial && !draft.projectId && !projectId && onlyProject) setDraft(previous => ({ ...previous, projectId: onlyProject.id })); }, [initial, draft.projectId, projectId, onlyProject]);
const update = <K extends keyof JobInput>(key: K, value: JobInput[K]) => { setSaved(false); if (["vmImage", "executionConfigId", "tags"].includes(key)) { verificationGeneration.current++; setVerificationRef(null); localStorage.removeItem(verificationKey); } setDraft(previous => ({ ...previous, [key]: value })); };
const update = <K extends keyof JobInput>(key: K, value: JobInput[K]) => { setSaved(false); const sameImage = key === "vmImage" && canonicalizeVmImage(draft.vmImage ?? "") === canonicalizeVmImage(typeof value === "string" ? value : ""); if (["executionConfigId", "tags"].includes(key) || (key === "vmImage" && !sameImage)) { verificationGeneration.current++; setVerificationRef(null); localStorage.removeItem(verificationKey); } setDraft(previous => ({ ...previous, [key]: value })); };
const refreshConflict = useMutation({ retry: false, mutationFn: async () => {
if (!initial) throw new Error("No job selected");
const { data, error } = await api.GET("/api/v1/jobs/{jobId}", { params: { path: { jobId: initial.id } } });
Expand Down
3 changes: 3 additions & 0 deletions packages/app/test/editor.browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ test("image verification is explicit, autosave never provisions, and stale verif
await page.getByRole("status").filter({ hasText: "Not verified" }).waitFor(); assert.equal(createCount, 1);
await page.getByRole("button", { name: "Verify image" }).click();
await page.getByRole("status").filter({ hasText: "Verification passed" }).waitFor(); assert.equal(createCount, 2);
await page.getByLabel("VM image").fill("GHCR.IO/ACME/base:two");
await page.getByRole("status").filter({ hasText: "Job settings saved." }).waitFor();
await page.getByRole("status").filter({ hasText: "Verified: Verification passed" }).waitFor();
await page.getByLabel("VM tags").selectOption(["github"]);
await page.getByRole("status").filter({ hasText: "Not verified" }).waitFor(); await context.close();
});
Expand Down
Loading
Loading