Files
kandev/apps/web/components/settings/dynamic-agent-profile-editor-state.ts
T
2026-09-10 11:35:46 +01:00

266 lines
8.9 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { isDynamicErrorPolicyValid } from "@/components/settings/dynamic-agent-policy-editor";
import { updateAgentProfileAction } from "@/app/actions/agents";
import { isHandledApiError } from "@/lib/api/client";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
import type { Agent, AgentProfile } from "@/lib/types/http";
import type {
DynamicAgentCandidate,
DynamicErrorClass,
DynamicErrorPolicy,
} from "@/lib/types/agent-profile";
import {
dynamicDraftRevision,
useDynamicAgentProfileEditorDraft,
} from "@/components/settings/dynamic-agent-profile-editor-draft";
type DynamicAgentProfileEditorStateProps = {
agent: Agent;
profile: AgentProfile;
onDraftChange?: (patch: Pick<AgentProfile, "name" | "dynamic" | "enabled">) => void;
};
export type DynamicAgentProfileEditorState = {
name: string;
profileEnabled: boolean;
standalone: boolean;
hasExternalConflict: boolean;
routingEnabled: boolean;
enabledLabel: string;
concreteProfiles: AgentProfile[];
availableProfileOptions: ReturnType<typeof toAgentProfileOption>[];
updateName: (name: string) => void;
updateProfileEnabled: (enabled: boolean) => void;
addCandidate: (executionProfileId: string) => void;
moveCandidate: (index: number, direction: -1 | 1) => void;
removeCandidate: (index: number) => void;
updateCandidate: (index: number, patch: Partial<DynamicAgentCandidate>) => void;
updateCandidatePolicy: (
index: number,
errorClass: DynamicErrorClass,
patch: Partial<DynamicErrorPolicy>,
) => void;
candidates: DynamicAgentCandidate[];
discardDraft: () => void;
};
function dynamicProfilePayload(
name: string,
enabled: boolean,
version: number,
candidates: DynamicAgentCandidate[],
) {
return {
name: name.trim(),
enabled,
dynamic: {
version,
candidates: candidates.map((candidate, position) => ({
position,
execution_profile_id: candidate.executionProfileId,
enabled: candidate.enabled,
policies: {
version: candidate.policies.version,
transient: {
retry: {
enabled: candidate.policies.transient.retry.enabled,
max_retries: candidate.policies.transient.retry.maxRetries,
initial_interval_seconds: candidate.policies.transient.retry.initialIntervalSeconds,
},
wait_for_reset: {
enabled: candidate.policies.transient.waitForReset.enabled,
max_wait_seconds: candidate.policies.transient.waitForReset.maxWaitSeconds,
},
on_exhausted: candidate.policies.transient.onExhausted,
},
hard: {
retry: {
enabled: candidate.policies.hard.retry.enabled,
max_retries: candidate.policies.hard.retry.maxRetries,
initial_interval_seconds: candidate.policies.hard.retry.initialIntervalSeconds,
},
wait_for_reset: {
enabled: candidate.policies.hard.waitForReset.enabled,
max_wait_seconds: candidate.policies.hard.waitForReset.maxWaitSeconds,
},
on_exhausted: candidate.policies.hard.onExhausted,
},
},
})),
},
};
}
// eslint-disable-next-line max-lines-per-function -- coordinates persistence and the shared save surface.
export function useDynamicAgentProfileEditorState({
agent,
profile,
onDraftChange,
}: DynamicAgentProfileEditorStateProps): DynamicAgentProfileEditorState {
const { t } = useTranslation();
const enabledLabel = t("agents:enabled");
const { toast } = useToast();
const routingEnabled = useFeature("dynamicAgentRouting");
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
const draft = useDynamicAgentProfileEditorDraft({ profile, onDraftChange });
const [saving, setSaving] = useState(false);
const standalone = onDraftChange === undefined;
const concreteProfiles = useMemo(
() =>
settingsAgents
.filter((item) => item.name !== "dynamic")
.flatMap((item) => item.profiles)
.filter(
(candidate) =>
candidate.kind !== "dynamic" && candidate.enabled !== false && !candidate.workspaceId,
),
[settingsAgents],
);
const availableProfileOptions = useMemo(
() =>
settingsAgents.flatMap((item) =>
item.name === "dynamic"
? []
: item.profiles
.filter(
(candidate) =>
candidate.kind !== "dynamic" &&
candidate.enabled !== false &&
!candidate.workspaceId &&
!draft.candidates.some((current) => current.executionProfileId === candidate.id),
)
.map((candidate) => toAgentProfileOption(item, candidate)),
),
[draft.candidates, settingsAgents],
);
const save = async () => {
if (
!routingEnabled ||
!draft.name.trim() ||
draft.candidates.length === 0 ||
!profile.dynamic ||
draft.hasExternalConflict
) {
return;
}
setSaving(true);
const submitted = draft.currentProfile;
draft.markProfileSubmitted(submitted);
try {
const draftPayload = {
name: draft.name.trim(),
enabled: draft.profileEnabled,
dynamic: {
version: draft.dynamicVersion,
candidates: draft.candidates,
},
};
const payload = dynamicProfilePayload(
draft.name,
draft.profileEnabled,
draft.dynamicVersion,
draft.candidates,
);
if (onDraftChange) {
onDraftChange(draftPayload);
return;
}
const updated = await updateAgentProfileAction(profile.id, payload);
const nextAgents = settingsAgents.map((item) =>
item.id !== agent.id
? item
: {
...item,
profiles: item.profiles.map((itemProfile) =>
itemProfile.id === updated.id ? updated : itemProfile,
),
},
);
setSettingsAgents(nextAgents);
setAgentProfiles(
nextAgents.flatMap((item) =>
item.profiles.map((itemProfile) => toAgentProfileOption(item, itemProfile)),
),
);
draft.acceptProfileSaveResponse(updated, submitted);
toast({ title: t("agents:dynamicProfileSaved") });
} catch (error) {
draft.markProfileSubmitted(null);
if (isHandledApiError(error)) return;
toast({
title: t("agents:failedToSaveProfile"),
description: error instanceof Error ? error.message : undefined,
variant: "error",
});
} finally {
setSaving(false);
}
};
const draftRevision = dynamicDraftRevision(draft.name, draft.candidates, draft.profileEnabled);
const savedRevision = dynamicDraftRevision(
draft.savedProfile.name,
draft.savedProfile.dynamic?.candidates ?? [],
draft.savedProfile.enabled !== false,
);
const policiesValid = draft.candidates.every(
(candidate) =>
isDynamicErrorPolicyValid(candidate.policies.transient) &&
isDynamicErrorPolicyValid(candidate.policies.hard),
);
let invalidReason = t("agents:dynamicPolicyValidation");
if (!draft.name.trim()) invalidReason = t("agents:profileNameRequired");
else if (draft.candidates.length === 0) invalidReason = t("agents:noDynamicCandidates");
else if (draft.hasExternalConflict)
invalidReason = t("agents:profileExternalChangeInvalidReason");
useSettingsSaveContributor({
id: `dynamic-profile:${profile.id}`,
revision: draftRevision,
isDirty: standalone && draftRevision !== savedRevision,
canSave:
routingEnabled &&
!saving &&
!draft.hasExternalConflict &&
Boolean(draft.name.trim()) &&
draft.candidates.length > 0 &&
policiesValid,
invalidReason,
save,
discard: () => {
if (!standalone) return;
draft.reset();
},
});
return {
name: draft.name,
profileEnabled: draft.profileEnabled,
standalone,
hasExternalConflict: draft.hasExternalConflict,
routingEnabled,
enabledLabel,
concreteProfiles,
availableProfileOptions,
updateName: draft.updateName,
updateProfileEnabled: draft.updateProfileEnabled,
addCandidate: draft.addCandidate,
moveCandidate: draft.moveCandidate,
removeCandidate: draft.removeCandidate,
updateCandidate: draft.updateCandidate,
updateCandidatePolicy: draft.updateCandidatePolicy,
candidates: draft.candidates,
discardDraft: draft.reset,
};
}