Files
kandev/apps/web/app/settings/agents/[agentId]/agent-save-helpers.ts
T
2026-09-15 23:11:43 +01:00

550 lines
19 KiB
TypeScript

import {
createAgentAction,
createAgentProfileAction,
deleteAgentProfileAction,
updateAgentAction,
updateAgentProfileAction,
updateAgentProfileMcpConfigAction,
} from "@/app/actions/agents";
import type {
Agent,
AgentProfile,
McpServerDef,
PermissionSetting,
ModelConfig,
ProfileEnvVar,
} from "@/lib/types/http";
import { arePermissionsDirty, permissionsToProfilePatch } from "@/lib/agent-permissions";
import { areCLIFlagsEqual } from "@/lib/cli-flags";
import { areConfigOptionsEqual } from "@/lib/config-options";
import { t } from "@/lib/i18n";
import { toAgentProfilePayload } from "@/lib/api/domains/agent-profile-normalize";
import type { ProfileFormData } from "@/components/settings/profile-form-fields";
// The JSON key the MCP editor validates against — an identifier, interpolated
// into the parse error rather than written into the catalog.
const MCP_SERVERS_KEY = "mcpServers";
/**
* Translates a ProfileFormData patch (snake_case form keys) into a
* Partial<AgentProfile> (camelCase). Profiles in client state use the
* canonical camelCase AgentProfile shape, so without this translation
* patches like { cli_passthrough: true } would land as a new snake_case
* key and the camelCase reader would never see them.
*/
export function toAgentProfilePatch(patch: Partial<ProfileFormData>): Partial<AgentProfile> {
const next: Partial<AgentProfile> = {};
if (patch.name !== undefined) next.name = patch.name;
if (patch.model !== undefined) next.model = patch.model;
if (patch.fallback_model !== undefined) next.fallbackModel = patch.fallback_model;
if (patch.auto_fallback !== undefined) next.autoFallback = patch.auto_fallback;
if (patch.require_exact_model !== undefined) next.requireExactModel = patch.require_exact_model;
if (patch.mode !== undefined) next.mode = patch.mode;
if (patch.config_options !== undefined) next.configOptions = patch.config_options;
if (patch.allow_indexing !== undefined) next.allowIndexing = patch.allow_indexing;
if (patch.auto_approve !== undefined) next.autoApprove = patch.auto_approve;
if (patch.cli_passthrough !== undefined) next.cliPassthrough = patch.cli_passthrough;
if (patch.cli_flags !== undefined) next.cliFlags = patch.cli_flags;
if (patch.command_prefix !== undefined) next.commandPrefix = patch.command_prefix;
return next;
}
function areEnvVarsEqual(a?: ProfileEnvVar[], b?: ProfileEnvVar[]): boolean {
const left = a ?? [];
const right = b ?? [];
if (left.length !== right.length) return false;
return left.every(
(ev, i) =>
ev.key === right[i]?.key &&
(ev.value ?? "") === (right[i]?.value ?? "") &&
(ev.secret_id ?? "") === (right[i]?.secret_id ?? ""),
);
}
type DraftMcpConfig = {
enabled: boolean;
servers: string;
dirty: boolean;
error: string | null;
};
/**
* Editable in-memory shape for an agent profile being created or edited
* in the settings UI. Mirrors the canonical (camelCase) `AgentProfile`
* with form-state extras. The save helpers translate this back to
* snake_case at the API boundary.
*
* `allow_indexing` is kept as a snake_case form key so the permissions
* map (which is keyed by snake_case agent metadata) flows through the
* draft unchanged.
*/
export type DraftProfile = AgentProfile & {
allow_indexing?: boolean;
auto_approve?: boolean;
isNew?: boolean;
mcp_config?: DraftMcpConfig;
};
export type DraftAgent = Omit<Agent, "profiles"> & { profiles: DraftProfile[]; isNew?: boolean };
function dynamicProfilePayload(profile: DraftProfile) {
if (profile.kind !== "dynamic" && !profile.dynamic) return undefined;
return toAgentProfilePayload(profile).dynamic;
}
export const parseProfileMcpServers = (raw: string): Record<string, McpServerDef> => {
if (!raw.trim()) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(t("agents:mcpConfigMustBeJsonObject"));
}
if (MCP_SERVERS_KEY in parsed) {
const nested = (parsed as { mcpServers?: unknown }).mcpServers;
if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
throw new Error(t("agents:mcpKeyMustBeJsonObject", { key: MCP_SERVERS_KEY }));
}
return nested as Record<string, McpServerDef>;
}
return parsed as Record<string, McpServerDef>;
};
type SaveMcpForProfileParams = {
draftProfile: DraftProfile;
targetProfileId: string;
onToastError: (error: unknown) => void;
};
async function saveMcpForProfile({
draftProfile,
targetProfileId,
onToastError,
}: SaveMcpForProfileParams) {
if (!draftProfile.mcp_config?.dirty || !draftProfile.mcp_config.servers.trim()) return;
try {
const servers = parseProfileMcpServers(draftProfile.mcp_config.servers);
await updateAgentProfileMcpConfigAction(targetProfileId, {
enabled: draftProfile.mcp_config.enabled,
mcpServers: servers,
});
} catch (error) {
onToastError(error);
throw error;
}
}
function correlateCreatedProfiles(
submitted: DraftProfile[],
created: AgentProfile[],
): Map<string, string> {
const mappings = new Map<string, string>();
if (submitted.length === created.length) {
submitted.forEach((profile, index) => mappings.set(profile.id, created[index].id));
return mappings;
}
for (const profile of submitted) {
const match = created.find((candidate) => candidate.name === profile.name);
if (match) mappings.set(profile.id, match.id);
}
return mappings;
}
async function saveMcpForCreatedProfiles(
draftAgent: DraftAgent,
created: Agent,
onToastError: (error: unknown) => void,
) {
if (created.profiles.length === draftAgent.profiles.length) {
for (let index = 0; index < draftAgent.profiles.length; index += 1) {
await saveMcpForProfile({
draftProfile: draftAgent.profiles[index],
targetProfileId: created.profiles[index].id,
onToastError,
});
}
return;
}
for (const draftProfile of draftAgent.profiles) {
const createdProfile = created.profiles.find((profile) => profile.name === draftProfile.name);
if (!createdProfile) continue;
await saveMcpForProfile({
draftProfile,
targetProfileId: createdProfile.id,
onToastError,
});
}
}
function preservePendingMcpDrafts(draftAgent: DraftAgent, created: Agent): Agent {
const submittedById = new Map<string, DraftProfile>(
draftAgent.profiles.map((profile) => [profile.id, profile]),
);
const profileIds = correlateCreatedProfiles(draftAgent.profiles, created.profiles);
const submittedByCreatedId = new Map(
[...profileIds].map(([submittedId, createdId]) => [createdId, submittedById.get(submittedId)]),
);
return {
...created,
profiles: created.profiles.map((profile) => {
const pending = submittedByCreatedId.get(profile.id)?.mcp_config;
return pending ? { ...profile, mcp_config: pending } : profile;
}),
};
}
export type EnsureProfilesFn = (
agent: DraftAgent,
displayName: string,
defaultModel: string,
permissions?: Record<string, PermissionSetting>,
) => DraftAgent;
export type CloneAgentFn = (agent: Agent) => DraftAgent;
export type SaveAgentCallbacks = {
onToastError: (error: unknown) => void;
currentAgentModelConfig: ModelConfig;
permissionSettings: Record<string, PermissionSetting>;
resolveDisplayName: (name: string) => string;
upsertAgent: (agent: Agent) => void;
setDraftAgent: (agent: DraftAgent | ((current: DraftAgent) => DraftAgent)) => void;
ensureProfiles: EnsureProfilesFn;
cloneAgent: CloneAgentFn;
replaceRoute: (path: string) => void;
};
export async function saveNewAgent(draftAgent: DraftAgent, callbacks: SaveAgentCallbacks) {
let created = await createAgentAction({
name: draftAgent.name,
workspace_id: draftAgent.workspace_id,
profiles: draftAgent.profiles.map((profile) => ({
name: profile.name,
model: profile.model,
kind: profile.kind,
fallback_model: profile.fallbackModel ?? "",
auto_fallback: profile.autoFallback ?? false,
mode: profile.mode,
config_options: profile.configOptions ?? {},
...permissionsToProfilePatch(profile),
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
env_vars: profile.envVars ?? [],
dynamic: dynamicProfilePayload(profile),
})),
});
try {
await saveMcpForCreatedProfiles(draftAgent, created, callbacks.onToastError);
} catch (error) {
const reconciled = preservePendingMcpDrafts(draftAgent, created);
callbacks.upsertAgent(reconciled);
const savedDraft = callbacks.ensureProfiles(
callbacks.cloneAgent(reconciled),
callbacks.resolveDisplayName(reconciled.name),
callbacks.currentAgentModelConfig.default_model,
callbacks.permissionSettings,
);
const profileIds = correlateCreatedProfiles(draftAgent.profiles, reconciled.profiles);
callbacks.setDraftAgent((current) =>
mergeSavedAgentDraft(current, draftAgent, savedDraft, profileIds),
);
callbacks.replaceRoute(`/settings/agents/${encodeURIComponent(reconciled.name)}`);
throw error;
}
if ((draftAgent.mcp_config_path ?? "") !== (created.mcp_config_path ?? "")) {
created = await updateAgentAction(created.id, {
mcp_config_path: draftAgent.mcp_config_path ?? "",
});
}
callbacks.upsertAgent(created);
const savedDraft = callbacks.ensureProfiles(
callbacks.cloneAgent(created),
callbacks.resolveDisplayName(created.name),
callbacks.currentAgentModelConfig.default_model,
callbacks.permissionSettings,
);
const profileIds = correlateCreatedProfiles(draftAgent.profiles, created.profiles);
callbacks.setDraftAgent((current) =>
mergeSavedAgentDraft(current, draftAgent, savedDraft, profileIds),
);
callbacks.replaceRoute(`/settings/agents/${encodeURIComponent(created.name)}`);
return savedDraft;
}
async function saveExistingAgentPatch(draftAgent: DraftAgent, savedAgent: Agent) {
const agentPatch: { workspace_id?: string | null; mcp_config_path?: string | null } = {};
if ((draftAgent.workspace_id ?? null) !== (savedAgent.workspace_id ?? null)) {
agentPatch.workspace_id = draftAgent.workspace_id ?? null;
}
if ((draftAgent.mcp_config_path ?? "") !== (savedAgent.mcp_config_path ?? "")) {
agentPatch.mcp_config_path = draftAgent.mcp_config_path ?? "";
}
if (Object.keys(agentPatch).length > 0) {
await updateAgentAction(savedAgent.id, agentPatch);
}
}
async function savePersistedProfile(
profile: DraftProfile,
savedProfile: AgentProfile,
onToastError: (error: unknown) => void,
): Promise<AgentProfile> {
await saveMcpForProfile({
draftProfile: profile,
targetProfileId: savedProfile.id,
onToastError,
});
if (isProfileDirty(profile, savedProfile)) {
return updateAgentProfileAction(profile.id, {
name: profile.name,
model: profile.model,
kind: profile.kind,
fallback_model: profile.fallbackModel ?? "",
auto_fallback: profile.autoFallback ?? false,
mode: profile.mode,
config_options: profile.configOptions ?? {},
...permissionsToProfilePatch(profile),
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
env_vars: profile.envVars ?? [],
dynamic: dynamicProfilePayload(profile),
});
}
const { mcp_config: _pendingMcp, ...persistedProfile } = savedProfile as DraftProfile;
return persistedProfile;
}
async function saveExistingProfiles(
draftAgent: DraftAgent,
savedAgent: Agent,
isCreateMode: boolean,
onToastError: (error: unknown) => void,
): Promise<{ profiles: AgentProfile[]; profileIds: Map<string, string> }> {
const savedProfilesById = new Map(savedAgent.profiles.map((p) => [p.id, p]));
const nextProfiles: AgentProfile[] = isCreateMode ? [...savedAgent.profiles] : [];
const profileIds = new Map<string, string>();
const persistedProfiles: DraftProfile[] = [];
const persistedSubmittedIds = new Set<string>();
try {
for (const profile of draftAgent.profiles) {
const savedProfile = savedProfilesById.get(profile.id);
if (!savedProfile) {
const createdProfile = await createAgentProfileAction(savedAgent.id, {
name: profile.name,
model: profile.model,
kind: profile.kind,
fallback_model: profile.fallbackModel ?? "",
auto_fallback: profile.autoFallback ?? false,
mode: profile.mode,
config_options: profile.configOptions ?? {},
...permissionsToProfilePatch(profile),
cli_passthrough: profile.cliPassthrough ?? false,
cli_flags: profile.cliFlags ?? [],
command_prefix: profile.commandPrefix ?? "",
env_vars: profile.envVars ?? [],
dynamic: dynamicProfilePayload(profile),
});
profileIds.set(profile.id, createdProfile.id);
persistedSubmittedIds.add(profile.id);
persistedProfiles.push(
profile.mcp_config
? { ...createdProfile, mcp_config: profile.mcp_config }
: createdProfile,
);
await saveMcpForProfile({
draftProfile: profile,
targetProfileId: createdProfile.id,
onToastError,
});
persistedProfiles[persistedProfiles.length - 1] = createdProfile;
nextProfiles.push(createdProfile);
continue;
}
profileIds.set(profile.id, savedProfile.id);
const persistedProfile = await savePersistedProfile(profile, savedProfile, onToastError);
persistedSubmittedIds.add(profile.id);
persistedProfiles.push(persistedProfile);
nextProfiles.push(persistedProfile);
}
} catch (error) {
if (persistedProfiles.length > 0) {
throw new PartialProfileSaveError(
error,
persistedProfiles,
persistedSubmittedIds,
profileIds,
);
}
throw error;
}
return { profiles: nextProfiles, profileIds };
}
class PartialProfileSaveError extends Error {
constructor(
readonly original: unknown,
readonly persistedProfiles: DraftProfile[],
readonly persistedSubmittedIds: Set<string>,
readonly profileIds: Map<string, string>,
) {
super("Profile creation only partially completed");
}
}
function reconcilePartialProfileSave(
draftAgent: DraftAgent,
savedAgent: Agent,
partial: PartialProfileSaveError,
callbacks: SaveAgentCallbacks,
) {
const profilesById = new Map(savedAgent.profiles.map((profile) => [profile.id, profile]));
for (const profile of partial.persistedProfiles) profilesById.set(profile.id, profile);
const reconciled = {
...savedAgent,
profiles: [...profilesById.values()],
};
callbacks.upsertAgent(reconciled);
const submitted = {
...draftAgent,
profiles: draftAgent.profiles.filter((profile) =>
partial.persistedSubmittedIds.has(profile.id),
),
};
const persistedIds = new Set(partial.persistedProfiles.map((profile) => profile.id));
const savedDraft = callbacks.ensureProfiles(
{
...callbacks.cloneAgent(reconciled),
profiles: reconciled.profiles.filter((profile) => persistedIds.has(profile.id)),
},
callbacks.resolveDisplayName(reconciled.name),
callbacks.currentAgentModelConfig.default_model,
callbacks.permissionSettings,
);
callbacks.setDraftAgent((current) =>
mergeSavedAgentDraft(current, submitted, savedDraft, partial.profileIds),
);
}
async function deleteRemovedProfiles(draftAgent: DraftAgent, savedAgent: Agent) {
for (const savedProfile of savedAgent.profiles) {
const stillExists = draftAgent.profiles.some((p) => p.id === savedProfile.id);
if (!stillExists) {
await deleteAgentProfileAction(savedProfile.id);
}
}
}
export async function saveExistingAgent(
draftAgent: DraftAgent,
savedAgent: Agent,
isCreateMode: boolean,
callbacks: SaveAgentCallbacks,
) {
await saveExistingAgentPatch(draftAgent, savedAgent);
let savedProfiles: Awaited<ReturnType<typeof saveExistingProfiles>>;
try {
savedProfiles = await saveExistingProfiles(
draftAgent,
savedAgent,
isCreateMode,
callbacks.onToastError,
);
} catch (error) {
if (error instanceof PartialProfileSaveError) {
reconcilePartialProfileSave(draftAgent, savedAgent, error, callbacks);
throw error.original;
}
throw error;
}
if (!isCreateMode) {
await deleteRemovedProfiles(draftAgent, savedAgent);
}
const nextAgent = {
...savedAgent,
workspace_id: draftAgent.workspace_id ?? null,
mcp_config_path: draftAgent.mcp_config_path ?? "",
profiles: savedProfiles.profiles,
};
callbacks.upsertAgent(nextAgent);
const savedDraft = callbacks.ensureProfiles(
callbacks.cloneAgent(nextAgent),
callbacks.resolveDisplayName(nextAgent.name),
callbacks.currentAgentModelConfig.default_model,
callbacks.permissionSettings,
);
callbacks.setDraftAgent((current) =>
mergeSavedAgentDraft(current, draftAgent, savedDraft, savedProfiles.profileIds),
);
if (isCreateMode) {
callbacks.replaceRoute(`/settings/agents/${encodeURIComponent(savedAgent.name)}`);
}
return savedDraft;
}
export function mergeSavedAgentDraft(
current: DraftAgent,
submitted: DraftAgent,
saved: DraftAgent,
profileIds: ReadonlyMap<string, string> = new Map(),
): DraftAgent {
const currentById = new Map(current.profiles.map((profile) => [profile.id, profile]));
const submittedBySavedId = new Map(
submitted.profiles.map((profile) => [profileIds.get(profile.id) ?? profile.id, profile]),
);
const profiles = saved.profiles.map((savedProfile) => {
const submittedProfile = submittedBySavedId.get(savedProfile.id);
if (!submittedProfile) return savedProfile;
const currentProfile = currentById.get(submittedProfile.id);
if (!currentProfile || JSON.stringify(currentProfile) === JSON.stringify(submittedProfile)) {
return savedProfile;
}
return { ...savedProfile, ...currentProfile, id: savedProfile.id };
});
const submittedIds = new Set(submitted.profiles.map((profile) => profile.id));
profiles.push(...current.profiles.filter((profile) => !submittedIds.has(profile.id)));
return { ...saved, ...current, id: saved.id, name: saved.name, profiles };
}
function isProfileCliConfigDirty(draft: DraftProfile, saved: AgentProfile): boolean {
return (
draft.cliPassthrough !== saved.cliPassthrough ||
!areCLIFlagsEqual(draft.cliFlags ?? [], saved.cliFlags ?? []) ||
(draft.commandPrefix ?? "") !== (saved.commandPrefix ?? "") ||
!areEnvVarsEqual(draft.envVars, saved.envVars)
);
}
function isProfileIdentityDirty(draft: DraftProfile, saved: AgentProfile): boolean {
return [
(draft.kind ?? "concrete") !== (saved.kind ?? "concrete"),
JSON.stringify(draft.dynamic ?? null) !== JSON.stringify(saved.dynamic ?? null),
draft.name !== saved.name,
draft.model !== saved.model,
(draft.mode ?? "") !== (saved.mode ?? ""),
(draft.fallbackModel ?? "") !== (saved.fallbackModel ?? ""),
(draft.autoFallback ?? false) !== (saved.autoFallback ?? false),
].some(Boolean);
}
function isProfileSettingsDirty(draft: DraftProfile, saved: AgentProfile): boolean {
return (
areConfigOptionsEqual(draft.configOptions, saved.configOptions) === false ||
arePermissionsDirty(draft, saved)
);
}
export function isProfileDirty(draft: DraftProfile, saved?: AgentProfile): boolean {
if (!saved) return true;
return (
isProfileIdentityDirty(draft, saved) ||
isProfileSettingsDirty(draft, saved) ||
isProfileCliConfigDirty(draft, saved)
);
}