565 lines
19 KiB
Go
565 lines
19 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/hostutility"
|
|
"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"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
const (
|
|
// Orphan cleanup is startup hygiene, not a user-confirmed bulk action.
|
|
// Keep automatic batches small so registry bugs fail closed.
|
|
maxOrphanCleanupAgentTypesPerRun = 20
|
|
maxOrphanCleanupProfilesPerRun = 50
|
|
)
|
|
|
|
// CapabilityReader is the minimum surface of the host utility manager that
|
|
// the reconciler needs. Declared as an interface so tests can inject a fake.
|
|
type CapabilityReader interface {
|
|
Get(agentType string) (hostutility.AgentCapabilities, bool)
|
|
}
|
|
|
|
// ProfileReconciler reconciles persisted agent profiles against the host
|
|
// utility capability cache. On boot it seeds default profiles for newly
|
|
// probed agents, validates existing profile models/modes against the cache
|
|
// (auto-healing stale values), and soft-deletes profiles whose agent_id is
|
|
// no longer registered (e.g. after removing the non-ACP agent variants).
|
|
//
|
|
// The reconciler is idempotent and safe to run on every boot. It runs in a
|
|
// goroutine after hostUtility.Start so probe results are available, and
|
|
// never blocks task startup — profiles used before reconciliation simply get
|
|
// validated on the next boot.
|
|
type ProfileReconciler struct {
|
|
hostUtility CapabilityReader
|
|
registry *registry.Registry
|
|
store store.Repository
|
|
log *logger.Logger
|
|
}
|
|
|
|
type orphanCleanupCandidate struct {
|
|
agent *models.Agent
|
|
profile *models.AgentProfile
|
|
}
|
|
|
|
type orphanCleanupSummary struct {
|
|
enabledAgentCount int
|
|
dbAgentCount int
|
|
orphanAgentCount int
|
|
profilesCandidateCount int
|
|
profilesCandidatePartial bool
|
|
profilesDeletedCount int
|
|
profileListFailureCount int
|
|
skipped bool
|
|
skipReason string
|
|
enabledAgentIDs []string
|
|
orphanAgentNames []string
|
|
maxAgentTypesPerRun int
|
|
maxProfilesPerRun int
|
|
}
|
|
|
|
// NewProfileReconciler constructs a reconciler.
|
|
func NewProfileReconciler(
|
|
h CapabilityReader,
|
|
reg *registry.Registry,
|
|
st store.Repository,
|
|
log *logger.Logger,
|
|
) *ProfileReconciler {
|
|
return &ProfileReconciler{
|
|
hostUtility: h,
|
|
registry: reg,
|
|
store: st,
|
|
log: log.WithFields(zap.String("component", "profile-reconciler")),
|
|
}
|
|
}
|
|
|
|
// Run executes one reconciliation pass: orphan cleanup, default seeding, and
|
|
// stale model/mode healing. Errors are logged and the pass continues — the
|
|
// goal is best-effort convergence, not atomicity.
|
|
func (r *ProfileReconciler) Run(ctx context.Context) error {
|
|
if r == nil || r.hostUtility == nil || r.registry == nil || r.store == nil {
|
|
return fmt.Errorf("reconciler not fully configured")
|
|
}
|
|
|
|
// Virtual families are settings identities rather than inference agents.
|
|
// Seed their parent rows before orphan cleanup so the permanent dynamic
|
|
// family is protected even when no concrete provider probe is ready.
|
|
r.ensureVirtualFamilies(ctx)
|
|
|
|
// Orphan cleanup first: removed agents can't come back, regardless of
|
|
// probe state. The cleanup itself fails closed when the registry is empty
|
|
// so a transient registry/bootstrap issue cannot mass-delete profiles.
|
|
r.cleanupOrphans(ctx)
|
|
|
|
// Walk enabled inference agents and reconcile each one.
|
|
for _, ia := range r.registry.ListInferenceAgents() {
|
|
ag, ok := ia.(agents.Agent)
|
|
if !ok {
|
|
continue
|
|
}
|
|
r.reconcileAgent(ctx, ag)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// logReconcileError logs a reconciler store failure at WARN, except when the
|
|
// error is a context cancellation. The reconcile pass runs off hostUtilityCtx,
|
|
// which is canceled on backend shutdown, so an interrupted store call is
|
|
// expected teardown rather than a fault and is downgraded to DEBUG to keep
|
|
// shutdown logs quiet. Mirrors github.Poller.logCleanupError.
|
|
func (r *ProfileReconciler) logReconcileError(msg string, err error, fields ...zap.Field) {
|
|
fields = append(fields, zap.Error(err))
|
|
if errors.Is(err, context.Canceled) {
|
|
r.log.Debug(msg+" (context canceled during shutdown)", fields...)
|
|
return
|
|
}
|
|
r.log.Warn(msg, fields...)
|
|
}
|
|
|
|
func (r *ProfileReconciler) ensureVirtualFamilies(ctx context.Context) {
|
|
for _, ag := range r.registry.List() {
|
|
if !agents.IsVirtualAgent(ag) {
|
|
continue
|
|
}
|
|
if _, err := r.store.GetAgentByName(ctx, ag.ID()); err == nil {
|
|
continue
|
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
|
r.logReconcileError("reconcile: look up virtual agent failed", err,
|
|
zap.String("agent_id", ag.ID()))
|
|
continue
|
|
}
|
|
parent := &models.Agent{
|
|
ID: ag.ID(),
|
|
Name: ag.ID(),
|
|
SupportsMCP: false,
|
|
}
|
|
if err := r.store.CreateAgent(ctx, parent); err != nil {
|
|
r.logReconcileError("reconcile: seed virtual agent failed", err,
|
|
zap.String("agent_id", ag.ID()))
|
|
continue
|
|
}
|
|
r.log.Info("seeded virtual agent family", zap.String("agent_id", ag.ID()))
|
|
}
|
|
}
|
|
|
|
// cleanupOrphans soft-deletes profiles whose DB agent row references an
|
|
// agent type that is no longer registered or enabled (e.g. profiles left
|
|
// behind by the removed streamjson-based variants). The settings store keys
|
|
// each DB agent by a UUID in `id`, with the registry-facing identifier stored
|
|
// in `name` — we match against the registry on `name`.
|
|
func (r *ProfileReconciler) cleanupOrphans(ctx context.Context) {
|
|
summary := orphanCleanupSummary{
|
|
maxAgentTypesPerRun: maxOrphanCleanupAgentTypesPerRun,
|
|
maxProfilesPerRun: maxOrphanCleanupProfilesPerRun,
|
|
}
|
|
defer func() {
|
|
r.logOrphanCleanupSummary(summary)
|
|
}()
|
|
|
|
if !r.registry.IsLoaded() {
|
|
summary.skipped = true
|
|
summary.skipReason = "registry_not_loaded"
|
|
r.log.Warn("orphan cleanup skipped: registry is not loaded")
|
|
return
|
|
}
|
|
enabledAgents := r.registry.ListEnabled()
|
|
summary.enabledAgentCount = len(enabledAgents)
|
|
if len(enabledAgents) == 0 {
|
|
summary.skipped = true
|
|
summary.skipReason = "enabled_registry_empty"
|
|
r.log.Warn("orphan cleanup skipped: enabled agent registry is empty")
|
|
return
|
|
}
|
|
enabled := make(map[string]struct{}, len(enabledAgents))
|
|
for _, ag := range enabledAgents {
|
|
id := ag.ID()
|
|
enabled[id] = struct{}{}
|
|
summary.enabledAgentIDs = append(summary.enabledAgentIDs, id)
|
|
}
|
|
|
|
dbAgents, err := r.store.ListAgents(ctx)
|
|
if err != nil {
|
|
summary.skipped = true
|
|
summary.skipReason = "list_agents_failed"
|
|
r.logReconcileError("orphan cleanup: list agents failed", err)
|
|
return
|
|
}
|
|
summary.dbAgentCount = len(dbAgents)
|
|
candidates := r.collectOrphanCleanupCandidates(ctx, dbAgents, enabled, &summary)
|
|
if summary.profileListFailureCount > 0 {
|
|
summary.skipped = true
|
|
summary.skipReason = "profile_list_failed"
|
|
r.log.Warn("orphan cleanup skipped: profile list failed for one or more orphan agents",
|
|
zap.Int("profile_list_failure_count", summary.profileListFailureCount))
|
|
return
|
|
}
|
|
if exceedsOrphanCleanupLimit(summary.orphanAgentCount, len(candidates)) {
|
|
summary.skipped = true
|
|
summary.skipReason = "safety_limit_exceeded"
|
|
r.log.Warn("orphan cleanup skipped: candidate batch exceeds safety limit",
|
|
zap.Int("orphan_agent_count", summary.orphanAgentCount),
|
|
zap.Int("profiles_candidate_count", len(candidates)),
|
|
zap.Int("max_agent_types_per_run", maxOrphanCleanupAgentTypesPerRun),
|
|
zap.Int("max_profiles_per_run", maxOrphanCleanupProfilesPerRun),
|
|
zap.Strings("orphan_agents", summary.orphanAgentNames))
|
|
return
|
|
}
|
|
r.deleteOrphanCleanupCandidates(ctx, candidates, &summary)
|
|
}
|
|
|
|
func (r *ProfileReconciler) collectOrphanCleanupCandidates(
|
|
ctx context.Context,
|
|
dbAgents []*models.Agent,
|
|
enabled map[string]struct{},
|
|
summary *orphanCleanupSummary,
|
|
) []orphanCleanupCandidate {
|
|
var candidates []orphanCleanupCandidate
|
|
for _, dbAgent := range dbAgents {
|
|
if _, ok := enabled[dbAgent.Name]; ok {
|
|
continue
|
|
}
|
|
summary.orphanAgentCount++
|
|
summary.orphanAgentNames = append(summary.orphanAgentNames, dbAgent.Name)
|
|
profiles, err := r.store.ListAgentProfiles(ctx, dbAgent.ID)
|
|
if err != nil {
|
|
summary.profileListFailureCount++
|
|
summary.profilesCandidatePartial = true
|
|
r.logReconcileError("orphan cleanup: list profiles failed", err,
|
|
zap.String("agent_id", dbAgent.ID),
|
|
zap.String("agent_name", dbAgent.Name))
|
|
continue
|
|
}
|
|
for _, p := range profiles {
|
|
candidates = append(candidates, orphanCleanupCandidate{
|
|
agent: dbAgent,
|
|
profile: p,
|
|
})
|
|
}
|
|
}
|
|
if summary.profileListFailureCount == 0 {
|
|
summary.profilesCandidateCount = len(candidates)
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func exceedsOrphanCleanupLimit(orphanAgentCount, profileCount int) bool {
|
|
return orphanAgentCount > maxOrphanCleanupAgentTypesPerRun ||
|
|
profileCount > maxOrphanCleanupProfilesPerRun
|
|
}
|
|
|
|
func (r *ProfileReconciler) deleteOrphanCleanupCandidates(
|
|
ctx context.Context,
|
|
candidates []orphanCleanupCandidate,
|
|
summary *orphanCleanupSummary,
|
|
) {
|
|
for _, candidate := range candidates {
|
|
r.log.Info("soft-deleting orphan profile",
|
|
zap.String("profile_id", candidate.profile.ID),
|
|
zap.String("agent_id", candidate.profile.AgentID),
|
|
zap.String("agent_name", candidate.agent.Name))
|
|
if err := r.store.DeleteAgentProfile(ctx, candidate.profile.ID); err != nil {
|
|
r.logReconcileError("orphan cleanup: delete failed", err,
|
|
zap.String("profile_id", candidate.profile.ID))
|
|
continue
|
|
}
|
|
summary.profilesDeletedCount++
|
|
}
|
|
}
|
|
|
|
func (r *ProfileReconciler) logOrphanCleanupSummary(summary orphanCleanupSummary) {
|
|
fields := []zap.Field{
|
|
zap.Int("enabled_agent_count", summary.enabledAgentCount),
|
|
zap.Int("db_agent_count", summary.dbAgentCount),
|
|
zap.Int("orphan_agent_count", summary.orphanAgentCount),
|
|
zap.Int("profiles_candidate_count", summary.profilesCandidateCount),
|
|
zap.Bool("profiles_candidate_partial", summary.profilesCandidatePartial),
|
|
zap.Int("profiles_deleted_count", summary.profilesDeletedCount),
|
|
zap.Int("profile_list_failure_count", summary.profileListFailureCount),
|
|
zap.Bool("skipped", summary.skipped),
|
|
zap.String("skip_reason", summary.skipReason),
|
|
zap.Int("max_agent_types_per_run", summary.maxAgentTypesPerRun),
|
|
zap.Int("max_profiles_per_run", summary.maxProfilesPerRun),
|
|
zap.Strings("enabled_agents", summary.enabledAgentIDs),
|
|
zap.Strings("orphan_agents", summary.orphanAgentNames),
|
|
}
|
|
r.log.Info("orphan cleanup summary", fields...)
|
|
}
|
|
|
|
// reconcileAgent validates or seeds profiles for a single inference agent.
|
|
// When the agent's probe is not "ok", existing profiles are left untouched —
|
|
// the UI surfaces the probe error and the user fixes it before we retry.
|
|
func (r *ProfileReconciler) reconcileAgent(ctx context.Context, ag agents.Agent) {
|
|
agentType := ag.ID()
|
|
caps, ok := r.hostUtility.Get(agentType)
|
|
if !ok || caps.Status != hostutility.StatusOK {
|
|
r.log.Debug("skipping reconciliation: probe not ok",
|
|
zap.String("agent_id", agentType),
|
|
zap.String("status", string(caps.Status)))
|
|
return
|
|
}
|
|
|
|
dbAgent, err := r.ensureDBAgent(ctx, ag)
|
|
if err != nil {
|
|
r.logReconcileError("reconcile: ensure db agent failed", err,
|
|
zap.String("agent_id", agentType))
|
|
return
|
|
}
|
|
|
|
profiles, err := r.store.ListAgentProfiles(ctx, dbAgent.ID)
|
|
if err != nil {
|
|
r.logReconcileError("reconcile: list profiles failed", err,
|
|
zap.String("agent_id", agentType))
|
|
return
|
|
}
|
|
|
|
if len(profiles) == 0 {
|
|
// Only seed for an agent that has never been provisioned. Soft-deleted
|
|
// rows mean the profile(s) were deliberately removed, so re-seeding
|
|
// would resurrect them on every boot (the bug this guards).
|
|
//
|
|
// A soft-deleted row here implies a *user* deletion, not system orphan
|
|
// cleanup: cleanupOrphans is the only system path that soft-deletes
|
|
// profiles, and it acts solely on agents absent from ListEnabled(),
|
|
// whereas reconcileAgent runs only for ListInferenceAgents(). Both gate
|
|
// on Enabled(), so the sets are disjoint — an enabled, reconciled agent
|
|
// is never orphan-cleaned. (If Enabled() ever becomes dynamic, revisit:
|
|
// a re-enabled agent reuses its DB id via ensureDBAgent and would then
|
|
// see its orphan-cleaned rows here.)
|
|
hadProfiles, err := r.store.HasDeletedAgentProfiles(ctx, dbAgent.ID)
|
|
if err != nil {
|
|
r.logReconcileError("reconcile: check deleted profiles failed", err,
|
|
zap.String("agent_id", agentType))
|
|
return
|
|
}
|
|
if hadProfiles {
|
|
r.log.Debug("skipping seed: agent has user-deleted profiles",
|
|
zap.String("agent_id", agentType))
|
|
return
|
|
}
|
|
r.seedDefaultProfile(ctx, ag, dbAgent, caps)
|
|
return
|
|
}
|
|
|
|
for _, p := range profiles {
|
|
r.healProfile(ctx, p, caps, ag.ID())
|
|
}
|
|
}
|
|
|
|
// ensureDBAgent looks up the agent row in the store or creates one if missing.
|
|
// The store's agent row uses an auto-generated UUID for `ID`; `Name` holds
|
|
// the registry-facing identifier like "claude-acp".
|
|
//
|
|
// Callers must distinguish "not found" from transient DB errors to avoid
|
|
// duplicate rows on lock/timeout.
|
|
func (r *ProfileReconciler) ensureDBAgent(ctx context.Context, ag agents.Agent) (*models.Agent, error) {
|
|
dbAgent, err := r.store.GetAgentByName(ctx, ag.ID())
|
|
if err == nil && dbAgent != nil {
|
|
return dbAgent, nil
|
|
}
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
// Transient DB error — don't attempt to create, caller will retry.
|
|
return nil, fmt.Errorf("get agent %q: %w", ag.ID(), err)
|
|
}
|
|
// Not found: create a new row. The store assigns ID on CreateAgent.
|
|
newAgent := &models.Agent{
|
|
Name: ag.ID(),
|
|
SupportsMCP: true,
|
|
}
|
|
if createErr := r.store.CreateAgent(ctx, newAgent); createErr != nil {
|
|
return nil, createErr
|
|
}
|
|
return newAgent, nil
|
|
}
|
|
|
|
// seedDefaultProfile creates a single default profile for an agent that has no
|
|
// existing profiles, using the probed CurrentModelID and CurrentModeID.
|
|
func (r *ProfileReconciler) seedDefaultProfile(
|
|
ctx context.Context,
|
|
ag agents.Agent,
|
|
dbAgent *models.Agent,
|
|
caps hostutility.AgentCapabilities,
|
|
) {
|
|
profile := &models.AgentProfile{
|
|
AgentID: dbAgent.ID,
|
|
Name: profileNameFromCaps(ag, caps),
|
|
AgentDisplayName: ag.DisplayName(),
|
|
Model: caps.CurrentModelID,
|
|
Mode: caps.CurrentModeID,
|
|
AllowIndexing: ag.ID() == "auggie",
|
|
CLIPassthrough: false,
|
|
CursorMCPAuthEnabled: true,
|
|
UserModified: false,
|
|
}
|
|
if err := r.store.CreateAgentProfile(ctx, profile); err != nil {
|
|
r.logReconcileError("seed default profile failed", err,
|
|
zap.String("agent_id", dbAgent.ID))
|
|
return
|
|
}
|
|
r.log.Info("seeded default profile from probe",
|
|
zap.String("profile_id", profile.ID),
|
|
zap.String("agent_id", dbAgent.ID),
|
|
zap.String("model", profile.Model),
|
|
zap.String("mode", profile.Mode))
|
|
}
|
|
|
|
// healProfile validates system-managed profile routes against the agent-wide
|
|
// capability cache. Compatibility migrations run for every profile, while
|
|
// catalog-driven route changes skip user-modified profiles because they can
|
|
// use provider configuration that is not visible to the host probe.
|
|
func (r *ProfileReconciler) healProfile(
|
|
ctx context.Context,
|
|
p *models.AgentProfile,
|
|
caps hostutility.AgentCapabilities,
|
|
agentID string,
|
|
) {
|
|
// Compatibility migrations describe persisted protocol identifiers, not
|
|
// catalog-driven route choices. They must run even for user-modified
|
|
// profiles so an agent bridge upgrade cannot leave an unusable mode/model.
|
|
changed := false
|
|
if model, options, migrated := acpcompat.MigrateCursorModel(agentID, p.Model, p.ConfigOptions); migrated {
|
|
r.log.Info("migrating Cursor variant profile model",
|
|
zap.String("profile_id", p.ID),
|
|
zap.String("old_model", p.Model),
|
|
zap.String("new_model", model))
|
|
p.Model = model
|
|
p.ConfigOptions = options
|
|
changed = true
|
|
}
|
|
if healCompatibilityMode(p, caps, agentID) {
|
|
r.log.Info("migrating legacy agent mode",
|
|
zap.String("profile_id", p.ID),
|
|
zap.String("agent_id", agentID),
|
|
zap.String("mode", p.Mode))
|
|
changed = true
|
|
}
|
|
if p.UserModified {
|
|
if !changed {
|
|
return
|
|
}
|
|
if err := r.store.UpdateAgentProfile(ctx, p); err != nil {
|
|
r.logReconcileError("profile compatibility migration update failed", err,
|
|
zap.String("profile_id", p.ID))
|
|
}
|
|
return
|
|
}
|
|
|
|
if healProfileName(p, caps) {
|
|
changed = true
|
|
}
|
|
|
|
// No-silent-model-fallback: a configured model that is no longer
|
|
// advertised (e.g. provider auth expired) is KEPT, never overwritten
|
|
// with the probe default. The UI surfaces it as unavailable so the user
|
|
// decides — implicitly switching models on boot is exactly what this
|
|
// feature forbids. Only the empty-model case ("use the agent's
|
|
// default") is seeded from the probe.
|
|
if p.Model != "" && !modelExists(p.Model, caps.Models) {
|
|
r.log.Info("profile model no longer available, keeping (no silent fallback)",
|
|
zap.String("profile_id", p.ID),
|
|
zap.String("model", p.Model))
|
|
}
|
|
if p.Model == "" && caps.CurrentModelID != "" {
|
|
p.Model = caps.CurrentModelID
|
|
changed = true
|
|
}
|
|
if p.FallbackModel != "" && !modelExists(p.FallbackModel, caps.Models) {
|
|
r.log.Info("profile fallback model no longer available, keeping (no silent fallback)",
|
|
zap.String("profile_id", p.ID),
|
|
zap.String("fallback_model", p.FallbackModel))
|
|
}
|
|
|
|
if p.Mode != "" && !modeExists(p.Mode, caps.Modes) {
|
|
r.log.Info("profile mode no longer available, clearing",
|
|
zap.String("profile_id", p.ID),
|
|
zap.String("old_mode", p.Mode))
|
|
p.Mode = ""
|
|
changed = true
|
|
}
|
|
if p.Mode == "" && caps.CurrentModeID != "" {
|
|
p.Mode = caps.CurrentModeID
|
|
changed = true
|
|
}
|
|
|
|
if !changed {
|
|
return
|
|
}
|
|
if err := r.store.UpdateAgentProfile(ctx, p); err != nil {
|
|
r.logReconcileError("profile heal update failed", err,
|
|
zap.String("profile_id", p.ID))
|
|
}
|
|
}
|
|
|
|
// healCompatibilityMode migrates a mode ID that belonged to the legacy Codex
|
|
// bridge. "auto" was accepted by that bridge but is not advertised by the
|
|
// current bridge. Custom modes on user profiles remain untouched.
|
|
func healCompatibilityMode(p *models.AgentProfile, caps hostutility.AgentCapabilities, agentID string) bool {
|
|
if p == nil || agentID != "codex-acp" || p.Mode != "auto" || caps.CurrentModeID == "" || caps.CurrentModeID == p.Mode {
|
|
return false
|
|
}
|
|
if !modeExists(caps.CurrentModeID, caps.Modes) {
|
|
return false
|
|
}
|
|
p.Mode = caps.CurrentModeID
|
|
return true
|
|
}
|
|
|
|
// healProfileName updates the profile name when it still matches the agent
|
|
// display name (stale seed from before we started naming profiles after the
|
|
// current model). Workspace-scoped and user-modified profiles are skipped.
|
|
func healProfileName(p *models.AgentProfile, caps hostutility.AgentCapabilities) bool {
|
|
if p.WorkspaceID != "" || p.UserModified || p.Name != p.AgentDisplayName || caps.CurrentModelID == "" {
|
|
return false
|
|
}
|
|
for _, m := range caps.Models {
|
|
if m.ID == caps.CurrentModelID && m.Name != "" && m.Name != p.Name {
|
|
p.Name = m.Name
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// profileNameFromCaps picks a user-facing default profile name from the
|
|
// probed model list: prefer the Name of the agent's currentModelId, fall
|
|
// back to the model id, then the agent display name. Keeps the profile
|
|
// label meaningful ("Claude Sonnet 4.6") instead of the agent name echoed.
|
|
func profileNameFromCaps(ag agents.Agent, caps hostutility.AgentCapabilities) string {
|
|
if caps.CurrentModelID != "" {
|
|
for _, m := range caps.Models {
|
|
if m.ID == caps.CurrentModelID && m.Name != "" {
|
|
return m.Name
|
|
}
|
|
}
|
|
return caps.CurrentModelID
|
|
}
|
|
return ag.DisplayName()
|
|
}
|
|
|
|
func modelExists(id string, models []hostutility.Model) bool {
|
|
for _, m := range models {
|
|
if m.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func modeExists(id string, modes []hostutility.Mode) bool {
|
|
for _, m := range modes {
|
|
if m.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|