From 176c4e2151192e55402b19bf15b676b63ace2c08 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 20 Oct 2026 20:21:25 +0000 Subject: [PATCH] feat: add execution option autocomplete --- packages/api-client/src/schema.d.ts | 18 +++++++ packages/api/src/exe.ts | 9 ++++ packages/api/src/flow-service.ts | 15 ++++++ .../api/src/postgres/integration-service.ts | 15 +++--- packages/api/src/settings-contracts.ts | 2 +- packages/api/test/execution-backend.test.ts | 7 ++- .../app/src/features/jobs/editor-queries.ts | 2 +- packages/app/src/features/jobs/editor.tsx | 4 +- packages/docs/openapi.yaml | 54 +++++++++++++++++++ 9 files changed, 115 insertions(+), 11 deletions(-) diff --git a/packages/api-client/src/schema.d.ts b/packages/api-client/src/schema.d.ts index a22c6aee..c1c2b38d 100644 --- a/packages/api-client/src/schema.d.ts +++ b/packages/api-client/src/schema.d.ts @@ -2724,6 +2724,12 @@ export interface operations { credentialStatus: "connected"; /** @default [] */ accountTags: string[]; + /** @enum {string} */ + agentKind?: "codex" | "claude" | "pi"; + /** @default [] */ + models: string[]; + /** @default [] */ + efforts: string[]; }[]; }; }; @@ -2962,6 +2968,12 @@ export interface operations { credentialStatus: "connected"; /** @default [] */ accountTags: string[]; + /** @enum {string} */ + agentKind?: "codex" | "claude" | "pi"; + /** @default [] */ + models: string[]; + /** @default [] */ + efforts: string[]; } | null; exeConnections: { connectionId: string; @@ -2969,6 +2981,12 @@ export interface operations { credentialStatus: "connected"; /** @default [] */ accountTags: string[]; + /** @enum {string} */ + agentKind?: "codex" | "claude" | "pi"; + /** @default [] */ + models: string[]; + /** @default [] */ + efforts: string[]; }[]; ampConnections: { connectionId: string; diff --git a/packages/api/src/exe.ts b/packages/api/src/exe.ts index d2ab41e5..ee6be47c 100644 --- a/packages/api/src/exe.ts +++ b/packages/api/src/exe.ts @@ -4,11 +4,20 @@ export interface ExeConnection { apiToken: string; /** Legacy fields are read only during the migration window. Targets are authoritative. */ tags?: string[]; + availableTags?: string[]; agentKind?: AgentKind; models?: string[]; modelsRefreshedAt?: string; + modelsByAgent?: Partial>; + modelsRefreshedAtByAgent?: Partial>; } +export const effortOptions: Record = { + codex: ["minimal", "low", "medium", "high", "xhigh"], + claude: ["low", "medium", "high"], + pi: [], +}; + export interface ExeRunConnection extends ExeConnection { agentCommand?: string; model?: string; diff --git a/packages/api/src/flow-service.ts b/packages/api/src/flow-service.ts index adfe98fc..6197ce8d 100644 --- a/packages/api/src/flow-service.ts +++ b/packages/api/src/flow-service.ts @@ -32,6 +32,7 @@ import { ACCESS_SCOPES, accessTokenDigest, issueAccessToken, normalizeScopes } f import { TraceProjectionRepository } from "./postgres/trace-projection-repository"; import { replayTrace, traceReplaySchema, traceReplayRunIdSchema, TraceReplayError } from "./trace-replay"; import { ProjectRepository } from "./postgres/project-repository"; +import { effortOptions, type ExeConnection } from "./exe"; export class ServiceError extends Error { constructor(public status: number, public code: string, message: string) { super(message); } @@ -71,6 +72,18 @@ export class ApiService { return { targetId: id, connectionId: id, workspace: "ephemeral", cwd: "/home/exedev/workspace", agentKind: value.agentKind, tags: value.tags ?? [] }; } + private async validateExecutionOptions(id: string, model: string, effort: string, tags: string[]) { + if (id.startsWith("amp:")) return; + const connection = await this.connections().get(`exe:${id}`); + if (!connection) throw new ServiceError(400, "invalid_request", "execution integration not found"); + const availableModels = connection.modelsByAgent?.[connection.agentKind ?? "codex"] ?? connection.models ?? []; + const availableEfforts = effortOptions[connection.agentKind ?? "codex"] ?? []; + if (model && availableModels.length && !availableModels.includes(model)) throw new ServiceError(400, "invalid_request", "Model is not available on this agent."); + if (effort && availableEfforts.length && !availableEfforts.includes(effort)) throw new ServiceError(400, "invalid_request", "Effort is not available on this agent."); + const availableTags = new Set(connection.availableTags ?? connection.tags ?? []); + if (tags.some(tag => !availableTags.has(tag))) throw new ServiceError(400, "invalid_request", "VM tags must be selected from the available exe.dev labels."); + } + private normalizedTriggers(input: JobInput["triggers"], existing: Trigger[] = []): Trigger[] { const source = input.some(trigger => trigger.kind === "manual") ? input : [{ kind: "manual" as const, enabled: true, config: {} }, ...input]; const prior = new Map(existing.map(trigger => [trigger.id, trigger])), used = new Set(existing.map(trigger => trigger.slug)); @@ -198,6 +211,7 @@ 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(); + 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 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), concurrencyLimit: input.concurrencyLimit, enabled: true, triggers, createdAt: timestamp, updatedAt: timestamp }; @@ -205,6 +219,7 @@ export class ApiService { } 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"); + await this.validateExecutionOptions(input.executionConfigId, input.model ?? "", input.effort ?? "", input.tags ?? []); const triggers = this.normalizedTriggers(input.triggers, current.triggers).map(trigger => ({ ...trigger, jobId })); 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), concurrencyLimit: input.concurrencyLimit, triggers, 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(); diff --git a/packages/api/src/postgres/integration-service.ts b/packages/api/src/postgres/integration-service.ts index 49c23cc3..76052953 100644 --- a/packages/api/src/postgres/integration-service.ts +++ b/packages/api/src/postgres/integration-service.ts @@ -1,6 +1,6 @@ import { AmpBackend, type AmpConnection } from "../amp-backend"; import { ExeIntegrationError, ExeVmBackend } from "../exe-vm-backend"; -import type { ExeConnection } from "../exe"; +import { effortOptions, type ExeConnection } from "../exe"; import type { AmpConnectionInput, ExeConnectionInput, TailIntegrationInput } from "../types"; import { signTailDelivery, verifyTailDelivery } from "../cloudflare-tail"; import type { Database } from "./database"; @@ -21,7 +21,8 @@ export class IntegrationService { if (!apiToken) throw new ExeIntegrationError("An exe.dev HTTPS token is required"); const tags = Array.isArray(value.tags) ? value.tags.map(String).map(tag => tag.trim()).filter(Boolean) : []; if (tags.length > 20 || tags.some(tag => !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(tag))) throw new ExeIntegrationError("VM tags are invalid"); - const candidate: ExeConnection = { apiToken, tags, ...(value.agentKind ? { agentKind: value.agentKind } : {}) }; + const priorCache = existing?.modelsByAgent ?? (existing?.agentKind && existing.models ? { [existing.agentKind]: existing.models } : {}); + const candidate: ExeConnection = { ...existing, apiToken, tags, ...(value.agentKind ? { agentKind: value.agentKind } : {}), modelsByAgent: priorCache }; const backend = new ExeVmBackend(candidate), permissions = await backend.testPermissions(); if (!permissions.ok) { if (permissions.checks.some(check => check.status === 401)) throw new ExeIntegrationError("exe.dev rejected the account token. Check that it is valid and has not expired."); @@ -29,10 +30,12 @@ export class IntegrationService { const failed = permissions.checks.find(check => !check.ok); throw new ExeIntegrationError(`exe.dev credential verification failed${failed ? ` for ${failed.requestBody} (HTTP ${failed.status})` : ""}. Try again shortly.`); } - const validation = value.agentKind ? await backend.validateAgentAndModels(value.agentKind) : undefined, connectionId = value.connectionId || crypto.randomUUID(); - const saved = { ...candidate, ...(validation ? { models: validation.models, modelsRefreshedAt: timestamp() } : {}) }; + const cached = value.agentKind ? candidate.modelsByAgent?.[value.agentKind] : undefined; + const validation = value.agentKind && !cached ? await backend.validateAgentAndModels(value.agentKind) : undefined, connectionId = value.connectionId || crypto.randomUUID(); + const modelsByAgent = value.agentKind && validation ? { ...candidate.modelsByAgent, [value.agentKind]: validation.models } : candidate.modelsByAgent; + const saved = { ...candidate, availableTags: permissions.tags, ...(modelsByAgent ? { modelsByAgent } : {}), ...(value.agentKind ? { models: modelsByAgent?.[value.agentKind] ?? [], modelsRefreshedAt: existing?.modelsRefreshedAtByAgent?.[value.agentKind] ?? timestamp(), modelsRefreshedAtByAgent: { ...existing?.modelsRefreshedAtByAgent, ...(validation ? { [value.agentKind]: timestamp() } : {}) } } : {}) }; await this.connections.put(`exe:${connectionId}`, saved); - return { ok: true, connectionId, ...(validation ? { models: validation.models } : {}) }; + return { ok: true, connectionId, models: saved.models ?? [] }; } async testExe(input: Partial) { const saved = input.connectionId ? await this.connections.get(`exe:${input.connectionId}`) : null; @@ -90,7 +93,7 @@ export class IntegrationService { const connectionsPromise = this.connections.all(["linear", "clickup", "exe:", "amp:", "cloudflare-tail:"]); const [connections, tails] = await Promise.all([connectionsPromise, this.tails(connectionsPromise)]); const linear = connections.get("linear"), clickup = connections.get("clickup"); - const exeConnections = [...connections].filter(([kind]) => kind.startsWith("exe:")).map(([kind, value]) => ({ connectionId: kind.slice(4), credentialStatus: "connected" as const, accountTags: tags(value?.tags) })); + const exeConnections = [...connections].filter(([kind]) => kind.startsWith("exe:")).map(([kind, value]) => { const agentKind = value?.agentKind; return { connectionId: kind.slice(4), credentialStatus: "connected" as const, accountTags: tags(value?.availableTags ?? value?.tags), ...(agentKind ? { agentKind } : {}), models: (agentKind && (value?.modelsByAgent?.[agentKind] ?? value?.models)) ?? [], efforts: agentKind ? effortOptions[agentKind as keyof typeof effortOptions] ?? [] : [] }; }); const ampConnections = [...connections].filter(([kind]) => kind.startsWith("amp:")).map(([kind, value]) => ({ connectionId: kind.slice(4), project: text(value?.project) ?? "Unnamed Amp project", ...(text(value?.apiBaseUrl) ? { apiBaseUrl: text(value.apiBaseUrl) } : {}) })); return { linear: linear ? { organizationName: text(linear.organizationName) ?? null, viewerEmail: text(linear.viewerEmail) ?? null } : null, clickup: clickup ? { teamName: text(clickup.teamName) ?? null } : null, exe: exeConnections[0] ?? null, exeConnections, ampConnections, cloudflareTail: { count: tails.length, installations: tails } }; } diff --git a/packages/api/src/settings-contracts.ts b/packages/api/src/settings-contracts.ts index 01b3f0f3..b4afcffa 100644 --- a/packages/api/src/settings-contracts.ts +++ b/packages/api/src/settings-contracts.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { tailIntegration } from "./editor-contracts"; const timestamp = z.string(); -export const exeConnection = z.object({ connectionId: z.string(), credentialStatus: z.literal("connected"), accountTags: z.array(z.string()).default([]) }); +export const exeConnection = z.object({ connectionId: z.string(), credentialStatus: z.literal("connected"), accountTags: z.array(z.string()).default([]), agentKind: z.enum(["codex", "claude", "pi"]).optional(), models: z.array(z.string()).default([]), efforts: z.array(z.string()).default([]) }); export const ampConnection = z.object({ connectionId: z.string(), project: z.string(), apiBaseUrl: z.string().optional() }); export const integrationStatus = z.object({ linear: z.object({ organizationName: z.string().nullable(), viewerEmail: z.string().nullable() }).nullable(), clickup: z.object({ teamName: z.string().nullable() }).nullable(), exe: exeConnection.nullable(), exeConnections: z.array(exeConnection), ampConnections: z.array(ampConnection), cloudflareTail: z.object({ count: z.number().int(), installations: z.array(tailIntegration) }) }); export const authorizedClient = z.object({ grantId: z.string(), clientId: z.string(), clientName: z.string(), scopes: z.array(z.string()), authorizationDate: timestamp, expiresAt: timestamp.nullable(), lastUsedAt: timestamp.nullable() }); diff --git a/packages/api/test/execution-backend.test.ts b/packages/api/test/execution-backend.test.ts index d4bd39dc..57fb2b10 100644 --- a/packages/api/test/execution-backend.test.ts +++ b/packages/api/test/execution-backend.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ExeVmBackend, isOwnedVmName, tagsFromInventory, vmNameFor } from "../src/exe-vm-backend"; import type { ExecutionBackend, ExecutionObservation, LaunchRequest, RunHandle } from "../src/execution"; -import type { ExeRunConnection } from "../src/exe"; +import { effortOptions, type ExeRunConnection } from "../src/exe"; import { agentDriver } from "../src/agent-driver"; afterEach(() => vi.unstubAllGlobals()); @@ -9,6 +9,11 @@ afterEach(() => vi.unstubAllGlobals()); const connection = { apiToken: "secret", agentKind: "codex", tags: ["github", "llm"], model: "gpt-5.5", effort: "high" } satisfies ExeRunConnection; describe("ExeVmBackend", () => { + it("defines the supported effort choices by agent", () => { + expect(effortOptions.codex).toEqual(["minimal", "low", "medium", "high", "xhigh"]); + expect(effortOptions.claude).toEqual(["low", "medium", "high"]); + expect(effortOptions.pi).toEqual([]); + }); it.each([true, false])("terminates the writer while preserving the VM (stop succeeds=%s)", async succeeds => { const commands: string[] = []; vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { diff --git a/packages/app/src/features/jobs/editor-queries.ts b/packages/app/src/features/jobs/editor-queries.ts index 4a826e9a..23f15ad0 100644 --- a/packages/app/src/features/jobs/editor-queries.ts +++ b/packages/app/src/features/jobs/editor-queries.ts @@ -4,7 +4,7 @@ import { messageOf } from "../auth/session"; export const targetsQuery = queryOptions({ queryKey: ["editor", "integrations"], queryFn: async ({ signal }) => { const { data, error } = await api.GET("/api/v1/integrations", { signal }); if (!data || error) throw new Error(messageOf(error)); - return [...data.exeConnections.map(connection => ({ id: connection.connectionId, name: "exe.dev", kind: "exe" as const, agentKind: "codex", efforts: ["minimal", "low", "medium", "high", "xhigh"] as string[] })), ...data.ampConnections.map(connection => ({ id: `amp:${connection.connectionId}`, name: `Amp · ${connection.project}`, kind: "amp" as const, agentKind: "Amp", efforts: [] as string[] }))]; + return [...data.exeConnections.map(connection => ({ id: connection.connectionId, name: "exe.dev", kind: "exe" as const, agentKind: connection.agentKind ?? "codex", models: connection.models ?? [], tags: connection.accountTags ?? [], efforts: connection.efforts ?? [] })), ...data.ampConnections.map(connection => ({ id: `amp:${connection.connectionId}`, name: `Amp · ${connection.project}`, kind: "amp" as const, agentKind: "Amp", models: [] as string[], tags: [] as string[], efforts: [] as string[] }))]; }, staleTime: 60_000 }); export const metadataQuery = queryOptions({ queryKey: ["editor", "templates"], queryFn: async ({ signal }) => { const { data, error } = await api.GET("/api/v1/trigger-contexts", { signal }); diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx index d2d2bae4..2d61dd27 100644 --- a/packages/app/src/features/jobs/editor.tsx +++ b/packages/app/src/features/jobs/editor.tsx @@ -72,8 +72,8 @@ function EditorForm({ initial }: { initial?: Job }) {

Execution

Choose an integration for this job, then configure its VM tags, model, effort, and concurrency.

{targets.isPending &&

Loading integrations…

}{targets.error &&

{targets.error.message}

}{targets.data?.length === 0 &&

Connect an execution provider in settings before creating a job.

} - - + +

Prompt and context

Write instructions using context supplied by your triggers.

update("promptTemplate", value)} triggers={draft.triggers} /> diff --git a/packages/docs/openapi.yaml b/packages/docs/openapi.yaml index 25625bac..9a5e1e0d 100644 --- a/packages/docs/openapi.yaml +++ b/packages/docs/openapi.yaml @@ -2417,10 +2417,28 @@ paths: type: array items: type: string + agentKind: + type: string + enum: + - codex + - claude + - pi + models: + default: [] + type: array + items: + type: string + efforts: + default: [] + type: array + items: + type: string required: - connectionId - credentialStatus - accountTags + - models + - efforts additionalProperties: false "400": $ref: "#/components/responses/Error400" @@ -2844,10 +2862,28 @@ paths: type: array items: type: string + agentKind: + type: string + enum: + - codex + - claude + - pi + models: + default: [] + type: array + items: + type: string + efforts: + default: [] + type: array + items: + type: string required: - connectionId - credentialStatus - accountTags + - models + - efforts additionalProperties: false - type: "null" exeConnections: @@ -2865,10 +2901,28 @@ paths: type: array items: type: string + agentKind: + type: string + enum: + - codex + - claude + - pi + models: + default: [] + type: array + items: + type: string + efforts: + default: [] + type: array + items: + type: string required: - connectionId - credentialStatus - accountTags + - models + - efforts additionalProperties: false ampConnections: type: array