Files
kandev/apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go
T
2026-08-21 22:04:17 +01:00

165 lines
6.2 KiB
Go

package lifecycle
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/agentctl/acpcompat"
)
// DeletedProfileError carries the soft-deleted profile's ID and name so the
// caller (watcher self-heal) can surface a human-readable cause. Wraps
// store.ErrAgentProfileDeleted — use errors.Is / errors.As.
type DeletedProfileError struct {
ProfileID string
ProfileName string
}
// ErrVirtualProfile marks a profile family that is a routing owner rather
// than a concrete subprocess. Direct lifecycle launches must stop here; the
// shared dynamic conductor is the only owner allowed to select a candidate.
var ErrVirtualProfile = errors.New("agent profile belongs to a virtual execution family")
func (e *DeletedProfileError) Error() string {
if e.ProfileName != "" {
return fmt.Sprintf("agent profile %q (%s) was removed", e.ProfileName, e.ProfileID)
}
return fmt.Sprintf("agent profile %s was removed", e.ProfileID)
}
func (e *DeletedProfileError) Unwrap() error { return store.ErrAgentProfileDeleted }
// StoreProfileResolver implements ProfileResolver using the agent settings store
type StoreProfileResolver struct {
store store.Repository
registry *registry.Registry
}
// NewStoreProfileResolver creates a new profile resolver using the given store and registry.
// The registry is used to look up the agent's default model when the profile doesn't specify one.
func NewStoreProfileResolver(store store.Repository, reg *registry.Registry) *StoreProfileResolver {
return &StoreProfileResolver{store: store, registry: reg}
}
// ResolveProfile looks up an agent profile by ID and returns the profile info.
//
// When the row is missing, ResolveProfile retries via
// GetAgentProfileIncludingDeleted to disambiguate "never existed" (returns
// the original "profile not found" error) from "soft-deleted" (returns a
// *DeletedProfileError wrapping store.ErrAgentProfileDeleted so callers can
// trigger watcher self-heal).
func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
profile, err := r.store.GetAgentProfile(ctx, profileID)
if err != nil {
if deletedErr := r.checkSoftDeleted(ctx, profileID, err); deletedErr != nil {
return nil, deletedErr
}
return nil, fmt.Errorf("profile not found: %w", err)
}
// Get the parent agent to get the agent name
agent, err := r.store.GetAgent(ctx, profile.AgentID)
if err != nil {
return nil, fmt.Errorf("agent not found for profile: %w", err)
}
if err := r.migrateCursorProfile(ctx, profile, agent); err != nil {
return nil, err
}
if agent.Name == agents.DynamicAgentID {
return nil, fmt.Errorf("profile %s: %w", profile.ID, ErrVirtualProfile)
}
// Resolve agent capabilities from the registry.
model, nativeSessionResume := r.resolveAgentCapabilities(agent.Name, profile.Model)
return &AgentProfileInfo{
ProfileID: profile.ID,
ProfileName: profile.Name,
AgentID: agent.ID,
AgentName: agent.Name,
Model: model,
Mode: profile.Mode,
FallbackModel: profile.FallbackModel,
AutoFallback: profile.AutoFallback,
ConfigOptions: profile.ConfigOptions,
AutoApprove: profile.AutoApprove,
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
AllowIndexing: profile.AllowIndexing,
CLIFlags: profile.CLIFlags,
CommandPrefix: profile.CommandPrefix,
EnvVars: profile.EnvVars,
CLIPassthrough: profile.CLIPassthrough,
NativeSessionResume: nativeSessionResume,
SupportsMCP: agent.SupportsMCP,
}, nil
}
func (r *StoreProfileResolver) migrateCursorProfile(
ctx context.Context,
profile *models.AgentProfile,
agent *models.Agent,
) error {
if profile == nil || agent == nil {
return nil
}
agentID := agent.Name
if agentID == "" {
agentID = agent.ID
}
model, options, changed := acpcompat.MigrateCursorModel(agentID, profile.Model, profile.ConfigOptions)
if !changed {
return nil
}
profile.Model = model
profile.ConfigOptions = options
if err := r.store.UpdateAgentProfile(ctx, profile); err != nil {
return fmt.Errorf("migrate Cursor profile %q: %w", profile.ID, err)
}
return nil
}
// checkSoftDeleted returns a *DeletedProfileError when the missing-row error
// from GetAgentProfile resolves to an existing-but-soft-deleted row. Returns
// nil when the row really doesn't exist or the secondary lookup fails — the
// caller falls back to the original "profile not found" error.
func (r *StoreProfileResolver) checkSoftDeleted(ctx context.Context, profileID string, primaryErr error) error {
// Only retry on the well-known "missing row" signal. The store layer
// returns sql.ErrNoRows from QueryRow.Scan when the filtered SELECT
// matches nothing — that includes both "no row at all" and
// "row exists but deleted_at IS NOT NULL". Other errors (driver,
// permissions) are returned as-is by the caller.
if !errors.Is(primaryErr, sql.ErrNoRows) {
return nil
}
profile, err := r.store.GetAgentProfileIncludingDeleted(ctx, profileID)
if err != nil || profile == nil || profile.DeletedAt == nil {
return nil
}
return &DeletedProfileError{ProfileID: profile.ID, ProfileName: profile.Name}
}
// resolveAgentCapabilities looks up the agent in the registry and returns the
// effective model and whether the agent supports native session resume.
// The model comes straight from the profile; static per-agent defaults have
// been removed. Empty model means "agent picks its own default".
func (r *StoreProfileResolver) resolveAgentCapabilities(agentName, profileModel string) (string, bool) {
if r.registry == nil {
return profileModel, false
}
ag, ok := r.registry.Get(agentName)
if !ok {
return profileModel, false
}
var nativeSessionResume bool
if rt := ag.Runtime(); rt != nil {
nativeSessionResume = rt.SessionConfig.NativeSessionResume
}
return profileModel, nativeSessionResume
}