Files
kandev/apps/backend/internal/agent/settings/controller/controller.go
T
João Salavisa a7005e5d2f chore: revert agent subscription usage in settings and chat tooltip (#1940)
* chore: revert agent subscription usage in settings and chat tooltip

Reverts #1697 (55c8b461b). The subscription usage plugin now covers this,
so the built-in Settings > Agents section and the context-doughnut tooltip
rows are no longer needed.

Conflicts resolved to preserve later work on the same files:
- token-usage-display.tsx/.test.tsx: kept the pinnable tooltip (#1869),
  the rebuilt context-window layout (#1741), and the ACP/API source row
  (#1752); removed only SessionUsageRows and its imports.
- settings/agents/page.tsx: #1741 moved <AgentUsageSection /> above the
  pre-existing <Separator />, so the revert missed the JSX; removed it
  and kept the separator.
- agent-usage-section.tsx/.test.tsx: deleted (later changes were cosmetic
  tweaks to the section itself).

Office-side subscription surfaces (dashboard quota card, agent detail
QuotaCard, internal/agent/usage clients) predate #1697 and are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(usage): keep #1697 client fixes when reverting its UI surface

#1697 bundled two things: the Settings > Agents section plus doughnut
tooltip rows, and a set of genuine bug fixes to the underlying provider
usage clients. Reverting it wholesale threw away the second group, which
is still live code behind the office dashboard quota card and the agent
detail QuotaCard.

Restores the client layer to its post-#1697 state (billing.go, cache.go,
client_claude.go, client_codex.go, fileutil.go, types.go and the two
client tests), fixing the defects CodeRabbit flagged on #1940:

- billing type is recomputed per call again instead of being frozen for
  the process lifetime by sync.OnceValue, so login/logout is observed
  without a backend restart
- usage cache regains fetch coalescing and short negative caching, so a
  provider outage no longer means one outbound call per request
- Claude usage parses float utilization and the richer limits[] array
  instead of failing to decode
- Claude token refresh patches only claudeAiOauth in a decoded map and
  writes atomically via temp+rename, preserving unknown keys in the
  credentials file the real CLI shares
- Codex reads tokens.access_token rather than a nonexistent flat token
  field, and its doc comment matches the ~/.codex/auth.json path

The removed UI/API surface stays reverted: GET /api/v1/agents/usage, the
host usage lister, the settings section, and the tooltip rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:04:25 +01:00

212 lines
8.3 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/discovery"
agentdto "github.com/kandev/kandev/internal/agent/dto"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/modelfetcher"
"github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// buildCommandString builds a display-friendly command string with proper quoting.
func buildCommandString(cmd []string) string {
var parts []string
for _, arg := range cmd {
if strings.ContainsAny(arg, " \t\n\"'`$\\") {
escaped := strings.ReplaceAll(arg, "\"", "\\\"")
parts = append(parts, "\""+escaped+"\"")
} else {
parts = append(parts, arg)
}
}
return strings.Join(parts, " ")
}
var (
ErrAgentNotFound = errors.New("agent not found")
ErrAgentAlreadyExists = errors.New("agent already exists")
ErrAgentProfileNotFound = errors.New("agent profile not found")
ErrAgentMcpUnsupported = errors.New("mcp not supported by agent")
ErrModelRequired = errors.New("model is required for agent profiles")
ErrLogoNotAvailable = errors.New("logo not available for agent")
ErrInvalidSlug = errors.New("display name must produce a valid slug")
ErrCommandRequired = errors.New("command is required")
ErrInvalidProfileEnvVars = errors.New("invalid profile env vars")
ErrInvalidCommandPrefix = errors.New("invalid command prefix")
)
type Controller struct {
repo store.Repository
discovery *discovery.Registry
agentRegistry *registry.Registry
sessionChecker SessionChecker
watcherDeps WatcherDependencyChecker
routingTierDeps RoutingTierDependencyChecker
mcpService *mcpconfig.Service
modelCache *modelfetcher.Cache
hostUtility *hostutility.Manager
jobStore *JobStore
hub JobBroadcaster
logger *logger.Logger
}
// SetWatcherDependencyChecker wires in the watcher dependency enumerator so
// DeleteProfile can include referencing watchers in ErrProfileInUseDetail.
// Optional; when unset the delete path keeps its pre-watcher behaviour.
func (c *Controller) SetWatcherDependencyChecker(w WatcherDependencyChecker) {
c.watcherDeps = w
}
// SetRoutingTierDependencyChecker wires in workspace routing tier lookups so
// DeleteProfile can reject profiles selected as a workspace tier source.
func (c *Controller) SetRoutingTierDependencyChecker(r RoutingTierDependencyChecker) {
c.routingTierDeps = r
}
// ErrProfileInUseDetail is returned when a profile cannot be deleted because
// active sessions or external integration watchers reference it. The UI uses
// the breakdown to render a "this will also disable N watchers — continue?"
// confirmation dialog before re-issuing the request with force=true.
type ErrProfileInUseDetail struct {
ActiveSessions []agentdto.ActiveTaskInfo
Watchers []WatcherReference
RoutingTiers []RoutingTierReference
}
func (e *ErrProfileInUseDetail) Error() string {
return fmt.Sprintf("agent profile is used by %d active session(s), %d watcher(s), and %d routing tier(s)",
len(e.ActiveSessions), len(e.Watchers), len(e.RoutingTiers))
}
// WatcherReference points at one issue/PR watcher row that uses the profile
// being deleted. Kind is the integration name ("linear", "jira",
// "github_issue", "github_review"). Label is a short human-friendly string
// (the filter, repo list, or JQL clipped to a UI-safe length by the producer).
type WatcherReference struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
}
// RoutingTierReference points at a workspace provider-routing tier that was
// seeded from the profile being deleted.
type RoutingTierReference struct {
WorkspaceID string `json:"workspace_id"`
ProviderID string `json:"provider_id"`
Tier string `json:"tier"`
}
// WatcherDependencyChecker enumerates watcher rows that reference an agent
// profile and disables them on force-delete. Implementations live in
// cmd/kandev (one per integration store); the controller stays decoupled
// from linear/jira/github packages.
//
// ListWatchersByAgentProfile feeds the confirmation dialog; the user sees
// the list and confirms. DisableWatchersByAgentProfile fires on force-delete
// so the watcher row reflects the deletion immediately — without it, the
// watcher stays enabled-but-orphaned until its next external trigger fires
// the lazy preflight, which never happens for filters that match nothing
// new after the profile is deleted.
type WatcherDependencyChecker interface {
ListWatchersByAgentProfile(ctx context.Context, agentProfileID string) ([]WatcherReference, error)
DisableWatchersByAgentProfile(ctx context.Context, agentProfileID, cause string) ([]WatcherReference, error)
}
type SessionChecker interface {
HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error)
DeleteEphemeralTasksByAgentProfile(ctx context.Context, agentProfileID string) (int64, error)
GetActiveTaskInfoByAgentProfile(ctx context.Context, agentProfileID string) ([]agentdto.ActiveTaskInfo, error)
}
type RoutingTierDependencyChecker interface {
ListRoutingTierReferencesByAgentProfile(ctx context.Context, profileID string) ([]RoutingTierReference, error)
}
func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry *registry.Registry, sessionChecker SessionChecker, log *logger.Logger,
) *Controller {
return &Controller{
repo: repo,
discovery: discoveryRegistry,
agentRegistry: agentRegistry,
sessionChecker: sessionChecker,
mcpService: mcpconfig.NewService(repo),
modelCache: modelfetcher.NewCache(),
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
}
}
// SetHostUtility wires the host utility manager into the controller so that
// endpoints like /agent-models can read the cached capability data. Called
// once at startup after the host utility manager is constructed; leaving this
// unset simply causes the model endpoints to report "not_configured".
func (c *Controller) SetHostUtility(h *hostutility.Manager) {
c.hostUtility = h
}
// SetJobBroadcaster initializes the install job store with a WS broadcaster
// for streaming install progress. Called once during handler registration.
// If unset (hub == nil), the streaming install API returns
// ErrJobStoreUnavailable — without this guard a nil hub would silently
// degrade to a non-broadcasting store and the UI would never see progress.
func (c *Controller) SetJobBroadcaster(hub JobBroadcaster) {
c.hub = hub
if hub == nil {
c.jobStore = nil
return
}
c.jobStore = NewJobStore(hub, c.logger.Zap(), func(agentName string) {
c.InvalidateDiscoveryCache()
// Kick a fresh capability probe immediately so the UI doesn't sit on
// stale "not_installed" until the next periodic poll. When the probe
// finishes, re-broadcast the updated availability so any open profile
// page transitions out of "Probing…" without a manual refresh.
if c.hostUtility != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := c.hostUtility.Refresh(ctx, agentName); err != nil {
c.logger.Debug("post-install capability refresh failed",
zap.String("agent", agentName), zap.Error(err))
}
c.BroadcastAvailableAgents()
}()
}
c.logger.Info("install succeeded", zap.String("agent", agentName))
})
}
// BroadcastAvailableAgents fetches the current available-agents snapshot and
// pushes it over WS as `agent.available.updated`. Used after install + probe
// so the UI flips from "probing" to the resolved status without a refresh.
func (c *Controller) BroadcastAvailableAgents() {
if c.hub == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := c.ListAvailableAgents(ctx)
if err != nil {
c.logger.Debug("broadcast available agents: list failed", zap.Error(err))
return
}
msg, err := ws.NewNotification(ws.ActionAgentAvailableUpdated, map[string]any{
"agents": resp.Agents,
"tools": resp.Tools,
})
if err != nil {
return
}
c.hub.Broadcast(msg)
}