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
18 changes: 18 additions & 0 deletions packages/api-client/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2724,6 +2724,12 @@ export interface operations {
credentialStatus: "connected";
/** @default [] */
accountTags: string[];
/** @enum {string} */
agentKind?: "codex" | "claude" | "pi";
/** @default [] */
models: string[];
/** @default [] */
efforts: string[];
}[];
};
};
Expand Down Expand Up @@ -2962,13 +2968,25 @@ export interface operations {
credentialStatus: "connected";
/** @default [] */
accountTags: string[];
/** @enum {string} */
agentKind?: "codex" | "claude" | "pi";
/** @default [] */
models: string[];
/** @default [] */
efforts: string[];
} | null;
exeConnections: {
connectionId: string;
/** @constant */
credentialStatus: "connected";
/** @default [] */
accountTags: string[];
/** @enum {string} */
agentKind?: "codex" | "claude" | "pi";
/** @default [] */
models: string[];
/** @default [] */
efforts: string[];
}[];
ampConnections: {
connectionId: string;
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/exe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<AgentKind, string[]>>;
modelsRefreshedAtByAgent?: Partial<Record<AgentKind, string>>;
}

export const effortOptions: Record<AgentKind, string[]> = {
codex: ["minimal", "low", "medium", "high", "xhigh"],
claude: ["low", "medium", "high"],
pi: [],
};

export interface ExeRunConnection extends ExeConnection {
agentCommand?: string;
model?: string;
Expand Down
15 changes: 15 additions & 0 deletions packages/api/src/flow-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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<ExeConnection>(`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));
Expand Down Expand Up @@ -198,13 +211,15 @@ 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 };
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");
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();
Expand Down
15 changes: 9 additions & 6 deletions packages/api/src/postgres/integration-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,18 +21,21 @@ 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.");
if (permissions.missingPermissions.length) throw new ExeIntegrationError(`exe.dev token is missing required permissions: ${permissions.missingPermissions.map(command => command === "integrations" ? "integrations list" : command).join(", ")}`);
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<ExeConnectionInput>) {
const saved = input.connectionId ? await this.connections.get<ExeConnection>(`exe:${input.connectionId}`) : null;
Expand Down Expand Up @@ -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 } };
}
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/settings-contracts.ts
Original file line number Diff line number Diff line change
@@ -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() });
Expand Down
7 changes: 6 additions & 1 deletion packages/api/test/execution-backend.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
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());

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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/features/jobs/editor-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/features/jobs/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ function EditorForm({ initial }: { initial?: Job }) {
<Label>Slug <Input required pattern="[a-z][a-z0-9_-]{0,29}" value={draft.slug} onChange={e => update("slug", e.target.value)} /></Label>
</div></Card><Card><h2>Execution</h2><p className="mb-5 text-sm text-slate-600 dark:text-slate-400">Choose an integration for this job, then configure its VM tags, model, effort, and concurrency.</p><div className="grid gap-5 sm:grid-cols-2"><Label>Integration <Select required value={draft.executionConfigId} onChange={e => { update("executionConfigId", e.target.value); update("model", ""); update("effort", ""); }}><option value="">Choose an integration</option>{targets.data?.map(t => <option key={t.id} value={t.id}>{t.name} · {t.agentKind}</option>)}{draft.executionConfigId && !target && <option value={draft.executionConfigId}>Unavailable integration: {draft.executionConfigId}</option>}</Select></Label>
{targets.isPending && <p role="status">Loading integrations…</p>}{targets.error && <p role="alert">{targets.error.message}</p>}{targets.data?.length === 0 && <p>Connect an execution provider in settings before creating a job.</p>}
<Label>VM tags <Input value={(draft.tags ?? []).join(", ")} disabled={target?.kind === "amp"} onChange={e => update("tags", e.target.value.split(",").map(tag => tag.trim()).filter(Boolean))} placeholder="payments, production" /></Label>
<Label>Model <Input value={draft.model} disabled={target?.kind === "amp"} onChange={e => update("model", e.target.value)} placeholder="Harness default" /></Label>
<Label>VM tags <Select multiple value={draft.tags ?? []} disabled={target?.kind === "amp"} onChange={e => update("tags", Array.from(e.target.selectedOptions, option => option.value))}>{target?.tags?.map(tag => <option key={tag}>{tag}</option>)}{(draft.tags ?? []).filter(tag => !target?.tags?.includes(tag)).map(tag => <option key={tag} value={tag}>{tag} (unavailable)</option>)}</Select></Label>
<Label>Model <Select value={draft.model} disabled={target?.kind === "amp"} onChange={e => update("model", e.target.value)}><option value="">Harness default</option>{target?.models?.map(model => <option key={model}>{model}</option>)}{draft.model && !target?.models?.includes(draft.model) && <option value={draft.model}>{draft.model} (unavailable)</option>}</Select></Label>
<Label>Effort <Select value={draft.effort} disabled={target?.kind === "amp"} onChange={e => update("effort", e.target.value)}><option value="">Harness default</option>{target?.efforts?.map(effort => <option key={effort}>{effort}</option>)}{draft.effort && !target?.efforts?.includes(draft.effort) && <option value={draft.effort}>{draft.effort} (unavailable)</option>}</Select></Label>
<Label>Concurrency limit <Input required type="number" min={1} max={50} value={draft.concurrencyLimit} onChange={e => update("concurrencyLimit", e.target.valueAsNumber)} /></Label>
</div></Card><Card className="space-y-5"><h2>Prompt and context</h2><p className="text-sm text-slate-600 dark:text-slate-400">Write instructions using context supplied by your triggers.</p><TemplateField label="Prompt template" multiline value={draft.promptTemplate} onChange={value => update("promptTemplate", value)} triggers={draft.triggers} />
Expand Down
Loading
Loading