diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 1cced140a7..f2b276e564 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/coder/acp-go-sdk v0.13.5 github.com/creack/pty v1.1.24 + github.com/Masterminds/semver/v3 v3.2.1 github.com/gin-gonic/gin v1.9.1 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.6.0 @@ -46,7 +47,6 @@ require ( ) require ( - github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 55aa324722..5ca39f685c 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -176,6 +176,9 @@ type CommandOptions struct { // standalone CLI was found in the execution environment. Such agents emit // the native binary (e.g. "copilot --acp") instead of "npx -y ". PreferNativeBinary bool + // ManagedRuntimeVersion is an internal exact version override for trusted + // managed npm ACP runtimes. Empty preserves the legacy unversioned command. + ManagedRuntimeVersion string } // PassthroughOptions are passed to BuildPassthroughCommand. diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index ebce7badc9..00c7cc77d7 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -107,7 +107,7 @@ func (a *ClaudeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { } func (a *ClaudeACP) BuildCommand(opts CommandOptions) Command { - return a.ManagedNPMRuntime().CachedACPCommand() + return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion) } func (a *ClaudeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index a5ff6cd286..787cdfb18e 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -96,7 +96,7 @@ func (a *CodexACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { } func (a *CodexACP) BuildCommand(opts CommandOptions) Command { - return a.ManagedNPMRuntime().CachedACPCommand() + return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion) } func (a *CodexACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { diff --git a/apps/backend/internal/agent/agents/copilot_acp.go b/apps/backend/internal/agent/agents/copilot_acp.go index ca7d447cb3..cd3c8c76e8 100644 --- a/apps/backend/internal/agent/agents/copilot_acp.go +++ b/apps/backend/internal/agent/agents/copilot_acp.go @@ -90,8 +90,8 @@ func (a *CopilotACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) // execution environment. See NativeBinaryAgent. func (a *CopilotACP) NativeBinaryName() string { return copilotNativeBinary } -func (a *CopilotACP) BuildCommand(CommandOptions) Command { - return a.ManagedNPMRuntime().CachedACPCommand() +func (a *CopilotACP) BuildCommand(opts CommandOptions) Command { + return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion) } func (a *CopilotACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index c7e3a4ca1d..759f569b2c 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -78,7 +78,7 @@ func (a *Gemini) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { } func (a *Gemini) BuildCommand(opts CommandOptions) Command { - return a.ManagedNPMRuntime().CachedACPCommand() + return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion) } func (a *Gemini) ManagedNPMRuntime() ManagedNPMRuntimeSpec { diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime.go b/apps/backend/internal/agent/agents/managed_npm_runtime.go index b645d3e2c0..f45c4e915e 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime.go @@ -13,36 +13,56 @@ type ManagedNPMRuntimeSpec struct { ACPArgs []string } +// PackageSpec returns the trusted package name or exact package@version spec. +func (s ManagedNPMRuntimeSpec) PackageSpec(version string) string { + if version == "" { + return s.Package + } + return s.Package + "@" + version +} + // ExecutionCacheKey returns npm's deterministic _npx execution-tree key for // this trusted package spec. npm derives it from the full package string using -// SHA-512 and the first 16 lowercase hexadecimal characters. -func (s ManagedNPMRuntimeSpec) ExecutionCacheKey() string { - digest := sha512.Sum512([]byte(s.Package)) +// SHA-512 and the first 16 lowercase hexadecimal characters. The optional +// argument preserves the unversioned legacy key when omitted. +func (s ManagedNPMRuntimeSpec) ExecutionCacheKey(versions ...string) string { + digest := sha512.Sum512([]byte(s.PackageSpec(firstVersion(versions)))) return hex.EncodeToString(digest[:])[:16] } -// CachedACPCommand returns the normal launch command. The package is -// intentionally unversioned and prefer-offline lets npm reuse a suitable -// execution-cache entry while still fetching when the cache is empty. -func (s ManagedNPMRuntimeSpec) CachedACPCommand() Command { - args := []string{"npx", "--yes", "--prefer-offline", s.Package} +// ACPCommand returns the normal launch command for the exact version when one +// is supplied. An empty version keeps the legacy unversioned behavior. +func (s ManagedNPMRuntimeSpec) ACPCommand(version string) Command { + args := []string{"npx", "--yes", "--prefer-offline", s.PackageSpec(version)} args = append(args, s.ACPArgs...) return NewCommand(args...) } -// CacheUpdateCommand returns the explicit cache-refresh command. npm exec -// installs the built-in unversioned package with online freshness before -// running a no-op under the prepared execution environment. -func (s ManagedNPMRuntimeSpec) CacheUpdateCommand() Command { +// CachedACPCommand returns the legacy unversioned launch command. +func (s ManagedNPMRuntimeSpec) CachedACPCommand() Command { + return s.ACPCommand("") +} + +// CacheUpdateCommand returns the explicit cache preparation command. The +// optional version makes npm prepare one deterministic package@version tree. +func (s ManagedNPMRuntimeSpec) CacheUpdateCommand(versions ...string) Command { + packageSpec := s.PackageSpec(firstVersion(versions)) return NewCommand( "npm", "exec", "--yes", "--prefer-online", - "--package="+s.Package, + "--package="+packageSpec, "--", "node", "-e", "", ) } + +func firstVersion(versions []string) string { + if len(versions) == 0 { + return "" + } + return versions[0] +} diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go index 5459c4d811..abeb89ab87 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go @@ -59,6 +59,63 @@ func TestManagedNPMRuntimeExecutionCacheKeyMatchesNPM(t *testing.T) { } } +func TestManagedNPMRuntimeBuildsExactVersionCommandsAndCacheKey(t *testing.T) { + spec := ManagedNPMRuntimeSpec{ + Package: "opencode-ai", + ACPArgs: []string{"acp", "--print-logs"}, + } + wantACP := []string{"npx", "--yes", "--prefer-offline", "opencode-ai@1.18.5", "acp", "--print-logs"} + if got := spec.ACPCommand("1.18.5").Args(); !slices.Equal(got, wantACP) { + t.Fatalf("ACPCommand = %#v, want %#v", got, wantACP) + } + wantUpdate := []string{ + "npm", "exec", "--yes", "--prefer-online", + "--package=opencode-ai@1.18.5", "--", "node", "-e", "", + } + if got := spec.CacheUpdateCommand("1.18.5").Args(); !slices.Equal(got, wantUpdate) { + t.Fatalf("CacheUpdateCommand = %#v, want %#v", got, wantUpdate) + } + if got := spec.ExecutionCacheKey("1.18.5"); got != "cd439a892fc193b3" { + t.Fatalf("versioned ExecutionCacheKey = %q, want cd439a892fc193b3", got) + } +} + +func TestManagedNPMRuntimeExactVersionSupportsScopedPackages(t *testing.T) { + spec := ManagedNPMRuntimeSpec{Package: "@scope/managed-acp", ACPArgs: []string{"--acp"}} + want := []string{"npx", "--yes", "--prefer-offline", "@scope/managed-acp@3.4.5", "--acp"} + if got := spec.ACPCommand("3.4.5").Args(); !slices.Equal(got, want) { + t.Fatalf("scoped ACPCommand = %#v, want %#v", got, want) + } + if spec.ExecutionCacheKey("3.4.5") == spec.ExecutionCacheKey() { + t.Fatal("versioned scoped cache key equals legacy key") + } +} + +func TestManagedAgentsHonorExactVersionCommandOption(t *testing.T) { + tests := []struct { + name string + agent ManagedNPMRuntimeAgent + }{ + {"claude", NewClaudeACP()}, + {"codex", NewCodexACP()}, + {"opencode", NewOpenCodeACP()}, + {"copilot", NewCopilotACP()}, + {"gemini", NewGemini()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version := "1.2.3" + want := tt.agent.ManagedNPMRuntime().ACPCommand(version).Args() + got := tt.agent.(interface { + BuildCommand(CommandOptions) Command + }).BuildCommand(CommandOptions{ManagedRuntimeVersion: version}).Args() + if !slices.Equal(got, want) { + t.Fatalf("exact BuildCommand = %#v, want %#v", got, want) + } + }) + } +} + func assertUnversionedPackage(t *testing.T, argv []string, wantPackage string) { t.Helper() packageArg := argv[3] diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index 8d38bc05a6..1af18bbed2 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -85,7 +85,7 @@ func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) } func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command { - return a.ManagedNPMRuntime().CachedACPCommand() + return a.ManagedNPMRuntime().ACPCommand(opts.ManagedRuntimeVersion) } func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { diff --git a/apps/backend/internal/agent/hostutility/managed_runtime_test.go b/apps/backend/internal/agent/hostutility/managed_runtime_test.go new file mode 100644 index 0000000000..bb4d9f8dde --- /dev/null +++ b/apps/backend/internal/agent/hostutility/managed_runtime_test.go @@ -0,0 +1,71 @@ +package hostutility + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/managedruntime" +) + +type managedRuntimeSelectionReader struct { + selection managedruntime.Selection + found bool + err error +} + +func (r managedRuntimeSelectionReader) Get( + context.Context, + string, + string, +) (managedruntime.Selection, bool, error) { + return r.selection, r.found, r.err +} + +func TestResolveInferenceCommandUsesActiveExactVersion(t *testing.T) { + manager := &Manager{managedRuntimeSelections: managedRuntimeSelectionReader{ + selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"}, + found: true, + }} + agent := agents.NewOpenCodeACP() + + command, err := manager.resolveInferenceCommand(context.Background(), agent.ID(), agent, agents.Command{}) + if err != nil { + t.Fatalf("resolveInferenceCommand: %v", err) + } + want := agent.ManagedNPMRuntime().ACPCommand("1.18.5").Args() + if got := command.Args(); !equalStrings(got, want) { + t.Fatalf("command = %#v, want %#v", got, want) + } +} + +func TestResolveInferenceCommandPreservesLegacyAndReportsSelectionErrors(t *testing.T) { + agent := agents.NewOpenCodeACP() + manager := &Manager{managedRuntimeSelections: managedRuntimeSelectionReader{}} + command, err := manager.resolveInferenceCommand(context.Background(), agent.ID(), agent, agents.Command{}) + if err != nil { + t.Fatalf("legacy resolveInferenceCommand: %v", err) + } + if got, want := command.Args(), agent.ManagedNPMRuntime().CachedACPCommand().Args(); !equalStrings(got, want) { + t.Fatalf("legacy command = %#v, want %#v", got, want) + } + + wantErr := errors.New("selection read failed") + manager.managedRuntimeSelections = managedRuntimeSelectionReader{err: wantErr} + if _, err := manager.resolveInferenceCommand(context.Background(), agent.ID(), agent, agents.Command{}); !errors.Is(err, wantErr) { + t.Fatalf("selection error = %v, want %v", err, wantErr) + } +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index 3a0d35c8dd..4eca56472d 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -16,6 +16,7 @@ import ( "golang.org/x/sync/singleflight" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/registry" agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" @@ -54,14 +55,15 @@ type Manager struct { cache *cache modelCache *modelConfigCache - mu sync.RWMutex - instances map[string]*instance // keyed by agent type - createGroup singleflight.Group - modelGroup singleflight.Group - modelGenerationMu sync.Mutex - modelGenerations map[string]uint64 - startCancel context.CancelFunc - stopped bool + mu sync.RWMutex + instances map[string]*instance // keyed by agent type + createGroup singleflight.Group + modelGroup singleflight.Group + modelGenerationMu sync.Mutex + modelGenerations map[string]uint64 + managedRuntimeSelections managedruntime.SelectionReader + startCancel context.CancelFunc + stopped bool } // SetProfileResolver wires the profile eligibility and launch-policy reader. @@ -105,6 +107,12 @@ func (m *Manager) SetAuthToken(token string) { m.authToken = token } +// SetManagedRuntimeSelectionStore wires the install-wide exact-version +// resolver used by every host-local managed-runtime command path. +func (m *Manager) SetManagedRuntimeSelectionStore(store managedruntime.SelectionReader) { + m.managedRuntimeSelections = store +} + func (m *Manager) SetTemporaryArtifactRegistry(registry *tempartifacts.Registry) { m.mu.Lock() defer m.mu.Unlock() @@ -623,8 +631,12 @@ func (m *Manager) probeWithCommand( ) AgentCapabilities { probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() + resolvedCommand, err := m.resolveInferenceCommand(probeCtx, inst.agentType, ia, command) + if err != nil { + return probeFailureCapabilities(inst.agentType, StatusFailed, err.Error(), 0, time.Now()) + } - req := buildProbeRequest(inst, ia, refresh, command) + req := buildProbeRequest(inst, ia, refresh, resolvedCommand) resp, err := inst.client.Probe(probeCtx, req) now := time.Now() if err != nil { @@ -676,6 +688,41 @@ func (m *Manager) probeWithCommand( return caps } +// resolveInferenceCommand selects the trusted exact host version for ordinary +// probes and prompts. A non-empty override is reserved for candidate probes. +func (m *Manager) resolveInferenceCommand( + ctx context.Context, + agentType string, + ia agents.InferenceAgent, + override agents.Command, +) (agents.Command, error) { + if !override.IsEmpty() { + return override, nil + } + cfg := ia.InferenceConfig() + if cfg == nil || !cfg.Supported { + return agents.Command{}, errors.New("inference config not available") + } + command := cfg.Command + ag, ok := ia.(agents.Agent) + if !ok || m.managedRuntimeSelections == nil { + return command, nil + } + managed, ok := ag.(agents.ManagedNPMRuntimeAgent) + if !ok { + return command, nil + } + spec := managed.ManagedNPMRuntime() + selection, found, err := m.managedRuntimeSelections.Get(ctx, agentType, spec.Package) + if err != nil { + return agents.Command{}, fmt.Errorf("resolve active managed runtime version for %s: %w", agentType, err) + } + if !found || selection.Package != spec.Package { + return command, nil + } + return spec.ACPCommand(selection.Version), nil +} + const modelConfigResolveTimeout = 60 * time.Second func buildProbeRequest( diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index f54ade81ab..8a93cc5103 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -47,6 +47,10 @@ func (m *Manager) ExecuteProfilePrompt(ctx context.Context, profileID, prompt st return nil, err } cfg := ia.InferenceConfig() + command, err := m.resolveInferenceCommand(ctx, profile.AgentID, ia, agents.Command{}) + if err != nil { + return nil, err + } resolved := m.resolveModel(profile.AgentID, profile.Model, ia) autoApprove := profile.AutoApprove req := &agentctlutil.PromptRequest{ @@ -56,7 +60,7 @@ func (m *Manager) ExecuteProfilePrompt(ctx context.Context, profileID, prompt st Mode: profile.Mode, AutoApprovePermissions: &autoApprove, InferenceConfig: &agentctlutil.InferenceConfigDTO{ - Command: cfg.Command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, + Command: command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: cliFlags, CommandPrefix: prefix, }, } @@ -171,8 +175,12 @@ func (m *Manager) resolveModelConfigFlight( if cfg == nil || !cfg.Supported { return nil, errors.New("inference config not available") } + command, err := m.resolveInferenceCommand(probeCtx, agentType, ia, agents.Command{}) + if err != nil { + return nil, err + } - probeReq := buildProbeRequest(inst, ia, req.Refresh, agents.Command{}) + probeReq := buildProbeRequest(inst, ia, req.Refresh, command) probeReq.Model = req.Model probeReq.Mode = req.Mode probeReq.ConfigOptions = cloneStringMap(req.ConfigOptions) @@ -240,6 +248,23 @@ func (m *Manager) RefreshWithCommand( ctx context.Context, agentType string, command agents.Command, +) (AgentCapabilities, error) { + caps, err := m.ProbeWithCommand(ctx, agentType, command) + if err != nil { + return AgentCapabilities{}, err + } + if caps.Status == StatusOK { + m.PublishCapabilities(agentType, caps) + } + return caps, nil +} + +// ProbeWithCommand runs a trusted command override without changing the live +// capability cache. Runtime activation uses this boundary before persistence. +func (m *Manager) ProbeWithCommand( + ctx context.Context, + agentType string, + command agents.Command, ) (AgentCapabilities, error) { m.invalidateModelConfigCache(agentType) inst, ia, err := m.getInstance(ctx, agentType) @@ -247,12 +272,19 @@ func (m *Manager) RefreshWithCommand( return AgentCapabilities{}, err } caps := m.probeWithCommand(ctx, inst, ia, true, command) - if caps.Status == StatusOK { - m.cache.set(caps) - } return caps, nil } +// PublishCapabilities makes a successful candidate the live catalogue. The +// caller is responsible for persisting the active selection first. +func (m *Manager) PublishCapabilities(agentType string, caps AgentCapabilities) { + if caps.Status != StatusOK { + return + } + m.invalidateModelConfigCache(agentType) + m.cache.set(caps) +} + // ExecutePrompt runs a sessionless utility prompt against the warm instance // for the given agent type. Convenience wrapper over ExecutePromptWithMCP // that opts the call out of MCP tool access — most callers (PR title, commit @@ -281,6 +313,10 @@ func (m *Manager) ExecutePromptWithMCP( return nil, err } cfg := ia.InferenceConfig() + command, err := m.resolveInferenceCommand(ctx, agentType, ia, agents.Command{}) + if err != nil { + return nil, err + } resolved := m.resolveModel(agentType, model, ia) @@ -290,7 +326,7 @@ func (m *Manager) ExecutePromptWithMCP( Model: resolved, Mode: mode, InferenceConfig: &agentctlutil.InferenceConfigDTO{ - Command: cfg.Command.Args(), + Command: command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, Env: agents.RuntimeEnvFor(ia), diff --git a/apps/backend/internal/agent/managedruntime/selection.go b/apps/backend/internal/agent/managedruntime/selection.go new file mode 100644 index 0000000000..6ad494c873 --- /dev/null +++ b/apps/backend/internal/agent/managedruntime/selection.go @@ -0,0 +1,102 @@ +package managedruntime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +var ( + ErrInvalidSelection = errors.New("invalid managed runtime selection") + errSettingsMissing = errors.New("managed runtime settings store is unavailable") +) + +const selectionKeyPrefix = "managed_runtime.active." + +// SettingsStore is the small persistence seam required by the install-wide +// managed-runtime selection store. +type SettingsStore interface { + Get(context.Context, string) ([]byte, bool, error) + Save(context.Context, string, []byte) error +} + +// SelectionStore is the runtime-facing seam for reading and persisting an +// active managed-runtime selection. +type SelectionStore interface { + Get(context.Context, string, string) (Selection, bool, error) + Save(context.Context, string, string, string) error +} + +// SelectionReader is the read-only seam used by runtime command builders. +type SelectionReader interface { + Get(context.Context, string, string) (Selection, bool, error) +} + +// Selection is the trusted package identity and exact active version for one +// built-in agent. +type Selection struct { + Package string `json:"package"` + Version string `json:"version"` +} + +type Store struct { + settings SettingsStore +} + +// NewStore creates the install-wide active-version store. +func NewStore(settings SettingsStore) *Store { + return &Store{settings: settings} +} + +// Get returns the selection only when it belongs to the package currently +// trusted for the agent. A package replacement therefore starts unselected. +func (s *Store) Get(ctx context.Context, agentID, packageName string) (Selection, bool, error) { + if s == nil || s.settings == nil { + return Selection{}, false, errSettingsMissing + } + if agentID == "" || packageName == "" { + return Selection{}, false, fmt.Errorf("%w: agent and package are required", ErrInvalidSelection) + } + raw, found, err := s.settings.Get(ctx, selectionKey(agentID)) + if err != nil || !found { + return Selection{}, found, err + } + var selection Selection + if err := json.Unmarshal(raw, &selection); err != nil { + return Selection{}, false, fmt.Errorf("%w: decode: %v", ErrInvalidSelection, err) + } + if selection.Package != packageName { + return Selection{}, false, nil + } + if selection.Version == "" { + return Selection{}, false, fmt.Errorf("%w: version is empty", ErrInvalidSelection) + } + if _, err := ParseStableVersion(selection.Version); err != nil { + return Selection{}, false, fmt.Errorf("%w: %v", ErrInvalidSelection, err) + } + return selection, true, nil +} + +// Save persists a validated exact version for one trusted package. +func (s *Store) Save(ctx context.Context, agentID, packageName, version string) error { + if s == nil || s.settings == nil { + return errSettingsMissing + } + if agentID == "" || packageName == "" { + return fmt.Errorf("%w: agent and package are required", ErrInvalidSelection) + } + if _, err := ParseStableVersion(version); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidSelection, err) + } + raw, err := json.Marshal(Selection{Package: packageName, Version: version}) + if err != nil { + return fmt.Errorf("marshal managed runtime selection: %w", err) + } + return s.settings.Save(ctx, selectionKey(agentID), raw) +} + +func selectionKey(agentID string) string { + return selectionKeyPrefix + strings.TrimSpace(agentID) +} diff --git a/apps/backend/internal/agent/managedruntime/selection_test.go b/apps/backend/internal/agent/managedruntime/selection_test.go new file mode 100644 index 0000000000..e3d9fbe68d --- /dev/null +++ b/apps/backend/internal/agent/managedruntime/selection_test.go @@ -0,0 +1,82 @@ +package managedruntime + +import ( + "context" + "errors" + "testing" +) + +type memorySettings struct { + values map[string][]byte + err error +} + +func (s *memorySettings) Get(_ context.Context, key string) ([]byte, bool, error) { + if s.err != nil { + return nil, false, s.err + } + value, ok := s.values[key] + return append([]byte(nil), value...), ok, nil +} + +func (s *memorySettings) Save(_ context.Context, key string, value []byte) error { + if s.err != nil { + return s.err + } + if s.values == nil { + s.values = make(map[string][]byte) + } + s.values[key] = append([]byte(nil), value...) + return nil +} + +func TestStoreRoundTripAndPerAgentIsolation(t *testing.T) { + settings := &memorySettings{} + store := NewStore(settings) + ctx := context.Background() + + if _, found, err := store.Get(ctx, "opencode-acp", "opencode-ai"); err != nil || found { + t.Fatalf("missing selection = found %v, err %v", found, err) + } + if err := store.Save(ctx, "opencode-acp", "opencode-ai", "1.18.5"); err != nil { + t.Fatalf("save opencode: %v", err) + } + if err := store.Save(ctx, "claude-acp", "@example/claude", "2.0.0"); err != nil { + t.Fatalf("save claude: %v", err) + } + + got, found, err := store.Get(ctx, "opencode-acp", "opencode-ai") + if err != nil || !found || got.Package != "opencode-ai" || got.Version != "1.18.5" { + t.Fatalf("opencode selection = %#v, found %v, err %v", got, found, err) + } + got, found, err = store.Get(ctx, "claude-acp", "@example/claude") + if err != nil || !found || got.Version != "2.0.0" { + t.Fatalf("claude selection = %#v, found %v, err %v", got, found, err) + } +} + +func TestStoreRejectsInvalidStoredValueAndPackageMismatch(t *testing.T) { + settings := &memorySettings{values: map[string][]byte{ + selectionKey("opencode-acp"): []byte(`{"package":"opencode-ai","version":"latest"}`), + selectionKey("claude-acp"): []byte(`{"package":"old-claude","version":"1.0.0"}`), + }} + store := NewStore(settings) + + if _, _, err := store.Get(context.Background(), "opencode-acp", "opencode-ai"); !errors.Is(err, ErrInvalidSelection) { + t.Fatalf("invalid stored selection error = %v, want %v", err, ErrInvalidSelection) + } + if _, found, err := store.Get(context.Background(), "claude-acp", "@example/claude"); err != nil || found { + t.Fatalf("package mismatch = found %v, err %v; want no selection", found, err) + } +} + +func TestStorePropagatesSettingsErrors(t *testing.T) { + wantErr := errors.New("settings unavailable") + store := NewStore(&memorySettings{err: wantErr}) + if _, _, err := store.Get(context.Background(), "opencode-acp", "opencode-ai"); !errors.Is(err, wantErr) { + t.Fatalf("get error = %v, want %v", err, wantErr) + } + if err := store.Save(context.Background(), "opencode-acp", "opencode-ai", "1.18.5"); !errors.Is(err, wantErr) { + t.Fatalf("save error = %v, want %v", err, wantErr) + } +} diff --git a/apps/backend/internal/agent/managedruntime/versions.go b/apps/backend/internal/agent/managedruntime/versions.go new file mode 100644 index 0000000000..0a1357729d --- /dev/null +++ b/apps/backend/internal/agent/managedruntime/versions.go @@ -0,0 +1,220 @@ +package managedruntime + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" +) + +var ErrInvalidStableVersion = errors.New("invalid stable semver") +var ErrNoStableVersions = errors.New("no stable runtime versions available") + +// Operation is the backend-derived action for a managed runtime target. +type Operation string + +const ( + OperationUpdate Operation = "update" + OperationRollback Operation = "rollback" + OperationRepair Operation = "repair" + OperationUpToDate Operation = "up_to_date" +) + +// VersionOption is one selectable version in the bounded catalogue. +type VersionOption struct { + Version string `json:"version"` + Latest bool `json:"latest"` +} + +// Catalogue contains the bounded UI projection and the complete stable set +// used to authorize a submitted target. +type Catalogue struct { + Versions []VersionOption + Latest string + all map[string]struct{} +} + +var stableVersionPattern = regexp.MustCompile( + `^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`, +) + +// ParseStableVersion accepts only strict, prerelease-free SemVer values. +func ParseStableVersion(value string) (*semver.Version, error) { + if !stableVersionPattern.MatchString(value) { + return nil, fmt.Errorf("%w: %q", ErrInvalidStableVersion, value) + } + parsed, err := semver.NewVersion(value) + if err != nil { + return nil, fmt.Errorf("%w: %q: %v", ErrInvalidStableVersion, value, err) + } + if parsed.Prerelease() != "" { + return nil, fmt.Errorf("%w: prerelease %q", ErrInvalidStableVersion, value) + } + return parsed, nil +} + +// SortStableVersions validates, deduplicates, and sorts versions newest first. +func SortStableVersions(values []string) ([]string, error) { + type parsedVersion struct { + value string + parsed *semver.Version + } + unique := make(map[string]parsedVersion, len(values)) + for _, value := range values { + parsed, err := ParseStableVersion(value) + if err != nil { + return nil, err + } + unique[value] = parsedVersion{value: value, parsed: parsed} + } + versions := make([]parsedVersion, 0, len(unique)) + for _, version := range unique { + versions = append(versions, version) + } + sort.Slice(versions, func(i, j int) bool { + compare := versions[i].parsed.Compare(versions[j].parsed) + if compare == 0 { + return versions[i].value > versions[j].value + } + return compare > 0 + }) + out := make([]string, len(versions)) + for i, version := range versions { + out[i] = version.value + } + return out, nil +} + +// BuildCatalogue filters npm metadata to strict stable versions, orders it +// newest first, and keeps the newest 50 plus any active/current extras. The +// complete stable set remains private so callers can validate targets that +// are outside the bounded UI projection. +func BuildCatalogue(published []string, latest string, extras ...string) (Catalogue, error) { + stable, seen := filterStableVersions(published) + if len(stable) == 0 { + return Catalogue{}, ErrNoStableVersions + } + ordered, err := SortStableVersions(stable) + if err != nil { + return Catalogue{}, err + } + latest = normalizedLatest(latest, ordered, seen) + visibleValues := visibleCatalogueVersions(ordered, seen, extras...) + visibleValues, err = SortStableVersions(visibleValues) + if err != nil { + return Catalogue{}, err + } + options := make([]VersionOption, 0, len(visibleValues)) + for _, value := range visibleValues { + options = append(options, VersionOption{Version: value, Latest: value == latest}) + } + return Catalogue{Versions: options, Latest: latest, all: seen}, nil +} + +func filterStableVersions(values []string) ([]string, map[string]struct{}) { + stable := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if _, err := ParseStableVersion(value); err != nil { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + stable = append(stable, value) + } + return stable, seen +} + +func normalizedLatest(latest string, ordered []string, published map[string]struct{}) string { + latest = strings.TrimSpace(latest) + if _, err := ParseStableVersion(latest); err != nil { + return ordered[0] + } + if _, exists := published[latest]; !exists { + return ordered[0] + } + return latest +} + +func visibleCatalogueVersions( + ordered []string, + published map[string]struct{}, + extras ...string, +) []string { + visible := make(map[string]struct{}, 50+len(extras)) + for _, value := range ordered { + if len(visible) >= 50 { + break + } + visible[value] = struct{}{} + } + for _, value := range extras { + if _, err := ParseStableVersion(value); err != nil { + continue + } + if _, exists := published[value]; exists { + visible[value] = struct{}{} + } + } + values := make([]string, 0, len(visible)) + for value := range visible { + values = append(values, value) + } + return values +} + +// Has reports whether version is one of the published stable versions. +func (c Catalogue) Has(version string) bool { + if _, err := ParseStableVersion(version); err != nil { + return false + } + _, ok := c.all[version] + return ok +} + +// ClassifyOperation derives the operator-facing action. Empty or malformed +// observed values are treated as unknown and therefore require repair. +func ClassifyOperation(active, current, target string) (Operation, error) { + targetVersion, err := ParseStableVersion(target) + if err != nil { + return "", err + } + activeVersion, activeOK := parseKnownVersion(active) + currentVersion, currentOK := parseKnownVersion(current) + if activeOK && currentOK && active == target && current == target { + return OperationUpToDate, nil + } + if !currentOK { + return OperationRepair, nil + } + baseValue := current + baseVersion := currentVersion + if activeOK { + baseValue = active + baseVersion = activeVersion + } + if target == baseValue { + return OperationRepair, nil + } + compare := targetVersion.Compare(baseVersion) + if compare > 0 { + return OperationUpdate, nil + } + if compare < 0 { + return OperationRollback, nil + } + return OperationRepair, nil +} + +func parseKnownVersion(value string) (*semver.Version, bool) { + if value == "" { + return nil, false + } + parsed, err := ParseStableVersion(value) + return parsed, err == nil +} diff --git a/apps/backend/internal/agent/managedruntime/versions_test.go b/apps/backend/internal/agent/managedruntime/versions_test.go new file mode 100644 index 0000000000..853af82929 --- /dev/null +++ b/apps/backend/internal/agent/managedruntime/versions_test.go @@ -0,0 +1,93 @@ +package managedruntime + +import ( + "fmt" + "slices" + "testing" +) + +func TestParseStableVersion(t *testing.T) { + for _, value := range []string{"1.2.3", "0.0.1", "10.20.30+build.7"} { + if _, err := ParseStableVersion(value); err != nil { + t.Errorf("ParseStableVersion(%q) = %v, want nil", value, err) + } + } + for _, value := range []string{ + "1.2", "1.2.3-beta.1", "v1.2.3", "latest", "@scope/pkg@1.2.3", "1.2.3 ", "01.2.3", + } { + if _, err := ParseStableVersion(value); err == nil { + t.Errorf("ParseStableVersion(%q) = nil error, want rejection", value) + } + } +} + +func TestSortStableVersionsDeduplicatesDescending(t *testing.T) { + got, err := SortStableVersions([]string{"1.2.0", "2.0.0", "1.10.0", "2.0.0", "1.2.0"}) + if err != nil { + t.Fatalf("SortStableVersions: %v", err) + } + want := []string{"2.0.0", "1.10.0", "1.2.0"} + if !slices.Equal(got, want) { + t.Fatalf("sorted versions = %#v, want %#v", got, want) + } +} + +func TestBuildCatalogueFiltersBoundsAndRetainsExtras(t *testing.T) { + published := make([]string, 0, 54) + for minor := 0; minor < 52; minor++ { + published = append(published, fmt.Sprintf("1.0.%d", minor)) + } + published = append(published, "1.0.99-beta.1", "latest", "1.0.1") + + catalogue, err := BuildCatalogue(published, "1.0.50", "1.0.0") + if err != nil { + t.Fatalf("BuildCatalogue: %v", err) + } + if len(catalogue.Versions) != 51 { + t.Fatalf("visible versions = %d, want 51", len(catalogue.Versions)) + } + if catalogue.Versions[0].Version != "1.0.51" || catalogue.Versions[0].Latest { + t.Fatalf("first option = %#v", catalogue.Versions[0]) + } + if catalogue.Latest != "1.0.50" { + t.Fatalf("latest = %q", catalogue.Latest) + } + if !catalogue.Has("1.0.0") || catalogue.Has("1.0.99-beta.1") || catalogue.Has("latest") { + t.Fatal("catalogue target membership is incorrect") + } + latestFound := false + for _, option := range catalogue.Versions { + if option.Version == "1.0.50" { + latestFound = option.Latest + } + } + if !latestFound { + t.Fatal("latest marker missing") + } +} + +func TestClassifyOperation(t *testing.T) { + tests := []struct { + name string + active, current, target string + want Operation + }{ + {name: "update", active: "1.0.1", current: "1.0.1", target: "1.0.2", want: OperationUpdate}, + {name: "rollback", active: "1.0.3", current: "1.0.3", target: "1.0.2", want: OperationRollback}, + {name: "repair unknown", active: "", current: "", target: "1.0.2", want: OperationRepair}, + {name: "repair inactive current", active: "", current: "1.0.2", target: "1.0.2", want: OperationRepair}, + {name: "up to date", active: "1.0.2", current: "1.0.2", target: "1.0.2", want: OperationUpToDate}, + {name: "repair active drift", active: "1.0.3", current: "1.0.2", target: "1.0.3", want: OperationRepair}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ClassifyOperation(test.active, test.current, test.target) + if err != nil { + t.Fatalf("ClassifyOperation: %v", err) + } + if got != test.want { + t.Fatalf("operation = %q, want %q", got, test.want) + } + }) + } +} diff --git a/apps/backend/internal/agent/registry/provider.go b/apps/backend/internal/agent/registry/provider.go index 6c89a7ec89..55cd4e5ae3 100644 --- a/apps/backend/internal/agent/registry/provider.go +++ b/apps/backend/internal/agent/registry/provider.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/runtime/routingerr" + agentruntime "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -90,23 +91,8 @@ func validateMockProviders(reg *Registry, raw string, log *logger.Logger) { // HTTP /routing/retry endpoint can flip a degraded provider back to // healthy without waiting for the next real launch. func registerRealProvidersProber(reg *Registry, log *logger.Logger) { - resolver := func(providerID string) ([]string, map[string]string, bool) { - ag, ok := reg.Get(providerID) - if !ok || !ag.Enabled() { - return nil, nil, false - } - cmd := ag.BuildCommand(agents.CommandOptions{}) - if cmd.IsEmpty() { - return nil, nil, false - } - var env map[string]string - if rt := ag.Runtime(); rt != nil && len(rt.Env) > 0 { - env = make(map[string]string, len(rt.Env)) - for k, v := range rt.Env { - env[k] = v - } - } - return cmd.Args(), env, true + resolver := func(ctx context.Context, providerID string) ([]string, map[string]string, bool) { + return reg.resolveProviderCommand(ctx, providerID) } probe := routingerr.NewACPProbe(resolver, log) for _, id := range RoutableProviderIDs { @@ -122,6 +108,64 @@ func registerRealProvidersProber(reg *Registry, log *logger.Logger) { } } +// resolveProviderCommand builds the host command used by a recovery probe. +// Managed agents must use the same persisted selection as ordinary host +// launches. A selection read failure therefore returns no command instead of +// silently probing the unversioned package. +func (r *Registry) resolveProviderCommand( + ctx context.Context, + providerID string, +) ([]string, map[string]string, bool) { + ag, ok := r.Get(providerID) + if !ok || !ag.Enabled() { + return nil, nil, false + } + managedRuntimeVersion, ok := r.resolveManagedProviderVersion(ctx, providerID, ag) + if !ok { + return nil, nil, false + } + opts := agents.CommandOptions{ + Runtime: agentruntime.RuntimeStandalone, + ManagedRuntimeVersion: managedRuntimeVersion, + } + cmd := ag.BuildCommand(opts) + if cmd.IsEmpty() { + return nil, nil, false + } + var env map[string]string + if rt := ag.Runtime(); rt != nil && len(rt.Env) > 0 { + env = make(map[string]string, len(rt.Env)) + for k, v := range rt.Env { + env[k] = v + } + } + return cmd.Args(), env, true +} + +func (r *Registry) resolveManagedProviderVersion( + ctx context.Context, + providerID string, + ag agents.Agent, +) (string, bool) { + managed, ok := ag.(agents.ManagedNPMRuntimeAgent) + if !ok { + return "", true + } + selectionStore := r.managedRuntimeSelectionReader() + if selectionStore == nil { + return "", false + } + spec := managed.ManagedNPMRuntime() + selection, found, err := selectionStore.Get(ctx, providerID, spec.Package) + if err != nil || !found { + return "", err == nil + } + if selection.Package != spec.Package || selection.Version == "" { + return "", false + } + return selection.Version, true +} + // registerExtraMockProviders parses the supplied comma-separated list // of provider IDs and registers each accepted entry as a MockAgent // alias under that canonical provider ID. diff --git a/apps/backend/internal/agent/registry/provider_test.go b/apps/backend/internal/agent/registry/provider_test.go index 7e3d13de24..f313578b15 100644 --- a/apps/backend/internal/agent/registry/provider_test.go +++ b/apps/backend/internal/agent/registry/provider_test.go @@ -1,12 +1,45 @@ package registry import ( + "context" + "errors" "os" "testing" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/managedruntime" + agentruntime "github.com/kandev/kandev/internal/agentruntime" ) +type recordingManagedProviderAgent struct { + agents.Agent + selection agents.ManagedNPMRuntimeSpec + opts agents.CommandOptions +} + +func (a *recordingManagedProviderAgent) BuildCommand(opts agents.CommandOptions) agents.Command { + a.opts = opts + return a.selection.ACPCommand(opts.ManagedRuntimeVersion) +} + +func (a *recordingManagedProviderAgent) ManagedNPMRuntime() agents.ManagedNPMRuntimeSpec { + return a.selection +} + +type providerSelectionReader struct { + selection managedruntime.Selection + found bool + err error +} + +func (s providerSelectionReader) Get( + context.Context, + string, + string, +) (managedruntime.Selection, bool, error) { + return s.selection, s.found, s.err +} + func TestProvide_MockAgentModes(t *testing.T) { tests := []struct { name string @@ -87,6 +120,52 @@ func TestProvide_MockAgentModes(t *testing.T) { } } +func TestResolveProviderCommandUsesActiveManagedRuntimeVersion(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + agent := &recordingManagedProviderAgent{ + Agent: agents.NewOpenCodeACP(), + selection: agents.NewOpenCodeACP().ManagedNPMRuntime(), + } + if err := reg.Register(agent); err != nil { + t.Fatalf("Register: %v", err) + } + reg.SetManagedRuntimeSelectionStore(providerSelectionReader{ + selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"}, + found: true, + }) + + args, _, ok := reg.resolveProviderCommand(context.Background(), agent.ID()) + if !ok { + t.Fatal("resolveProviderCommand returned !ok") + } + if got := args[3]; got != "opencode-ai@1.18.5" { + t.Fatalf("package arg = %q, want exact active version", got) + } + if agent.opts.Runtime != agentruntime.RuntimeStandalone { + t.Fatalf("runtime = %q, want standalone", agent.opts.Runtime) + } +} + +func TestResolveProviderCommandFailsClosedOnSelectionReadError(t *testing.T) { + log := newTestLogger() + reg := NewRegistry(log) + agent := &recordingManagedProviderAgent{ + Agent: agents.NewOpenCodeACP(), + selection: agents.NewOpenCodeACP().ManagedNPMRuntime(), + } + if err := reg.Register(agent); err != nil { + t.Fatalf("Register: %v", err) + } + wantErr := errors.New("selection unavailable") + reg.SetManagedRuntimeSelectionStore(providerSelectionReader{err: wantErr}) + + args, _, ok := reg.resolveProviderCommand(context.Background(), agent.ID()) + if ok || args != nil { + t.Fatalf("resolveProviderCommand = (%v, ..., %v), want fail closed", args, ok) + } +} + // TestProvide_MockProviders_RegistersExtraAliases ensures that when // KANDEV_MOCK_AGENT=only and KANDEV_MOCK_PROVIDERS lists canonical // routing provider IDs, each accepted ID is registered as an extra diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 2460345d30..bb91e3773d 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/common/logger" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -33,6 +34,23 @@ type Registry struct { loaded bool mu sync.RWMutex logger *logger.Logger + // managedRuntimeSelections supplies exact versions to host-local provider + // recovery probes. It is wired after the settings store is available. + managedRuntimeSelections managedruntime.SelectionReader +} + +// SetManagedRuntimeSelectionStore wires the install-wide exact-version reader +// used by host-local managed-runtime recovery probes. +func (r *Registry) SetManagedRuntimeSelectionStore(store managedruntime.SelectionReader) { + r.mu.Lock() + defer r.mu.Unlock() + r.managedRuntimeSelections = store +} + +func (r *Registry) managedRuntimeSelectionReader() managedruntime.SelectionReader { + r.mu.RLock() + defer r.mu.RUnlock() + return r.managedRuntimeSelections } // NewRegistry creates a new agent registry diff --git a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go new file mode 100644 index 0000000000..e9dbf670c2 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go @@ -0,0 +1,145 @@ +package lifecycle + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/managedruntime" + agentruntime "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/task/models" +) + +type managedRuntimeSelectionStore struct { + selection managedruntime.Selection + found bool + err error +} + +func (s managedRuntimeSelectionStore) Get( + context.Context, + string, + string, +) (managedruntime.Selection, bool, error) { + return s.selection, s.found, s.err +} + +func (s managedRuntimeSelectionStore) Save(context.Context, string, string, string) error { + return nil +} + +func TestBuildAgentCommandUsesExactVersionOnlyOnStandalone(t *testing.T) { + log := newTestLogger() + manager := &Manager{commandBuilder: NewCommandBuilder(), logger: log} + manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{ + selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"}, + found: true, + }) + agent := agents.NewOpenCodeACP() + + tests := []struct { + name string + executorType models.ExecutorType + wantVersion bool + }{ + {name: "standalone", executorType: models.ExecutorTypeLocal, wantVersion: true}, + {name: "worktree", executorType: models.ExecutorTypeWorktree, wantVersion: true}, + {name: "docker", executorType: models.ExecutorTypeLocalDocker}, + {name: "ssh", executorType: models.ExecutorTypeSSH}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmds, err := manager.buildAgentCommandWithContext( + context.Background(), + &LaunchRequest{ExecutorType: string(tt.executorType)}, + nil, + agent, + false, + ) + if err != nil { + t.Fatalf("buildAgentCommandWithContext: %v", err) + } + hasVersion := strings.Contains(cmds.initial, "opencode-ai@1.18.5") + if hasVersion != tt.wantVersion { + t.Fatalf("command = %q, version present = %v, want %v", cmds.initial, hasVersion, tt.wantVersion) + } + }) + } +} + +func TestBuildAgentCommandFailsWhenActiveSelectionCannotBeRead(t *testing.T) { + manager := &Manager{commandBuilder: NewCommandBuilder(), logger: newTestLogger()} + wantErr := errors.New("selection unavailable") + manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{err: wantErr}) + + _, err := manager.buildAgentCommandWithContext( + context.Background(), + &LaunchRequest{ExecutorType: string(models.ExecutorTypeLocal)}, + nil, + agents.NewOpenCodeACP(), + false, + ) + if !errors.Is(err, wantErr) { + t.Fatalf("selection error = %v, want %v", err, wantErr) + } +} + +func TestBuildFreshAgentCommandUsesExactVersionForStandaloneRestart(t *testing.T) { + manager := &Manager{commandBuilder: NewCommandBuilder(), logger: newTestLogger()} + manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{ + selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"}, + found: true, + }) + + commands, err := manager.buildFreshAgentCommand( + context.Background(), + &AgentExecution{RuntimeName: agentruntime.RuntimeStandalone}, + agents.NewOpenCodeACP(), + ) + if err != nil { + t.Fatalf("buildFreshAgentCommand: %v", err) + } + if !strings.Contains(commands.initial, "opencode-ai@1.18.5") { + t.Fatalf("restart command = %q, want exact selected version", commands.initial) + } +} + +func TestBuildFreshAgentCommandFailsWhenStandaloneSelectionCannotBeRead(t *testing.T) { + manager := &Manager{commandBuilder: NewCommandBuilder(), logger: newTestLogger()} + wantErr := errors.New("selection unavailable") + manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{err: wantErr}) + + _, err := manager.buildFreshAgentCommand( + context.Background(), + &AgentExecution{RuntimeName: agentruntime.RuntimeStandalone}, + agents.NewOpenCodeACP(), + ) + if !errors.Is(err, wantErr) { + t.Fatalf("selection error = %v, want %v", err, wantErr) + } +} + +func TestBuildFreshAgentCommandLeavesRemoteCommandUnversioned(t *testing.T) { + manager := &Manager{commandBuilder: NewCommandBuilder(), logger: newTestLogger()} + manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{ + selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"}, + found: true, + }) + + commands, err := manager.buildFreshAgentCommand( + context.Background(), + &AgentExecution{RuntimeName: agentruntime.RuntimeSSH}, + agents.NewOpenCodeACP(), + ) + if err != nil { + t.Fatalf("buildFreshAgentCommand: %v", err) + } + if strings.Contains(commands.initial, "opencode-ai@1.18.5") { + t.Fatalf("remote restart command = %q, must not use host selection", commands.initial) + } + if !strings.Contains(commands.initial, "opencode-ai") { + t.Fatalf("remote restart command = %q, want managed runtime command", commands.initial) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index 22014fa90c..cc882fa033 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/executor" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/runtime/activity" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" @@ -159,6 +160,9 @@ type Manager struct { // managedGoCache provides the opt-in GOCACHE for host-local executions. // System storage wiring installs it after settings persistence is ready. managedGoCache ManagedGoCacheEnvironmentProvider + // managedRuntimeSelections supplies exact versions for host-local managed + // npm runtimes. Remote/container runtimes intentionally do not consult it. + managedRuntimeSelections managedruntime.SelectionReader activityCoordinator *activity.Coordinator activityMu sync.Mutex @@ -179,6 +183,12 @@ func (m *Manager) SetManagedGoCacheEnvironmentProvider(provider ManagedGoCacheEn m.managedGoCache = provider } +// SetManagedRuntimeSelectionStore wires the install-wide exact-version +// resolver used by standalone managed-agent launches. +func (m *Manager) SetManagedRuntimeSelectionStore(store managedruntime.SelectionReader) { + m.managedRuntimeSelections = store +} + // SetActivityCoordinator wires the install-wide host-resource activity gate. // It is optional so embedded and test configurations retain legacy behavior. func (m *Manager) SetActivityCoordinator(coordinator *activity.Coordinator) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index c93a0f404e..0d4712ec28 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -1739,6 +1739,10 @@ func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentEx if err != nil { return agentCommands{}, err } + managedRuntimeVersion, err := m.resolveManagedRuntimeVersion(ctx, execution.RuntimeName, agentConfig) + if err != nil { + return agentCommands{}, err + } opts := agents.CommandOptions{ Model: model, @@ -1750,7 +1754,8 @@ func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentEx // Runtime is "standalone" / "docker" / "sprites" — MockAgent // reads this to pick a bare name (container PATH lookup) vs. // an absolute host path. - Runtime: execution.RuntimeName, + Runtime: execution.RuntimeName, + ManagedRuntimeVersion: managedRuntimeVersion, } args := m.commandBuilder.BuildCommandArgs(agentConfig, opts) continueArgs := m.commandBuilder.BuildContinueCommandArgs(agentConfig, opts) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 49966ec736..daa28e86ba 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agent/runtime/activity" "github.com/kandev/kandev/internal/agent/settings/cliflags" + "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/common/subproc" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/gitconfigenv" @@ -311,11 +312,13 @@ func (m *Manager) resolveProfileLaunchTokens(profileInfo *AgentProfileInfo) (cli return cliFlagTokens, commandPrefixTokens, nil } -// buildAgentCommand builds the agent command strings for the execution. -// Returns both the initial command and the continue command (for one-shot agents like Amp). -// Returns an error when a configured command_prefix cannot be resolved, so a -// configured profile fails closed instead of launching unwrapped. -func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfileInfo, agentConfig agents.Agent, preferNative bool) (agentCommands, error) { +func (m *Manager) buildAgentCommandWithContext( + ctx context.Context, + req *LaunchRequest, + profileInfo *AgentProfileInfo, + agentConfig agents.Agent, + preferNative bool, +) (agentCommands, error) { model := "" autoApprove := false permissionValues := make(map[string]bool) @@ -335,6 +338,11 @@ func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfil model = req.ModelOverride } cliFlagTokens = appendRouteOverrideFlags(cliFlagTokens, req) + runtime := models.ExecutorType(req.ExecutorType).Runtime() + managedRuntimeVersion, err := m.resolveManagedRuntimeVersion(ctx, runtime, agentConfig) + if err != nil { + return agentCommands{}, err + } // Only pass SessionID (for --resume flag) if the agent supports recovery. // Agents with CanRecover=false (e.g. Auggie) use history context injection instead. sessionID := req.ACPSessionID @@ -342,14 +350,15 @@ func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfil sessionID = "" } cmdOpts := agents.CommandOptions{ - Model: model, - SessionID: sessionID, - AutoApprove: autoApprove, - PermissionValues: permissionValues, - CLIFlagTokens: cliFlagTokens, - CommandPrefixTokens: commandPrefixTokens, - Runtime: models.ExecutorType(req.ExecutorType).Runtime(), - PreferNativeBinary: preferNative, + Model: model, + SessionID: sessionID, + AutoApprove: autoApprove, + PermissionValues: permissionValues, + CLIFlagTokens: cliFlagTokens, + CommandPrefixTokens: commandPrefixTokens, + Runtime: runtime, + PreferNativeBinary: preferNative, + ManagedRuntimeVersion: managedRuntimeVersion, } args := m.commandBuilder.BuildCommandArgs(agentConfig, cmdOpts) continueArgs := m.commandBuilder.BuildContinueCommandArgs(agentConfig, cmdOpts) @@ -359,6 +368,29 @@ func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfil return newAgentCommands(args, continueArgs), nil } +func (m *Manager) resolveManagedRuntimeVersion( + ctx context.Context, + runtime agentruntime.Runtime, + agentConfig agents.Agent, +) (string, error) { + if runtime != agentruntime.RuntimeStandalone || m.managedRuntimeSelections == nil { + return "", nil + } + managed, ok := agentConfig.(agents.ManagedNPMRuntimeAgent) + if !ok { + return "", nil + } + spec := managed.ManagedNPMRuntime() + selection, found, err := m.managedRuntimeSelections.Get(ctx, agentConfig.ID(), spec.Package) + if err != nil { + return "", fmt.Errorf("resolve active managed runtime version for %s: %w", agentConfig.ID(), err) + } + if !found || selection.Package != spec.Package { + return "", nil + } + return selection.Version, nil +} + func validateBuiltAgentCommands(args, continueArgs []string) error { if err := cliflags.ValidateCommandArgs(args); err != nil { return fmt.Errorf("validate agent command: %w", err) @@ -1054,7 +1086,7 @@ func (m *Manager) promoteWorkspaceExecution(ctx context.Context, execution *Agen return nil, fmt.Errorf("agent type %q is disabled", agentTypeName) } preferNative := m.preferNativeBinary(agentConfig, execution.RuntimeName, execution.MetadataSnapshot()) - cmds, err := m.buildAgentCommand(req, profileInfo, agentConfig, preferNative) + cmds, err := m.buildAgentCommandWithContext(sharedCtx, req, profileInfo, agentConfig, preferNative) if err != nil { return nil, err } @@ -1230,7 +1262,7 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen // Build the in-memory AgentExecution from the runtime instance. Extracted // to keep launchInternal under the cyclomatic-complexity budget. - execution, err := m.buildExecutionFromInstance(req, execReq, execInstance, rt, profileInfo, agentConfig, prepResult) + execution, err := m.buildExecutionFromInstance(ctx, req, execReq, execInstance, rt, profileInfo, agentConfig, prepResult) if err != nil { // Command resolution failed (e.g. a configured command_prefix could not // be tokenised). The execution isn't built yet, so stop the runtime @@ -1275,6 +1307,7 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen // into an in-memory *AgentExecution ready for Add. Pulled out of launchInternal // to keep the orchestration loop's cyclomatic complexity within the linter budget. func (m *Manager) buildExecutionFromInstance( + ctx context.Context, req *LaunchRequest, execReq *ExecutorCreateRequest, execInstance *ExecutorInstance, @@ -1297,7 +1330,7 @@ func (m *Manager) buildExecutionFromInstance( // promoteWorkspaceExecution's call site rather than re-deriving from the // requested ExecutorType. preferNative := m.preferNativeBinary(agentConfig, execution.RuntimeName, execReq.Metadata) - cmds, err := m.buildAgentCommand(req, profileInfo, agentConfig, preferNative) + cmds, err := m.buildAgentCommandWithContext(ctx, req, profileInfo, agentConfig, preferNative) if err != nil { return nil, err } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go index 58eacc70a6..492de496bb 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -95,7 +95,7 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) { t.Run("CanRecover=true with ACPSessionID includes --resume", func(t *testing.T) { ag := &resumeTestAgent{canRecover: &canRecoverTrue} req := &LaunchRequest{ACPSessionID: "sess-123"} - cmds, err := mgr.buildAgentCommand(req, nil, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), req, nil, ag, false) require.NoError(t, err) require.Contains(t, cmds.initial, "--resume") require.Contains(t, cmds.initial, "sess-123") @@ -104,7 +104,7 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) { t.Run("CanRecover=false with ACPSessionID omits --resume", func(t *testing.T) { ag := &resumeTestAgent{canRecover: &canRecoverFalse} req := &LaunchRequest{ACPSessionID: "sess-123"} - cmds, err := mgr.buildAgentCommand(req, nil, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), req, nil, ag, false) require.NoError(t, err) require.False(t, strings.Contains(cmds.initial, "--resume"), "expected no --resume flag, got: %s", cmds.initial) @@ -115,7 +115,7 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) { t.Run("CanRecover=true with empty ACPSessionID omits --resume", func(t *testing.T) { ag := &resumeTestAgent{canRecover: &canRecoverTrue} req := &LaunchRequest{ACPSessionID: ""} - cmds, err := mgr.buildAgentCommand(req, nil, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), req, nil, ag, false) require.NoError(t, err) require.False(t, strings.Contains(cmds.initial, "--resume"), "expected no --resume flag when ACPSessionID is empty, got: %s", cmds.initial) @@ -124,7 +124,7 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) { t.Run("CanRecover=nil (default true) with ACPSessionID includes --resume", func(t *testing.T) { ag := &resumeTestAgent{canRecover: nil} req := &LaunchRequest{ACPSessionID: "sess-456"} - cmds, err := mgr.buildAgentCommand(req, nil, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), req, nil, ag, false) require.NoError(t, err) require.Contains(t, cmds.initial, "--resume") require.Contains(t, cmds.initial, "sess-456") @@ -167,7 +167,7 @@ func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, nil, tt.agent, true) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, tt.agent, true) require.NoError(t, err) require.Equal(t, tt.want, cmds.initial) }) @@ -205,7 +205,7 @@ func TestBuildAgentCommand_CLIFlagsAppended(t *testing.T) { {Flag: "--add-dir /shared", Enabled: true}, // must be split }, } - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, profile, ag, false) require.NoError(t, err) require.Contains(t, cmds.initial, "--allow-all-tools") @@ -223,7 +223,7 @@ func TestBuildAgentCommand_CLIFlagsAppended(t *testing.T) { {Flag: `--broken "unterminated`, Enabled: true}, }, } - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, profile, ag, false) require.NoError(t, err) // The bad flag is dropped entirely; the launch still produces the // agent's base command so a user with a typo still gets their task @@ -232,7 +232,7 @@ func TestBuildAgentCommand_CLIFlagsAppended(t *testing.T) { }) t.Run("nil profile produces bare command", func(t *testing.T) { - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, nil, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, ag, false) require.NoError(t, err) require.Equal(t, "copilot --acp", cmds.initial) }) @@ -247,7 +247,7 @@ func TestBuildAgentCommand_CommandPrefix(t *testing.T) { ProfileID: "p1", CommandPrefix: "greywall --", } - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, profile, ag, false) require.NoError(t, err) require.Equal(t, "greywall -- copilot --acp", cmds.initial) }) @@ -258,14 +258,14 @@ func TestBuildAgentCommand_CommandPrefix(t *testing.T) { CommandPrefix: "greywall --", CLIFlags: []settingsmodels.CLIFlag{{Flag: "--allow-all-tools", Enabled: true}}, } - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, profile, ag, false) require.NoError(t, err) require.Equal(t, "greywall -- copilot --acp --allow-all-tools", cmds.initial) }) t.Run("empty prefix leaves the command unwrapped", func(t *testing.T) { profile := &AgentProfileInfo{ProfileID: "p3"} - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, profile, ag, false) require.NoError(t, err) require.Equal(t, "copilot --acp", cmds.initial) }) @@ -275,7 +275,7 @@ func TestBuildAgentCommand_CommandPrefix(t *testing.T) { ProfileID: "p4", CommandPrefix: `greywall "unterminated`, } - _, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, ag, false) + _, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, profile, ag, false) require.Error(t, err, "a configured prefix that cannot be resolved must abort the launch") }) @@ -292,7 +292,7 @@ func TestBuildAgentCommand_CommandPrefix(t *testing.T) { CommandPrefix: "greywall --", CLIFlags: []settingsmodels.CLIFlag{{Flag: "--allow-all-tools", Enabled: true}}, } - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, profile, continueAgent, false) + cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, profile, continueAgent, false) require.NoError(t, err) require.Equal(t, "greywall -- amp threads continue --allow-all-tools", cmds.continue_) }) @@ -320,7 +320,7 @@ func TestBuildAgentCommand_RejectsInvalidBuiltArgv(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := mgr.buildAgentCommand(&LaunchRequest{}, nil, tt.agent, false) + _, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, tt.agent, false) require.Error(t, err) }) } diff --git a/apps/backend/internal/agent/runtime/routingerr/acp_probe.go b/apps/backend/internal/agent/runtime/routingerr/acp_probe.go index e3d55a9ad5..9ad61be960 100644 --- a/apps/backend/internal/agent/runtime/routingerr/acp_probe.go +++ b/apps/backend/internal/agent/runtime/routingerr/acp_probe.go @@ -24,7 +24,7 @@ import ( // agent type. Kept as a function seam so this package does not import // the heavyweight registry/agents packages — the wire-up site (see // registry.Provide) injects a concrete resolver at boot. -type CommandResolver func(providerID string) (argv []string, env map[string]string, ok bool) +type CommandResolver func(ctx context.Context, providerID string) (argv []string, env map[string]string, ok bool) // ACPProbe is a cheap availability check that spawns the agent's ACP // binary, performs a single JSON-RPC `initialize` round-trip, and tears @@ -76,7 +76,9 @@ func (p *ACPProbe) Probe(ctx context.Context, in ProbeInput) *Error { Stderr: "acp-probe: no command resolver wired", }) } - argv, env, ok := p.resolver(in.ProviderID) + pCtx, cancel := context.WithTimeout(ctx, p.timeout) + defer cancel() + argv, env, ok := p.resolver(pCtx, in.ProviderID) if !ok || len(argv) == 0 { return Classify(Input{ Phase: PhaseAuthCheck, @@ -85,9 +87,6 @@ func (p *ACPProbe) Probe(ctx context.Context, in ProbeInput) *Error { }) } - pCtx, cancel := context.WithTimeout(ctx, p.timeout) - defer cancel() - cmd := exec.CommandContext(pCtx, argv[0], argv[1:]...) if len(env) > 0 { cmd.Env = mergeEnv(env) diff --git a/apps/backend/internal/agent/runtime/routingerr/acp_probe_test.go b/apps/backend/internal/agent/runtime/routingerr/acp_probe_test.go index d24e2fee06..c5f0ffc842 100644 --- a/apps/backend/internal/agent/runtime/routingerr/acp_probe_test.go +++ b/apps/backend/internal/agent/runtime/routingerr/acp_probe_test.go @@ -27,7 +27,7 @@ func writeShellBin(t *testing.T, dir, name, body string) string { // every provider id. Used to exercise the probe loop without wiring the // full agent registry into a unit test. func staticResolver(argv []string) CommandResolver { - return func(_ string) ([]string, map[string]string, bool) { + return func(_ context.Context, _ string) ([]string, map[string]string, bool) { return argv, nil, true } } @@ -89,7 +89,7 @@ func TestACPProbe_MissingResolver(t *testing.T) { } func TestACPProbe_ResolverReturnsNotOK(t *testing.T) { - probe := NewACPProbe(func(_ string) ([]string, map[string]string, bool) { + probe := NewACPProbe(func(_ context.Context, _ string) ([]string, map[string]string, bool) { return nil, nil, false }, nil) got := probe.Probe(context.Background(), ProbeInput{ProviderID: "unknown"}) diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go index 52bf2a11b7..46a566fb73 100644 --- a/apps/backend/internal/agent/settings/controller/agent_config.go +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -203,11 +203,24 @@ func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, PermissionValues: req.PermissionSettings, }) } else { + managedRuntimeVersion := "" + var err error + if managed, ok := agentConfig.(agents.ManagedNPMRuntimeAgent); ok { + managedRuntimeVersion, err = c.activeRuntimeVersion( + ctx, + agentName, + managed.ManagedNPMRuntime().Package, + ) + if err != nil { + return nil, fmt.Errorf("resolve active managed runtime version: %w", err) + } + } cmd = agentConfig.BuildCommand(agents.CommandOptions{ - Model: req.Model, - PermissionValues: req.PermissionSettings, - CLIFlagTokens: cliFlagTokens, - PreferNativeBinary: previewPrefersNativeBinary(agentConfig), + Model: req.Model, + PermissionValues: req.PermissionSettings, + CLIFlagTokens: cliFlagTokens, + PreferNativeBinary: previewPrefersNativeBinary(agentConfig), + ManagedRuntimeVersion: managedRuntimeVersion, }) if len(cliFlagTokens) > 0 { cmd = cmd.With().Flag(cliFlagTokens...).Build() diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 299e8f620a..1ac9940739 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -93,7 +93,6 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent } modelConfig := c.buildModelConfigFromHostUtility(ag.ID()) - _ = ctx capabilities := dto.AgentCapabilitiesDTO{ SupportsSessionResume: availability.Capabilities.SupportsSessionResume, @@ -133,7 +132,7 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent } loginCommand := buildLoginCommandDTO(ag) - runtimeUpdate := c.buildRuntimeUpdateDTO(ag, availability.Available) + runtimeUpdate := c.buildRuntimeUpdateDTO(ctx, ag, availability.Available) return dto.AvailableAgentDTO{ Name: ag.ID(), @@ -155,7 +154,7 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent } } -func (c *Controller) buildRuntimeUpdateDTO(ag agents.Agent, available bool) *dto.RuntimeUpdateDTO { +func (c *Controller) buildRuntimeUpdateDTO(ctx context.Context, ag agents.Agent, available bool) *dto.RuntimeUpdateDTO { if !available { return nil } @@ -168,6 +167,11 @@ func (c *Controller) buildRuntimeUpdateDTO(ag agents.Agent, available bool) *dto return nil } item := &dto.RuntimeUpdateDTO{Supported: true, Package: spec.Package} + if c.managedRuntimeSelections != nil { + if selection, found, err := c.managedRuntimeSelections.Get(ctx, ag.ID(), spec.Package); err == nil && found { + item.ActiveVersion = selection.Version + } + } if c.runtimeUpdater != nil { if caps, found := c.runtimeUpdater.CurrentCapabilities(ag.ID()); found { item.CurrentVersion = caps.AgentVersion diff --git a/apps/backend/internal/agent/settings/controller/agent_update.go b/apps/backend/internal/agent/settings/controller/agent_update.go index 4c1518d54d..2bd940a6f6 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update.go +++ b/apps/backend/internal/agent/settings/controller/agent_update.go @@ -12,16 +12,21 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/settings/dto" ) var ( - ErrRuntimeUpdateUnsupported = errors.New("agent runtime update unsupported") - ErrRuntimeUpdaterUnavailable = errors.New("agent runtime updater unavailable") - ErrRuntimeUpdatePreviewFailed = errors.New("agent runtime update preview failed") + ErrRuntimeUpdateUnsupported = errors.New("agent runtime update unsupported") + ErrRuntimeUpdaterUnavailable = errors.New("agent runtime updater unavailable") + ErrRuntimeUpdatePreviewFailed = errors.New("agent runtime update preview failed") + ErrRuntimeUpdateTargetRequired = errors.New("managed runtime target version is required") + ErrRuntimeUpdateTargetInvalid = errors.New("managed runtime target version is invalid") + ErrRuntimeUpdateTargetMissing = errors.New("managed runtime target version is not published") ) // PreviewAgentUpdate resolves the trusted built-in update recipe without @@ -29,6 +34,7 @@ var ( func (c *Controller) PreviewAgentUpdate( ctx context.Context, name string, + targetVersions ...string, ) (*dto.AgentUpdatePreviewDTO, error) { if c.runtimeUpdater == nil { return nil, ErrRuntimeUpdaterUnavailable @@ -50,21 +56,98 @@ func (c *Controller) PreviewAgentUpdate( if caps, found := c.runtimeUpdater.CurrentCapabilities(name); found { current = caps.AgentVersion } - target, err := c.runtimeUpdater.ResolveTarget(ctx, spec.Package) + active, err := c.activeRuntimeVersion(ctx, name, spec.Package) if err != nil { return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err) } - command := spec.CacheUpdateCommand().Args() + catalogue, exactCatalogue, err := c.resolveRuntimeCatalogue(ctx, spec.Package, active, current) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err) + } + target := catalogue.Latest + if len(targetVersions) > 0 && strings.TrimSpace(targetVersions[0]) != "" { + target = strings.TrimSpace(targetVersions[0]) + if _, err := managedruntime.ParseStableVersion(target); err != nil { + return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) + } + if exactCatalogue && !catalogue.Has(target) { + return nil, fmt.Errorf("%w: %s", ErrRuntimeUpdateTargetMissing, target) + } + } + operation, err := managedruntime.ClassifyOperation(active, current, target) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) + } + command := spec.CacheUpdateCommand(target).Args() + if !exactCatalogue && (len(targetVersions) == 0 || strings.TrimSpace(targetVersions[0]) == "") { + // Keep the compatibility preview for embedders that provide only the + // legacy latest-version seam. Production uses the catalogue resolver and + // always previews an exact package@version command. + command = spec.CacheUpdateCommand().Args() + } return &dto.AgentUpdatePreviewDTO{ - AgentName: name, - Package: spec.Package, - CurrentVersion: current, - TargetVersion: target, - Command: command, - CommandString: buildCommandString(command), + AgentName: name, + Package: spec.Package, + CurrentVersion: current, + ActiveVersion: active, + TargetVersion: target, + Operation: string(operation), + AvailableVersions: runtimeVersionDTOs(catalogue), + Command: command, + CommandString: buildCommandString(command), }, nil } +func (c *Controller) resolveRuntimeCatalogue( + ctx context.Context, + packageName string, + extras ...string, +) (managedruntime.Catalogue, bool, error) { + if resolver, ok := c.runtimeUpdater.(RuntimeVersionResolver); ok { + metadata, err := resolver.ResolveVersions(ctx, packageName) + if err != nil { + return managedruntime.Catalogue{}, true, err + } + catalogue, err := managedruntime.BuildCatalogue(metadata.Versions, metadata.Latest, extras...) + return catalogue, true, err + } + target, err := c.runtimeUpdater.ResolveTarget(ctx, packageName) + if err != nil { + return managedruntime.Catalogue{}, false, err + } + catalogue, err := managedruntime.BuildCatalogue([]string{target}, target) + return catalogue, false, err +} + +func runtimeVersionDTOs(catalogue managedruntime.Catalogue) []dto.AgentUpdateVersionDTO { + versions := make([]dto.AgentUpdateVersionDTO, 0, len(catalogue.Versions)) + for _, version := range catalogue.Versions { + versions = append(versions, dto.AgentUpdateVersionDTO{ + Version: version.Version, + Latest: version.Latest, + }) + } + return versions +} + +func (c *Controller) activeRuntimeVersion( + ctx context.Context, + agentName string, + packageName string, +) (string, error) { + if c.managedRuntimeSelections == nil { + return "", nil + } + selection, found, err := c.managedRuntimeSelections.Get(ctx, agentName, packageName) + if err != nil { + return "", fmt.Errorf("read active runtime version: %w", err) + } + if !found { + return "", nil + } + return selection.Version, nil +} + // RuntimeUpdater is the external-process and host-probe boundary used by // managed runtime jobs. Implementations must execute commands as direct argv. type RuntimeUpdater interface { @@ -79,6 +162,30 @@ type RuntimeUpdater interface { ) (hostutility.AgentCapabilities, error) } +// RuntimeVersionMetadata is the trusted npm catalogue used to authorize an +// exact target after the browser preview has been shown. +type RuntimeVersionMetadata struct { + Versions []string + Latest string +} + +// RuntimeVersionResolver obtains package metadata without executing a package. +type RuntimeVersionResolver interface { + ResolveVersions(context.Context, string) (RuntimeVersionMetadata, error) +} + +// RuntimeCandidateUpdater separates validation probes from live capability +// publication. The update job persists the selection between these calls. +type RuntimeCandidateUpdater interface { + Probe(context.Context, string, agents.Command) (hostutility.AgentCapabilities, error) + PublishCapabilities(string, hostutility.AgentCapabilities) +} + +// ExactRuntimeCacheInvalidator removes only the version-specific npm tree. +type ExactRuntimeCacheInvalidator interface { + InvalidateExecutionCacheVersion(context.Context, string, string) error +} + type hostRuntimeUpdater struct { host *hostutility.Manager executor directCommandExecutor @@ -123,7 +230,37 @@ func (u *hostRuntimeUpdater) ResolveTarget(ctx context.Context, packageName stri if strings.TrimSpace(target) == "" { return "", errors.New("npm target version is empty") } - return strings.TrimSpace(target), nil + target = strings.TrimSpace(target) + if _, err := managedruntime.ParseStableVersion(target); err != nil { + return "", fmt.Errorf("npm target version is not stable: %w", err) + } + return target, nil +} + +func (u *hostRuntimeUpdater) ResolveVersions( + ctx context.Context, + packageName string, +) (RuntimeVersionMetadata, error) { + command := agents.NewCommand("npm", "view", packageName, "versions", "dist-tags", "--json") + output, err := u.executor.Output(ctx, command) + if err != nil { + return RuntimeVersionMetadata{}, err + } + var metadata struct { + Versions []string `json:"versions"` + DistTags map[string]string `json:"dist-tags"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &metadata); err != nil { + return RuntimeVersionMetadata{}, fmt.Errorf("parse npm runtime metadata: %w", err) + } + latest := "" + if metadata.DistTags != nil { + latest = strings.TrimSpace(metadata.DistTags["latest"]) + } + if latest == "" { + return RuntimeVersionMetadata{}, errors.New("npm latest runtime version is empty") + } + return RuntimeVersionMetadata{Versions: metadata.Versions, Latest: latest}, nil } func runDirectCommandOutput(ctx context.Context, command agents.Command) (string, error) { @@ -148,30 +285,29 @@ func (u *hostRuntimeUpdater) RunUpdate( return u.executor.Stream(ctx, command, onChunk) } -func (u *hostRuntimeUpdater) InvalidateExecutionCache(ctx context.Context, packageName string) error { - packageName = strings.TrimSpace(packageName) - if packageName == "" { - return errors.New("managed runtime package is empty") - } +func (u *hostRuntimeUpdater) npxCacheRoot(ctx context.Context) (string, error) { output, err := u.executor.Output(ctx, agents.NewCommand("npm", "config", "get", "cache")) if err != nil { - return fmt.Errorf("resolve npm cache root: %w", err) + return "", fmt.Errorf("resolve npm cache root: %w", err) } cacheRoot := strings.TrimSpace(output) if !filepath.IsAbs(cacheRoot) { - return fmt.Errorf("npm cache root is not absolute: %q", cacheRoot) + return "", fmt.Errorf("npm cache root is not absolute: %q", cacheRoot) } cacheRoot = filepath.Clean(cacheRoot) if cacheRoot == string(filepath.Separator) { - return errors.New("refusing to invalidate execution cache under filesystem root") + return "", errors.New("refusing to invalidate execution cache under filesystem root") } npxRoot := filepath.Join(cacheRoot, "_npx") if info, statErr := os.Lstat(npxRoot); statErr == nil && info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("refusing to invalidate execution cache through symlink: %s", npxRoot) + return "", fmt.Errorf("refusing to invalidate execution cache through symlink: %s", npxRoot) } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { - return fmt.Errorf("inspect npm execution cache: %w", statErr) + return "", fmt.Errorf("inspect npm execution cache: %w", statErr) } - key := (agents.ManagedNPMRuntimeSpec{Package: packageName}).ExecutionCacheKey() + return npxRoot, nil +} + +func removeNpmExecutionCacheKey(npxRoot, key string) error { target := filepath.Join(npxRoot, key) rel, err := filepath.Rel(npxRoot, target) if err != nil || rel != key { @@ -183,6 +319,40 @@ func (u *hostRuntimeUpdater) InvalidateExecutionCache(ctx context.Context, packa return nil } +func (u *hostRuntimeUpdater) InvalidateExecutionCache(ctx context.Context, packageName string) error { + packageName = strings.TrimSpace(packageName) + if packageName == "" { + return errors.New("managed runtime package is empty") + } + npxRoot, err := u.npxCacheRoot(ctx) + if err != nil { + return err + } + key := (agents.ManagedNPMRuntimeSpec{Package: packageName}).ExecutionCacheKey() + return removeNpmExecutionCacheKey(npxRoot, key) +} + +func (u *hostRuntimeUpdater) InvalidateExecutionCacheVersion( + ctx context.Context, + packageName string, + version string, +) error { + packageName = strings.TrimSpace(packageName) + version = strings.TrimSpace(version) + if packageName == "" || version == "" { + return errors.New("managed runtime package and version are required") + } + if _, err := managedruntime.ParseStableVersion(version); err != nil { + return err + } + npxRoot, err := u.npxCacheRoot(ctx) + if err != nil { + return err + } + spec := agents.ManagedNPMRuntimeSpec{Package: packageName} + return removeNpmExecutionCacheKey(npxRoot, spec.ExecutionCacheKey(version)) +} + func (u *hostRuntimeUpdater) Refresh( ctx context.Context, agentName string, @@ -191,6 +361,21 @@ func (u *hostRuntimeUpdater) Refresh( return u.host.RefreshWithCommand(ctx, agentName, command) } +func (u *hostRuntimeUpdater) Probe( + ctx context.Context, + agentName string, + command agents.Command, +) (hostutility.AgentCapabilities, error) { + return u.host.ProbeWithCommand(ctx, agentName, command) +} + +func (u *hostRuntimeUpdater) PublishCapabilities( + agentName string, + caps hostutility.AgentCapabilities, +) { + u.host.PublishCapabilities(agentName, caps) +} + func runDirectCommand(ctx context.Context, command agents.Command, onChunk func(string)) error { argv := command.Args() if len(argv) == 0 { @@ -246,10 +431,17 @@ func scanCommandOutput( } // EnqueueAgentUpdate starts or reuses an update for a built-in managed agent. -func (c *Controller) EnqueueAgentUpdate(name string) (*dto.AgentUpdateJobDTO, error) { +func (c *Controller) EnqueueAgentUpdate( + ctx context.Context, + name string, + targetVersion string, +) (*dto.AgentUpdateJobDTO, error) { if c.updateJobStore == nil || c.runtimeUpdater == nil { return nil, ErrRuntimeUpdaterUnavailable } + if active, found := c.updateJobStore.GetActive(name); found { + return active, nil + } ag, ok := c.agentRegistry.Get(name) if !ok { return nil, ErrAgentNotFound @@ -262,7 +454,17 @@ func (c *Controller) EnqueueAgentUpdate(name string) (*dto.AgentUpdateJobDTO, er if strings.TrimSpace(spec.Package) == "" { return nil, ErrRuntimeUpdateUnsupported } - job, err := c.updateJobStore.Enqueue(name, spec) + targetVersion = strings.TrimSpace(targetVersion) + if targetVersion == "" { + return nil, ErrRuntimeUpdateTargetRequired + } + if err := c.validateAgentUpdateTarget(ctx, spec, targetVersion); err != nil { + return nil, err + } + if noOp := c.alreadyActiveHealthyUpdate(ctx, name, spec, targetVersion); noOp != nil { + return noOp, nil + } + job, err := c.updateJobStore.Enqueue(name, spec, targetVersion) if err != nil { return nil, err } @@ -273,6 +475,65 @@ func (c *Controller) EnqueueAgentUpdate(name string) (*dto.AgentUpdateJobDTO, er return &snapshot, nil } +func (c *Controller) validateAgentUpdateTarget( + ctx context.Context, + spec agents.ManagedNPMRuntimeSpec, + targetVersion string, +) error { + if _, err := managedruntime.ParseStableVersion(targetVersion); err != nil { + return fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) + } + resolver, ok := c.runtimeUpdater.(RuntimeVersionResolver) + if !ok { + return nil + } + metadata, err := resolver.ResolveVersions(ctx, spec.Package) + if err != nil { + return fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err) + } + catalogue, err := managedruntime.BuildCatalogue(metadata.Versions, metadata.Latest) + if err != nil { + return fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err) + } + if !catalogue.Has(targetVersion) { + return fmt.Errorf("%w: %s", ErrRuntimeUpdateTargetMissing, targetVersion) + } + return nil +} + +// alreadyActiveHealthyUpdate returns a terminal response without retaining a +// job when the requested version is already the exact active healthy runtime. +func (c *Controller) alreadyActiveHealthyUpdate( + ctx context.Context, + agentName string, + spec agents.ManagedNPMRuntimeSpec, + targetVersion string, +) *dto.AgentUpdateJobDTO { + if c.managedRuntimeSelections == nil { + return nil + } + caps, found := c.runtimeUpdater.CurrentCapabilities(agentName) + if !found || caps.Status != hostutility.StatusOK || caps.AgentVersion != targetVersion { + return nil + } + selection, found, err := c.managedRuntimeSelections.Get(ctx, agentName, spec.Package) + if err != nil || !found || selection.Package != spec.Package || selection.Version != targetVersion { + return nil + } + now := time.Now().UTC() + return &dto.AgentUpdateJobDTO{ + AgentName: agentName, + Status: dto.AgentUpdateJobStatusSucceeded, + Operation: string(managedruntime.OperationUpToDate), + CurrentVersion: caps.AgentVersion, + ActiveVersion: selection.Version, + TargetVersion: targetVersion, + Output: "Runtime already up to date.\n", + StartedAt: now, + FinishedAt: &now, + } +} + func (c *Controller) ListAgentUpdateJobs() []dto.AgentUpdateJobDTO { if c.updateJobStore == nil { return nil diff --git a/apps/backend/internal/agent/settings/controller/agent_update_job.go b/apps/backend/internal/agent/settings/controller/agent_update_job.go index 8c746bd603..7d7299d932 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_job.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_job.go @@ -5,12 +5,14 @@ import ( "context" "errors" "fmt" + "strings" "sync" "time" "github.com/google/uuid" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/settings/dto" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -20,7 +22,9 @@ type AgentUpdateJob struct { ID string AgentName string Status dto.AgentUpdateJobStatus + Operation managedruntime.Operation CurrentVersion string + ActiveVersion string TargetVersion string Output *ringBuffer StartedAt time.Time @@ -30,15 +34,16 @@ type AgentUpdateJob struct { } type AgentUpdateJobStore struct { - mu sync.Mutex - jobs map[string]*AgentUpdateJob - activeByAgt map[string]*AgentUpdateJob - semaphore chan struct{} - hub JobBroadcaster - log *zap.Logger - updater RuntimeUpdater - maintenance *maintenanceCoordinator - onRefresh func() + mu sync.Mutex + jobs map[string]*AgentUpdateJob + activeByAgt map[string]*AgentUpdateJob + semaphore chan struct{} + hub JobBroadcaster + log *zap.Logger + updater RuntimeUpdater + maintenance *maintenanceCoordinator + onRefresh func() + selectionStore managedruntime.SelectionStore } func NewAgentUpdateJobStore( @@ -47,25 +52,32 @@ func NewAgentUpdateJobStore( updater RuntimeUpdater, maintenance *maintenanceCoordinator, onRefresh func(), + selectionStores ...managedruntime.SelectionStore, ) *AgentUpdateJobStore { if maintenance == nil { maintenance = newMaintenanceCoordinator() } + var selectionStore managedruntime.SelectionStore + if len(selectionStores) > 0 { + selectionStore = selectionStores[0] + } return &AgentUpdateJobStore{ - jobs: make(map[string]*AgentUpdateJob), - activeByAgt: make(map[string]*AgentUpdateJob), - semaphore: make(chan struct{}, jobMaxParallel), - hub: hub, - log: log, - updater: updater, - maintenance: maintenance, - onRefresh: onRefresh, + jobs: make(map[string]*AgentUpdateJob), + activeByAgt: make(map[string]*AgentUpdateJob), + semaphore: make(chan struct{}, jobMaxParallel), + hub: hub, + log: log, + updater: updater, + maintenance: maintenance, + onRefresh: onRefresh, + selectionStore: selectionStore, } } func (s *AgentUpdateJobStore) Enqueue( agentName string, spec agents.ManagedNPMRuntimeSpec, + targetVersions ...string, ) (*AgentUpdateJob, error) { s.mu.Lock() if existing, ok := s.activeByAgt[agentName]; ok { @@ -79,6 +91,11 @@ func (s *AgentUpdateJobStore) Enqueue( Output: newRingBuffer(jobOutputRingSize), StartedAt: time.Now().UTC(), } + requestedTarget := "" + if len(targetVersions) > 0 { + requestedTarget = strings.TrimSpace(targetVersions[0]) + job.TargetVersion = requestedTarget + } ref, claimed, err := s.maintenance.claim(agentName, MaintenanceKindUpdate, job.ID) if err != nil { s.mu.Unlock() @@ -94,7 +111,7 @@ func (s *AgentUpdateJobStore) Enqueue( s.mu.Unlock() s.broadcast(ws.ActionAgentUpdateStarted, job.snapshot()) - go s.run(job, spec, ref) + go s.run(job, spec, requestedTarget, ref) return job, nil } @@ -109,6 +126,18 @@ func (s *AgentUpdateJobStore) Get(jobID string) (*dto.AgentUpdateJobDTO, bool) { return &snapshot, true } +// GetActive returns the current queued or running update for an agent. +func (s *AgentUpdateJobStore) GetActive(agentName string) (*dto.AgentUpdateJobDTO, bool) { + s.mu.Lock() + defer s.mu.Unlock() + job, ok := s.activeByAgt[agentName] + if !ok { + return nil, false + } + snapshot := job.snapshot() + return &snapshot, true +} + func (s *AgentUpdateJobStore) ListAll() []dto.AgentUpdateJobDTO { s.mu.Lock() defer s.mu.Unlock() @@ -122,6 +151,7 @@ func (s *AgentUpdateJobStore) ListAll() []dto.AgentUpdateJobDTO { func (s *AgentUpdateJobStore) run( job *AgentUpdateJob, spec agents.ManagedNPMRuntimeSpec, + requestedTarget string, ref MaintenanceJobRef, ) { s.semaphore <- struct{}{} @@ -131,7 +161,7 @@ func (s *AgentUpdateJobStore) run( defer cancel() s.setStatus(job, dto.AgentUpdateJobStatusResolving) - target, err := s.updater.ResolveTarget(ctx, spec.Package) + target, exactTarget, err := s.resolveTarget(ctx, spec.Package, requestedTarget) if err != nil { s.finishFailed(job, ctx, fmt.Errorf("resolve target version: %w", err), ref) return @@ -143,17 +173,44 @@ func (s *AgentUpdateJobStore) run( job.CurrentVersion = currentVersion s.mu.Unlock() } + activeVersion := "" + if s.selectionStore != nil { + selection, found, selectionErr := s.selectionStore.Get(ctx, job.AgentName, spec.Package) + if selectionErr != nil { + s.finishFailed(job, ctx, fmt.Errorf("read active runtime version: %w", selectionErr), ref) + return + } + if found { + activeVersion = selection.Version + } + } + operation, err := managedruntime.ClassifyOperation(activeVersion, currentVersion, target) + if err != nil { + s.finishFailed(job, ctx, fmt.Errorf("classify runtime operation: %w", err), ref) + return + } s.mu.Lock() job.TargetVersion = target + job.Operation = operation + job.ActiveVersion = activeVersion s.mu.Unlock() - if currentVersion != "" && currentVersion == target { + if operation == managedruntime.OperationUpToDate || + (s.selectionStore == nil && currentVersion != "" && currentVersion == target) { s.finishAlreadyUpToDate(job, ref) return } + if candidate, ok := s.updater.(RuntimeCandidateUpdater); ok && s.selectionStore != nil { + s.runExactCandidate(ctx, job, spec, target, candidate, ref) + return + } s.setStatus(job, dto.AgentUpdateJobStatusUpdating) flusher := newUpdateOutputFlusher(s, job) - err = s.updater.RunUpdate(ctx, spec.CacheUpdateCommand(), flusher.append) + prepareCommand := spec.CacheUpdateCommand() + if exactTarget { + prepareCommand = spec.CacheUpdateCommand(target) + } + err = s.updater.RunUpdate(ctx, prepareCommand, flusher.append) flusher.flush() if err != nil { flusher.append("managed runtime cache appears stale; repairing execution cache\n") @@ -164,7 +221,7 @@ func (s *AgentUpdateJobStore) run( } flusher.append("retrying managed runtime update\n") flusher.flush() - err = s.updater.RunUpdate(ctx, spec.CacheUpdateCommand(), flusher.append) + err = s.updater.RunUpdate(ctx, prepareCommand, flusher.append) flusher.flush() if err != nil { s.finishFailed(job, ctx, fmt.Errorf("update runtime after cache repair: %w", err), ref) @@ -173,10 +230,113 @@ func (s *AgentUpdateJobStore) run( } s.setStatus(job, dto.AgentUpdateJobStatusRefreshing) - caps, refreshErr := s.updater.Refresh(ctx, job.AgentName, spec.CachedACPCommand()) + refreshCommand := spec.CachedACPCommand() + if exactTarget { + refreshCommand = spec.ACPCommand(target) + } + caps, refreshErr := s.updater.Refresh(ctx, job.AgentName, refreshCommand) s.finishRefresh(job, ctx, caps, refreshErr, ref) } +func (s *AgentUpdateJobStore) resolveTarget( + ctx context.Context, + packageName string, + requestedTarget string, +) (string, bool, error) { + if resolver, ok := s.updater.(RuntimeVersionResolver); ok { + return s.resolveTargetFromMetadata(ctx, resolver, packageName, requestedTarget) + } + if requestedTarget != "" { + if _, err := managedruntime.ParseStableVersion(requestedTarget); err != nil { + return "", true, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) + } + return requestedTarget, true, nil + } + target, err := s.updater.ResolveTarget(ctx, packageName) + return target, false, err +} + +func (s *AgentUpdateJobStore) resolveTargetFromMetadata( + ctx context.Context, + resolver RuntimeVersionResolver, + packageName string, + requestedTarget string, +) (string, bool, error) { + requestedTarget = strings.TrimSpace(requestedTarget) + if requestedTarget != "" { + // EnqueueAgentUpdate already checked this exact target against the + // package catalogue. The worker only needs to preserve that target, + // avoiding a second registry round-trip after the job is queued. + if _, err := managedruntime.ParseStableVersion(requestedTarget); err != nil { + return "", true, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) + } + return requestedTarget, true, nil + } + metadata, err := resolver.ResolveVersions(ctx, packageName) + if err != nil { + return "", true, err + } + catalogue, err := managedruntime.BuildCatalogue(metadata.Versions, metadata.Latest) + if err != nil { + return "", true, err + } + return catalogue.Latest, true, nil +} + +func (s *AgentUpdateJobStore) runExactCandidate( + ctx context.Context, + job *AgentUpdateJob, + spec agents.ManagedNPMRuntimeSpec, + target string, + candidate RuntimeCandidateUpdater, + ref MaintenanceJobRef, +) { + s.setStatus(job, dto.AgentUpdateJobStatusUpdating) + flusher := newUpdateOutputFlusher(s, job) + prepareCommand := spec.CacheUpdateCommand(target) + err := s.updater.RunUpdate(ctx, prepareCommand, flusher.append) + flusher.flush() + if err != nil { + flusher.append("managed runtime cache appears stale; repairing exact execution cache\n") + flusher.flush() + var repairErr error + if invalidator, ok := s.updater.(ExactRuntimeCacheInvalidator); ok { + repairErr = invalidator.InvalidateExecutionCacheVersion(ctx, spec.Package, target) + } else { + repairErr = s.updater.InvalidateExecutionCache(ctx, spec.Package) + } + if repairErr != nil { + s.finishFailed(job, ctx, fmt.Errorf("repair runtime execution cache: %w", repairErr), ref) + return + } + flusher.append("retrying managed runtime update\n") + flusher.flush() + err = s.updater.RunUpdate(ctx, prepareCommand, flusher.append) + flusher.flush() + if err != nil { + s.finishFailed(job, ctx, fmt.Errorf("update runtime after cache repair: %w", err), ref) + return + } + } + + s.setStatus(job, dto.AgentUpdateJobStatusRefreshing) + caps, probeErr := candidate.Probe(ctx, job.AgentName, spec.ACPCommand(target)) + if probeErr != nil { + s.finishFailed(job, ctx, fmt.Errorf("probe runtime candidate: %w", probeErr), ref) + return + } + if caps.Status != hostutility.StatusOK { + s.finishFailed(job, ctx, errors.New(capabilityRefreshError(caps)), ref) + return + } + if err := s.selectionStore.Save(ctx, job.AgentName, spec.Package, target); err != nil { + s.finishFailed(job, ctx, fmt.Errorf("persist active runtime version: %w", err), ref) + return + } + candidate.PublishCapabilities(job.AgentName, caps) + s.finishActivated(job, target, ref) +} + func (s *AgentUpdateJobStore) setStatus(job *AgentUpdateJob, status dto.AgentUpdateJobStatus) { s.mu.Lock() job.Status = status @@ -219,6 +379,25 @@ func (s *AgentUpdateJobStore) finishAlreadyUpToDate(job *AgentUpdateJob, ref Mai s.scheduleEviction(job.ID) } +func (s *AgentUpdateJobStore) finishActivated( + job *AgentUpdateJob, + target string, + ref MaintenanceJobRef, +) { + s.mu.Lock() + job.Status = dto.AgentUpdateJobStatusSucceeded + job.ActiveVersion = target + s.maintenance.release(job.AgentName, ref) + s.finishLocked(job) + snapshot := job.snapshot() + s.mu.Unlock() + if s.onRefresh != nil { + s.onRefresh() + } + s.broadcast(ws.ActionAgentUpdateFinished, snapshot) + s.scheduleEviction(job.ID) +} + func (s *AgentUpdateJobStore) finishRefresh( job *AgentUpdateJob, ctx context.Context, @@ -300,7 +479,9 @@ func (j *AgentUpdateJob) snapshot() dto.AgentUpdateJobDTO { JobID: j.ID, AgentName: j.AgentName, Status: j.Status, + Operation: string(j.Operation), CurrentVersion: j.CurrentVersion, + ActiveVersion: j.ActiveVersion, TargetVersion: j.TargetVersion, Output: j.Output.String(), Error: j.Error, diff --git a/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go b/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go new file mode 100644 index 0000000000..ac9cf59f9d --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go @@ -0,0 +1,315 @@ +package controller + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/managedruntime" + "github.com/kandev/kandev/internal/agent/settings/dto" + "go.uber.org/zap" +) + +type recoverySelectionStore struct { + mu sync.Mutex + values map[string]managedruntime.Selection + err error + saveErr error + events *[]string +} + +func newRecoverySelectionStore() *recoverySelectionStore { + return &recoverySelectionStore{values: make(map[string]managedruntime.Selection)} +} + +func (s *recoverySelectionStore) Get( + _ context.Context, + agentName string, + packageName string, +) (managedruntime.Selection, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return managedruntime.Selection{}, false, s.err + } + selection, ok := s.values[agentName+"\x00"+packageName] + return selection, ok, nil +} + +func (s *recoverySelectionStore) Save( + _ context.Context, + agentName string, + packageName string, + version string, +) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.saveErr != nil { + return s.saveErr + } + s.values[agentName+"\x00"+packageName] = managedruntime.Selection{ + Package: packageName, + Version: version, + } + if s.events != nil { + *s.events = append(*s.events, "persist") + } + return nil +} + +type recoveryRuntimeUpdater struct { + mu sync.Mutex + metadata RuntimeVersionMetadata + metadataCalls int + current hostutility.AgentCapabilities + currentFound bool + probeCaps hostutility.AgentCapabilities + probeErr error + runErrs []error + runCalls int + prepare []string + probe []string + invalidate []string + events *[]string +} + +func (u *recoveryRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentCapabilities, bool) { + u.mu.Lock() + defer u.mu.Unlock() + return u.current, u.currentFound +} + +func (u *recoveryRuntimeUpdater) ResolveTarget(context.Context, string) (string, error) { + return u.metadata.Latest, nil +} + +func (u *recoveryRuntimeUpdater) ResolveVersions(context.Context, string) (RuntimeVersionMetadata, error) { + u.mu.Lock() + defer u.mu.Unlock() + u.metadataCalls++ + return u.metadata, nil +} + +func (u *recoveryRuntimeUpdater) RunUpdate( + _ context.Context, + command agents.Command, + onChunk func(string), +) error { + u.mu.Lock() + u.runCalls++ + u.prepare = append(u.prepare, strings.Join(command.Args(), " ")) + index := u.runCalls - 1 + var err error + if index < len(u.runErrs) { + err = u.runErrs[index] + } + u.mu.Unlock() + onChunk("prepared\n") + return err +} + +func (u *recoveryRuntimeUpdater) InvalidateExecutionCache(context.Context, string) error { + return nil +} + +func (u *recoveryRuntimeUpdater) InvalidateExecutionCacheVersion( + _ context.Context, + packageName string, + version string, +) error { + u.mu.Lock() + defer u.mu.Unlock() + u.invalidate = append(u.invalidate, packageName+"@"+version) + return nil +} + +func (u *recoveryRuntimeUpdater) Refresh( + context.Context, + string, + agents.Command, +) (hostutility.AgentCapabilities, error) { + return hostutility.AgentCapabilities{}, errors.New("legacy refresh must not be used") +} + +func (u *recoveryRuntimeUpdater) Probe( + _ context.Context, + _ string, + command agents.Command, +) (hostutility.AgentCapabilities, error) { + u.mu.Lock() + u.probe = append(u.probe, strings.Join(command.Args(), " ")) + if u.events != nil { + *u.events = append(*u.events, "probe") + } + caps, err := u.probeCaps, u.probeErr + u.mu.Unlock() + return caps, err +} + +func (u *recoveryRuntimeUpdater) PublishCapabilities( + _ string, + _ hostutility.AgentCapabilities, +) { + u.mu.Lock() + defer u.mu.Unlock() + if u.events != nil { + *u.events = append(*u.events, "publish") + } +} + +func newRecoveryStore( + updater RuntimeUpdater, + selectionStore managedruntime.SelectionStore, +) (*AgentUpdateJobStore, <-chan dto.AgentUpdateJobDTO) { + hub := newUpdateTerminalBroadcaster() + return NewAgentUpdateJobStore( + hub, + zap.NewNop(), + updater, + newMaintenanceCoordinator(), + nil, + selectionStore, + ), hub.completed +} + +func TestAgentUpdateExactCandidatePersistsBeforePublishing(t *testing.T) { + selectionStore := newRecoverySelectionStore() + selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{ + Package: "@example/managed-acp", + Version: "1.0.3", + } + events := make([]string, 0, 4) + selectionStore.events = &events + updater := &recoveryRuntimeUpdater{ + metadata: RuntimeVersionMetadata{ + Versions: []string{"1.0.1", "1.0.2", "1.0.3"}, + Latest: "1.0.3", + }, + current: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.3"}, + currentFound: true, + probeCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.2"}, + } + updater.events = &events + store, completed := newRecoveryStore(updater, selectionStore) + + job, err := store.Enqueue("managed-acp", managedRuntimeSpec(), "1.0.2") + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + if final.Operation != string(managedruntime.OperationRollback) { + t.Fatalf("operation = %q, want rollback", final.Operation) + } + if final.ActiveVersion != "1.0.2" { + t.Fatalf("active version = %q, want 1.0.2", final.ActiveVersion) + } + if len(updater.prepare) != 1 || !strings.Contains(updater.prepare[0], "--package=@example/managed-acp@1.0.2") { + t.Fatalf("prepare commands = %#v", updater.prepare) + } + if len(updater.probe) != 1 || !strings.Contains(updater.probe[0], "@example/managed-acp@1.0.2") { + t.Fatalf("probe commands = %#v", updater.probe) + } + if len(updater.invalidate) != 0 { + t.Fatalf("invalidate calls = %#v, want none", updater.invalidate) + } + if len(events) != 3 || events[0] != "probe" || events[1] != "persist" || events[2] != "publish" { + t.Fatalf("activation order = %#v", events) + } +} + +func TestAgentUpdateExactTargetSkipsMetadataRefetch(t *testing.T) { + selectionStore := newRecoverySelectionStore() + updater := &recoveryRuntimeUpdater{ + metadata: RuntimeVersionMetadata{ + Versions: []string{"1.0.1", "1.0.2"}, + Latest: "1.0.2", + }, + current: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.1"}, + currentFound: true, + probeCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.2"}, + } + store, completed := newRecoveryStore(updater, selectionStore) + + job, err := store.Enqueue("managed-acp", managedRuntimeSpec(), "1.0.2") + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + + updater.mu.Lock() + metadataCalls := updater.metadataCalls + updater.mu.Unlock() + if metadataCalls != 0 { + t.Fatalf("metadata calls = %d, want no refetch for a validated target", metadataCalls) + } +} + +func TestAgentUpdateFailedCandidatePreservesSelectionAndCapabilities(t *testing.T) { + selectionStore := newRecoverySelectionStore() + selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{ + Package: "@example/managed-acp", + Version: "1.0.3", + } + events := make([]string, 0, 2) + updater := &recoveryRuntimeUpdater{ + metadata: RuntimeVersionMetadata{ + Versions: []string{"1.0.2", "1.0.3"}, + Latest: "1.0.3", + }, + current: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.3"}, + currentFound: true, + probeCaps: hostutility.AgentCapabilities{ + Status: hostutility.StatusFailed, + Error: "ACP initialization failed", + }, + } + updater.events = &events + store, completed := newRecoveryStore(updater, selectionStore) + job, err := store.Enqueue("managed-acp", managedRuntimeSpec(), "1.0.2") + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusFailed) + if !strings.Contains(final.Error, "ACP initialization failed") { + t.Fatalf("error = %q", final.Error) + } + selection, found, err := selectionStore.Get(context.Background(), "managed-acp", "@example/managed-acp") + if err != nil || !found || selection.Version != "1.0.3" { + t.Fatalf("selection after failure = %#v, found=%v, err=%v", selection, found, err) + } + if len(events) != 1 || events[0] != "probe" { + t.Fatalf("candidate publication events = %#v", updater.events) + } +} + +func TestAgentUpdateActiveHealthyTargetIsUpToDateWithoutPreparation(t *testing.T) { + selectionStore := newRecoverySelectionStore() + selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{ + Package: "@example/managed-acp", + Version: "1.0.3", + } + updater := &recoveryRuntimeUpdater{ + metadata: RuntimeVersionMetadata{Versions: []string{"1.0.3"}, Latest: "1.0.3"}, + current: hostutility.AgentCapabilities{ + Status: hostutility.StatusOK, + AgentVersion: "1.0.3", + }, + currentFound: true, + } + store, completed := newRecoveryStore(updater, selectionStore) + job, err := store.Enqueue("managed-acp", managedRuntimeSpec(), "1.0.3") + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + if final.Operation != string(managedruntime.OperationUpToDate) { + t.Fatalf("operation = %q, want up_to_date", final.Operation) + } + if updater.runCalls != 0 || len(updater.probe) != 0 { + t.Fatalf("updater mutated up-to-date target: runs=%d probes=%d", updater.runCalls, len(updater.probe)) + } +} diff --git a/apps/backend/internal/agent/settings/controller/agent_update_test.go b/apps/backend/internal/agent/settings/controller/agent_update_test.go index e55f1b4380..6fa3dfba99 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_test.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/settings/dto" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -41,6 +42,26 @@ type fakeRuntimeUpdater struct { resolvedPackage string } +type sequencedVersionUpdater struct { + fakeRuntimeUpdater + metadata RuntimeVersionMetadata + metadataErr error + metadataCall int +} + +func (u *sequencedVersionUpdater) ResolveVersions( + _ context.Context, + _ string, +) (RuntimeVersionMetadata, error) { + u.mu.Lock() + defer u.mu.Unlock() + u.metadataCall++ + if u.metadataCall > 1 && u.metadataErr != nil { + return RuntimeVersionMetadata{}, u.metadataErr + } + return u.metadata, nil +} + type recordingCommandExecutor struct { outputCommand []string output string @@ -79,6 +100,25 @@ func TestHostRuntimeUpdaterResolvesTargetWithDirectNPMArgv(t *testing.T) { } } +func TestHostRuntimeUpdaterResolvesStableVersionCatalogue(t *testing.T) { + executor := &recordingCommandExecutor{ + output: `{"versions":["1.0.1","1.0.2-beta.1","1.0.2"],"dist-tags":{"latest":"1.0.2"}}`, + } + updater := &hostRuntimeUpdater{executor: executor} + + metadata, err := updater.ResolveVersions(context.Background(), "@example/managed-acp") + if err != nil { + t.Fatalf("ResolveVersions: %v", err) + } + if metadata.Latest != "1.0.2" || len(metadata.Versions) != 3 { + t.Fatalf("metadata = %#v", metadata) + } + want := []string{"npm", "view", "@example/managed-acp", "versions", "dist-tags", "--json"} + if got := strings.Join(executor.outputCommand, "\x00"); got != strings.Join(want, "\x00") { + t.Fatalf("command = %v, want %v", executor.outputCommand, want) + } +} + func TestHostRuntimeUpdaterInvalidatesOnlyManagedNPMExecutionTree(t *testing.T) { cacheRoot := t.TempDir() spec := agents.ManagedNPMRuntimeSpec{Package: "opencode-ai"} @@ -225,7 +265,7 @@ func waitForUpdateStatus( return &snapshot } } - t.Fatalf("job %s finished with status %s, want %v", jobID, snapshot.Status, statuses) + t.Fatalf("job %s finished with status %s, error %q, operation %q, want %v", jobID, snapshot.Status, snapshot.Error, snapshot.Operation, statuses) return nil } @@ -316,6 +356,124 @@ func TestAgentUpdatePreviewRejectsUnsupportedAndResolutionFailure(t *testing.T) } } +func TestAgentUpdatePreviewSurfacesSelectionStoreFailure(t *testing.T) { + updater := &fakeRuntimeUpdater{target: "1.1.0"} + selectionStore := newRecoverySelectionStore() + selectionStore.err = errors.New("selection store locked") + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: managedRuntimeSpec(), + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + ctrl.SetRuntimeUpdater(updater) + ctrl.SetManagedRuntimeSelectionStore(selectionStore) + + _, err := ctrl.PreviewAgentUpdate(context.Background(), ag.ID()) + if !errors.Is(err, ErrRuntimeUpdatePreviewFailed) { + t.Fatalf("PreviewAgentUpdate error = %v, want %v", err, ErrRuntimeUpdatePreviewFailed) + } + if !strings.Contains(err.Error(), "selection store locked") { + t.Fatalf("PreviewAgentUpdate error = %v, want selection error", err) + } +} + +func TestEnqueueAgentUpdateReusesActiveJobBeforeMetadataResolution(t *testing.T) { + metadataErr := errors.New("registry unavailable") + updater := &sequencedVersionUpdater{ + fakeRuntimeUpdater: fakeRuntimeUpdater{ + current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, + currentFound: true, + refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.1.0"}, + runStarted: make(chan struct{}), + releaseRun: make(chan struct{}), + }, + metadata: RuntimeVersionMetadata{Versions: []string{"1.0.0", "1.1.0"}, Latest: "1.1.0"}, + metadataErr: metadataErr, + } + hub := newUpdateTerminalBroadcaster() + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: managedRuntimeSpec(), + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + ctrl.SetRuntimeUpdater(updater) + ctrl.updateJobStore = NewAgentUpdateJobStore( + hub, + zap.NewNop(), + updater, + newMaintenanceCoordinator(), + nil, + ) + + first, err := ctrl.EnqueueAgentUpdate(context.Background(), ag.ID(), "1.1.0") + if err != nil { + t.Fatalf("first EnqueueAgentUpdate: %v", err) + } + select { + case <-updater.runStarted: + case <-time.After(2 * time.Second): + t.Fatal("first update did not reach the running phase") + } + + second, err := ctrl.EnqueueAgentUpdate(context.Background(), ag.ID(), "1.1.0") + if err != nil { + t.Fatalf("second EnqueueAgentUpdate: %v", err) + } + if second.JobID != first.JobID { + t.Fatalf("job IDs differ: %s != %s", second.JobID, first.JobID) + } + updater.mu.Lock() + metadataCalls := updater.metadataCall + updater.mu.Unlock() + if metadataCalls != 1 { + t.Fatalf("metadata calls = %d, want one initial validation", metadataCalls) + } + + close(updater.releaseRun) + waitForUpdateStatus(t, hub.completed, first.JobID, dto.AgentUpdateJobStatusSucceeded) +} + +func TestEnqueueAgentUpdateDoesNotCreateJobForAlreadyActiveHealthyTarget(t *testing.T) { + selectionStore := newRecoverySelectionStore() + selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{ + Package: "@example/managed-acp", + Version: "1.1.0", + } + updater := &recoveryRuntimeUpdater{ + metadata: RuntimeVersionMetadata{Versions: []string{"1.1.0"}, Latest: "1.1.0"}, + current: hostutility.AgentCapabilities{ + Status: hostutility.StatusOK, + AgentVersion: "1.1.0", + }, + currentFound: true, + } + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: managedRuntimeSpec(), + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + ctrl.SetManagedRuntimeSelectionStore(selectionStore) + ctrl.SetJobBroadcaster(newUpdateTerminalBroadcaster()) + ctrl.SetRuntimeUpdater(updater) + + result, err := ctrl.EnqueueAgentUpdate(context.Background(), ag.ID(), "1.1.0") + if err != nil { + t.Fatalf("EnqueueAgentUpdate: %v", err) + } + if result.JobID != "" { + t.Fatalf("no-op response job ID = %q, want no persisted job", result.JobID) + } + if result.Operation != string(managedruntime.OperationUpToDate) { + t.Fatalf("no-op operation = %q, want up_to_date", result.Operation) + } + if jobs := ctrl.ListAgentUpdateJobs(); len(jobs) != 0 { + t.Fatalf("retained jobs = %d, want none", len(jobs)) + } + if updater.runCalls != 0 || len(updater.probe) != 0 { + t.Fatalf("no-op mutated updater: runs=%d probes=%d", updater.runCalls, len(updater.probe)) + } +} + func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) { updater := &fakeRuntimeUpdater{ current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index b06572ae00..37561078d4 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -10,6 +10,7 @@ import ( "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/managedruntime" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/modelfetcher" @@ -51,24 +52,25 @@ var ( ) type Controller struct { - repo store.Repository - discovery *discovery.Registry - agentRegistry *registry.Registry - sessionChecker SessionChecker - watcherDeps WatcherDependencyChecker - routingTierDeps RoutingTierDependencyChecker - automationDeps AutomationDependencyChecker - utilityDeps UtilityDependencyChecker - mcpService *mcpconfig.Service - modelCache *modelfetcher.Cache - hostUtility hostUtilityProvider - jobStore *JobStore - updateJobStore *AgentUpdateJobStore - runtimeUpdater RuntimeUpdater - maintenance *maintenanceCoordinator - hub JobBroadcaster - logger *logger.Logger - secretStore secrets.SecretStore + repo store.Repository + discovery *discovery.Registry + agentRegistry *registry.Registry + sessionChecker SessionChecker + watcherDeps WatcherDependencyChecker + routingTierDeps RoutingTierDependencyChecker + automationDeps AutomationDependencyChecker + utilityDeps UtilityDependencyChecker + mcpService *mcpconfig.Service + modelCache *modelfetcher.Cache + hostUtility hostUtilityProvider + jobStore *JobStore + updateJobStore *AgentUpdateJobStore + runtimeUpdater RuntimeUpdater + managedRuntimeSelections managedruntime.SelectionStore + maintenance *maintenanceCoordinator + hub JobBroadcaster + logger *logger.Logger + secretStore secrets.SecretStore } // SetSecretStore wires the metadata-only validator used by shared agent @@ -251,6 +253,13 @@ func (c *Controller) SetRuntimeUpdater(updater RuntimeUpdater) { c.initializeUpdateJobStore() } +// SetManagedRuntimeSelectionStore wires the install-wide active-version +// persistence used by previews, jobs, and the available-agent catalogue. +func (c *Controller) SetManagedRuntimeSelectionStore(store managedruntime.SelectionStore) { + c.managedRuntimeSelections = store + c.initializeUpdateJobStore() +} + // 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 @@ -301,6 +310,7 @@ func (c *Controller) initializeUpdateJobStore() { c.runtimeUpdater, c.maintenance, c.BroadcastAvailableAgents, + c.managedRuntimeSelections, ) } diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 51193572cb..5694c08685 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/discovery" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/modelfetcher" @@ -174,6 +175,25 @@ func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { } } +func TestController_PreviewAgentCommandUsesActiveManagedRuntimeVersion(t *testing.T) { + agent := agents.NewOpenCodeACP() + controller := newTestController(map[string]agents.Agent{agent.ID(): agent}) + selectionStore := newRecoverySelectionStore() + selectionStore.values[agent.ID()+"\x00opencode-ai"] = managedruntime.Selection{ + Package: "opencode-ai", + Version: "1.18.5", + } + controller.SetManagedRuntimeSelectionStore(selectionStore) + + result, err := controller.PreviewAgentCommand(context.Background(), agent.ID(), CommandPreviewRequest{}) + if err != nil { + t.Fatalf("PreviewAgentCommand() error = %v", err) + } + if !slices.Contains(result.Command, "opencode-ai@1.18.5") { + t.Fatalf("preview command = %v, want exact active version", result.Command) + } +} + func TestController_PreviewAgentCommand_RejectsInvalidCommandPrefix(t *testing.T) { controller := newTestController(map[string]agents.Agent{ "test-agent": &testAgent{ diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 79671cd469..eff69305d0 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -236,6 +236,7 @@ type RuntimeUpdateDTO struct { Supported bool `json:"supported"` Package string `json:"package"` CurrentVersion string `json:"current_version,omitempty"` + ActiveVersion string `json:"active_version,omitempty"` } type ListAvailableAgentsResponse struct { @@ -296,7 +297,9 @@ type AgentUpdateJobDTO struct { JobID string `json:"job_id"` AgentName string `json:"agent_name"` Status AgentUpdateJobStatus `json:"status"` + Operation string `json:"operation"` CurrentVersion string `json:"current_version,omitempty"` + ActiveVersion string `json:"active_version,omitempty"` TargetVersion string `json:"target_version,omitempty"` Output string `json:"output,omitempty"` Error string `json:"error,omitempty"` @@ -308,12 +311,28 @@ type AgentUpdateJobDTO struct { // AgentUpdatePreviewDTO is a read-only representation of the next managed // runtime update. The command is derived from trusted built-in agent metadata. type AgentUpdatePreviewDTO struct { - AgentName string `json:"agent_name"` - Package string `json:"package"` - CurrentVersion string `json:"current_version,omitempty"` - TargetVersion string `json:"target_version"` - Command []string `json:"command"` - CommandString string `json:"command_string"` + AgentName string `json:"agent_name"` + Package string `json:"package"` + CurrentVersion string `json:"current_version,omitempty"` + ActiveVersion string `json:"active_version,omitempty"` + TargetVersion string `json:"target_version"` + Operation string `json:"operation"` + AvailableVersions []AgentUpdateVersionDTO `json:"available_versions"` + Command []string `json:"command"` + CommandString string `json:"command_string"` +} + +// AgentUpdateVersionDTO is one stable, selectable package version. +type AgentUpdateVersionDTO struct { + Version string `json:"version"` + Latest bool `json:"latest"` +} + +// AgentUpdateRequest is the only state-changing input accepted by the +// managed-runtime update endpoint. Package identity and command arguments are +// always resolved from trusted built-in agent metadata. +type AgentUpdateRequest struct { + TargetVersion string `json:"target_version"` } type ListAgentUpdateJobsResponse struct { diff --git a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go index 0b96e71a7b..4e54051d91 100644 --- a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/controller" "github.com/kandev/kandev/internal/agent/settings/dto" @@ -28,8 +29,24 @@ type handlerRuntimeUpdater struct { once sync.Once } +type handlerSelectionStore struct { + selection managedruntime.Selection +} + +func (s handlerSelectionStore) Get( + context.Context, + string, + string, +) (managedruntime.Selection, bool, error) { + return s.selection, true, nil +} + +func (handlerSelectionStore) Save(context.Context, string, string, string) error { + return nil +} + func (u *handlerRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentCapabilities, bool) { - return hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, true + return hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.0"}, true } func (u *handlerRuntimeUpdater) ResolveTarget(context.Context, string) (string, error) { @@ -140,6 +157,13 @@ func updateRequest(method, path string) *http.Request { return request } +func updateJSONRequest(method, path, body string) *http.Request { + request := httptest.NewRequest(method, path, strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set(httpmw.InterimSettingsInterlockHeader, "test-interlock") + return request +} + func TestAgentUpdatePreviewEndpointIsReadOnly(t *testing.T) { router, _, _ := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) response := httptest.NewRecorder() @@ -171,7 +195,11 @@ func TestAgentUpdatePreviewEndpointIsReadOnly(t *testing.T) { func TestAgentUpdateEndpointsAcceptAndRetainJobs(t *testing.T) { router, _, completed := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) response := httptest.NewRecorder() - router.ServeHTTP(response, updateRequest(http.MethodPost, "/api/v1/agent-update/claude-acp")) + router.ServeHTTP(response, updateJSONRequest( + http.MethodPost, + "/api/v1/agent-update/claude-acp", + `{"target_version":"1.1.0"}`, + )) if response.Code != http.StatusAccepted { t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String()) } @@ -209,6 +237,38 @@ func TestAgentUpdateEndpointsAcceptAndRetainJobs(t *testing.T) { } } +func TestAgentUpdateEndpointDoesNotCreateAlreadyActiveHealthyJob(t *testing.T) { + router, ctrl, _ := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) + selectionStore := handlerSelectionStore{selection: managedruntime.Selection{ + Package: "@agentclientprotocol/claude-agent-acp", + Version: "1.0.0", + }} + ctrl.SetManagedRuntimeSelectionStore(selectionStore) + + response := httptest.NewRecorder() + router.ServeHTTP(response, updateJSONRequest( + http.MethodPost, + "/api/v1/agent-update/claude-acp", + `{"target_version":"1.0.0"}`, + )) + if response.Code != http.StatusAccepted { + t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String()) + } + var result dto.AgentUpdateJobDTO + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatalf("decode no-op response: %v", err) + } + if result.JobID != "" || result.Operation != string(managedruntime.OperationUpToDate) { + t.Fatalf("no-op response = %#v, want terminal up_to_date without job ID", result) + } + + jobs := httptest.NewRecorder() + router.ServeHTTP(jobs, updateRequest(http.MethodGet, "/api/v1/agent-update/jobs")) + if jobs.Code != http.StatusOK || !strings.Contains(jobs.Body.String(), `"jobs":[]`) { + t.Fatalf("no-op jobs response = %d %s", jobs.Code, jobs.Body.String()) + } +} + func TestAgentUpdateEndpointReturnsMaintenanceConflict(t *testing.T) { updater := &handlerRuntimeUpdater{ started: make(chan struct{}), @@ -223,9 +283,10 @@ func TestAgentUpdateEndpointReturnsMaintenanceConflict(t *testing.T) { } }) updateResponse := httptest.NewRecorder() - router.ServeHTTP(updateResponse, updateRequest( + router.ServeHTTP(updateResponse, updateJSONRequest( http.MethodPost, "/api/v1/agent-update/claude-acp", + `{"target_version":"1.1.0"}`, )) if updateResponse.Code != http.StatusAccepted { t.Fatalf("update status = %d, body = %s", updateResponse.Code, updateResponse.Body.String()) @@ -285,3 +346,18 @@ func TestAgentUpdateEndpointRejectsUnmanagedAgentAndMissingJob(t *testing.T) { t.Fatalf("missing status = %d, body = %s", missing.Code, missing.Body.String()) } } + +func TestAgentUpdateEndpointRequiresExactTargetVersion(t *testing.T) { + router, _, _ := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) + for _, body := range []string{"", `{}`, `{"target_version":"latest"}`} { + response := httptest.NewRecorder() + router.ServeHTTP(response, updateJSONRequest( + http.MethodPost, + "/api/v1/agent-update/claude-acp", + body, + )) + if response.Code != http.StatusBadRequest { + t.Fatalf("body %q status = %d, want 400: %s", body, response.Code, response.Body.String()) + } + } +} diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 71b70242e3..3cac96ea57 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -123,8 +123,18 @@ func (h *Handlers) httpUpdateAgentRuntime(c *gin.Context) { if !ok { return } + var request dto.AgentUpdateRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "target version is required"}) + return + } + request.TargetVersion = strings.TrimSpace(request.TargetVersion) + if request.TargetVersion == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target version is required"}) + return + } h.enqueueMaintenance(c, name, "update", func() (any, error) { - return h.controller.EnqueueAgentUpdate(name) + return h.controller.EnqueueAgentUpdate(c.Request.Context(), name, request.TargetVersion) }, classifyUpdateError) } @@ -133,7 +143,11 @@ func (h *Handlers) httpPreviewAgentUpdate(c *gin.Context) { if !ok { return } - preview, err := h.controller.PreviewAgentUpdate(c.Request.Context(), name) + preview, err := h.controller.PreviewAgentUpdate( + c.Request.Context(), + name, + strings.TrimSpace(c.Query("target_version")), + ) if err == nil { c.JSON(http.StatusOK, preview) return @@ -211,6 +225,12 @@ func classifyUpdateError(err error) (int, string, bool) { return http.StatusBadRequest, "agent runtime update unsupported", true case errors.Is(err, controller.ErrRuntimeUpdaterUnavailable): return http.StatusServiceUnavailable, "agent update service not ready", true + case errors.Is(err, controller.ErrRuntimeUpdateTargetRequired): + return http.StatusBadRequest, "target version is required", true + case errors.Is(err, controller.ErrRuntimeUpdateTargetInvalid): + return http.StatusBadRequest, "target version is invalid", true + case errors.Is(err, controller.ErrRuntimeUpdateTargetMissing): + return http.StatusBadRequest, "target version is not published", true default: return 0, "", false } @@ -221,7 +241,13 @@ func classifyUpdatePreviewError(err error) (int, string, bool) { return status, message, true } if errors.Is(err, controller.ErrRuntimeUpdatePreviewFailed) { - return http.StatusBadGateway, "unable to resolve latest runtime version", true + return http.StatusBadGateway, "unable to resolve runtime versions", true + } + if errors.Is(err, controller.ErrRuntimeUpdateTargetInvalid) { + return http.StatusBadRequest, "target version is invalid", true + } + if errors.Is(err, controller.ErrRuntimeUpdateTargetMissing) { + return http.StatusBadRequest, "target version is not published", true } return 0, "", false } diff --git a/apps/backend/internal/backendapp/agents.go b/apps/backend/internal/backendapp/agents.go index 91a3aee424..1ab2c481fa 100644 --- a/apps/backend/internal/backendapp/agents.go +++ b/apps/backend/internal/backendapp/agents.go @@ -7,6 +7,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agent/credentials" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" @@ -29,6 +30,7 @@ func provideLifecycleManager( agentRegistry *registry.Registry, secretStore secrets.SecretStore, baseBranchProvider lifecycle.BaseBranchProvider, + managedRuntimeSelections managedruntime.SelectionReader, ) (*lifecycle.Manager, error) { log.Info("Initializing Agent Manager...") @@ -129,6 +131,7 @@ func provideLifecycleManager( // enrichment fields. Without a wired SkillDeployer this is a no-op, // but the reader still lets future Wave-B/C consumers light up. lifecycleMgr.SetAgentProfileReader(agentSettingsRepo) + lifecycleMgr.SetManagedRuntimeSelectionStore(managedRuntimeSelections) // MCP handler is set later in main.go after MCP handlers are registered // via lifecycleMgr.SetMCPHandler(gateway.Dispatcher) diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index a2d1bd0bb3..f8b47636e4 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -362,6 +362,7 @@ func startServices( //nolint:cyclop log.Error("Failed to initialize services", zap.Error(err)) return false } + agentRegistry.SetManagedRuntimeSelectionStore(services.ManagedRuntimeSelections) if services.Workflow != nil { addCleanup(services.Workflow.Close) } @@ -446,7 +447,17 @@ func startAgentInfrastructure( // ============================================ // AGENT MANAGER // ============================================ - lifecycleMgr, err := provideLifecycleManager(ctx, cfg, log, eventBus, repos.AgentSettings, agentRegistry, userSecretStore, services.Task.TaskBaseBranches) + lifecycleMgr, err := provideLifecycleManager( + ctx, + cfg, + log, + eventBus, + repos.AgentSettings, + agentRegistry, + userSecretStore, + services.Task.TaskBaseBranches, + services.ManagedRuntimeSelections, + ) if err != nil { log.Error("Failed to initialize agent manager", zap.Error(err)) return false @@ -767,6 +778,7 @@ func startGatewayAndServe( _, ok := agentRegistry.GetInferenceAgent(agentID) return ok })) + hostUtilityMgr.SetManagedRuntimeSelectionStore(services.ManagedRuntimeSelections) // Wire the host utility manager into the settings controller so // /api/v1/agent-models/:agentName reads live capability data. agentSettingsController.SetHostUtility(hostUtilityMgr) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index b050390086..3d90c275cd 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/managedruntime" "github.com/kandev/kandev/internal/agent/registry" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" analyticsservice "github.com/kandev/kandev/internal/analytics/service" @@ -62,6 +63,12 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories userSecretStore := secrets.NewUserVisibleStore(repos.Secrets) agentSettingsController := agentsettingscontroller.NewController(repos.AgentSettings, discoveryRegistry, agentRegistry, repos.Task, log) agentSettingsController.SetSecretStore(userSecretStore) + managedRuntimeSettings, err := systemsettings.NewStore(dbPool) + if err != nil { + return nil, nil, fmt.Errorf("initialize managed runtime settings: %w", err) + } + managedRuntimeSelections := managedruntime.NewStore(managedRuntimeSettings) + agentSettingsController.SetManagedRuntimeSelectionStore(managedRuntimeSelections) userSvc := userservice.NewService(repos.User, eventBus, log) editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc) @@ -214,23 +221,24 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories } services := &Services{ - Task: taskSvc, - User: userSvc, - Editor: editorSvc, - Prompts: promptSvc, - Utility: utilitySvc, - Workflow: workflowSvc, - GitHub: githubSvc, - GitLab: gitlabSvc, - AzureDevOps: azureDevOpsSvc, - Jira: jiraSvc, - Linear: linearSvc, - Sentry: sentrySvc, - WorkflowSync: workflowSyncSvc, - Share: shareHTTP, - Automation: automationComponents, - Plugins: pluginsSvc, - GitCredentials: gitCredentialBroker, + ManagedRuntimeSelections: managedRuntimeSelections, + Task: taskSvc, + User: userSvc, + Editor: editorSvc, + Prompts: promptSvc, + Utility: utilitySvc, + Workflow: workflowSvc, + GitHub: githubSvc, + GitLab: gitlabSvc, + AzureDevOps: azureDevOpsSvc, + Jira: jiraSvc, + Linear: linearSvc, + Sentry: sentrySvc, + WorkflowSync: workflowSyncSvc, + Share: shareHTTP, + Automation: automationComponents, + Plugins: pluginsSvc, + GitCredentials: gitCredentialBroker, // Office is constructed later in initOfficeServices once all // of its dependencies (config loader, task integrations, etc.) are available. Office: nil, diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index b4f6895925..9f73a40790 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -3,6 +3,7 @@ package backendapp import ( "errors" + "github.com/kandev/kandev/internal/agent/managedruntime" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" analyticsrepository "github.com/kandev/kandev/internal/analytics/repository" authservice "github.com/kandev/kandev/internal/auth" @@ -66,19 +67,20 @@ type Repositories struct { } type Services struct { - Task *taskservice.Service - User *userservice.Service - Editor *editorservice.Service - Notification *notificationservice.Service - Prompts *promptservice.Service - Utility *utilityservice.Service - Workflow *workflowservice.Service - GitHub *github.Service - GitLab *gitlab.Service - AzureDevOps *azuredevops.Service - Jira *jira.Service - Linear *linear.Service - Sentry *sentry.Service + ManagedRuntimeSelections managedruntime.SelectionStore + Task *taskservice.Service + User *userservice.Service + Editor *editorservice.Service + Notification *notificationservice.Service + Prompts *promptservice.Service + Utility *utilityservice.Service + Workflow *workflowservice.Service + GitHub *github.Service + GitLab *gitlab.Service + AzureDevOps *azuredevops.Service + Jira *jira.Service + Linear *linear.Service + Sentry *sentry.Service // WorkflowSync keeps workspace workflows in sync with definition files // in a configured GitHub repository. Nil when GitHub is unavailable. WorkflowSync *workflowsync.Service diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 423a0e3e38..2b1c4722e1 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -53,8 +53,8 @@ type InstalledAgentsSectionProps = { resolveRuntimeUpdate: (name: string) => RuntimeUpdate | undefined; installJobs: Record; updateJobs: Record; - previewUpdate: (name: string) => Promise; - startUpdate: (name: string) => Promise; + previewUpdate: (name: string, targetVersion?: string) => Promise; + startUpdate: (name: string, targetVersion: string) => Promise; setTuiDialogOpen: (open: boolean) => void; handleRescan: () => Promise; }; diff --git a/apps/web/components/settings/agent-runtime-update-control.tsx b/apps/web/components/settings/agent-runtime-update-control.tsx index 906b13b651..e0c09dbf54 100644 --- a/apps/web/components/settings/agent-runtime-update-control.tsx +++ b/apps/web/components/settings/agent-runtime-update-control.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react"; +import { IconAlertTriangle, IconChevronDown, IconLoader2, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -24,7 +24,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { canApproveAgentRuntimeUpdate, + resolveRuntimeActiveVersion, + resolveRuntimeOperation, resolveRuntimeVersionPair, + latestRuntimeVersions, + runtimeOperationLabelKey, } from "@/lib/agent-runtime-update"; import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; import type { RuntimeUpdate } from "@/lib/types/http"; @@ -56,7 +60,7 @@ function updatePhase(t: TFunction, status: AgentUpdateJob["status"] | undefined) function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) { const { t } = useTranslation(); if (!job) return null; - const { versionsMatch } = resolveRuntimeVersionPair(null, job); + const isUpToDate = job.operation === "up_to_date"; if (job.status === "succeeded" && job.refresh_error) { return (

- {versionsMatch ? t("agents:runtimeAlreadyUpToDate") : t("agents:runtimeUpdatedSuccess")} + {isUpToDate ? t("agents:runtimeAlreadyUpToDate") : t("agents:runtimeUpdatedSuccess")}

); } @@ -101,25 +105,38 @@ type UpdateBodyProps = { approveError: string | null; job?: AgentUpdateJob; onRetryPreview: () => void; + selectedTarget: string; + onSelectTarget: (targetVersion: string) => void; + starting: boolean; }; function RuntimeVersionSummary({ + agentName, preview, job, }: { + agentName: string; preview: AgentUpdatePreview; job?: AgentUpdateJob; }) { const { t } = useTranslation(); - const { currentVersion, targetVersion, versionsMatch } = resolveRuntimeVersionPair(preview, job); + const { currentVersion, targetVersion } = resolveRuntimeVersionPair(preview, job); + const activeVersion = resolveRuntimeActiveVersion(preview, job); + const operation = resolveRuntimeOperation(preview, job); + const isUpToDate = operation === "up_to_date"; return ( -
-

{t("agents:runtimeVersion")}

+
+

{t(runtimeOperationLabelKey(operation))}

- {versionsMatch ? currentVersion : `${currentVersion} → ${targetVersion}`} + {isUpToDate ? currentVersion : `${currentVersion} → ${targetVersion}`}

- {versionsMatch && ( + {activeVersion && ( +

+ {t("agents:activeRuntimeVersion", { version: activeVersion })} +

+ )} + {isUpToDate && (

{t("agents:upToDate")}

@@ -128,6 +145,77 @@ function RuntimeVersionSummary({ ); } +function RuntimeVersionSelector({ + agentName, + preview, + selectedTarget, + loading, + starting, + job, + onSelectTarget, +}: { + agentName: string; + preview: AgentUpdatePreview; + selectedTarget: string; + loading: boolean; + starting: boolean; + job?: AgentUpdateJob; + onSelectTarget: (targetVersion: string) => void; +}) { + const { t } = useTranslation(); + const versions = latestRuntimeVersions( + preview.available_versions ?? [{ version: preview.target_version, latest: false }], + ); + const activeVersion = resolveRuntimeActiveVersion(preview, job); + return ( +
+ +
+ + + {loading ? ( + + ) : ( + +
+
+ ); +} + function UpdateBody({ agentName, preview, @@ -136,15 +224,18 @@ function UpdateBody({ approveError, job, onRetryPreview, + selectedTarget, + onSelectTarget, + starting, }: UpdateBodyProps) { const { t } = useTranslation(); const phase = updatePhase(t, job?.status); return (
- {loading && ( + {loading && !preview && (

{t("agents:checkingLatestRuntimeVersion")} @@ -178,7 +269,16 @@ function UpdateBody({ )} {preview && ( <> - + +

{t("agents:runtimeUpdateExplainer")}

{t("agents:runtimeUpdateSessionsNote")}

@@ -265,7 +365,9 @@ function UpdateFooter({ data-testid={`agent-update-confirm-${agentName}`} > {starting && } - {canRetry ? t("agents:retryUpdate") : t("agents:approveUpdate")} + {canRetry + ? t("agents:retryUpdate") + : t(runtimeOperationLabelKey(job?.operation ?? preview?.operation))} )} @@ -333,8 +435,8 @@ export function AgentRuntimeUpdateControl({ runtimeUpdate: RuntimeUpdate; job?: AgentUpdateJob; installJob?: InstallJob; - onPreview: (agentName: string) => Promise; - onUpdate: (agentName: string) => Promise; + onPreview: (agentName: string, targetVersion?: string) => Promise; + onUpdate: (agentName: string, targetVersion: string) => Promise; }) { const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); @@ -348,6 +450,8 @@ export function AgentRuntimeUpdateControl({ open, preview, previewError, + selectTarget, + selectedTarget, starting, } = useAgentUpdateDialogState({ agentName, job, onPreview, onUpdate }); const installInFlight = installJob?.status === "queued" || installJob?.status === "running"; @@ -362,7 +466,10 @@ export function AgentRuntimeUpdateControl({ previewError={previewError} approveError={approveError} job={activeJob} - onRetryPreview={() => void loadPreview()} + onRetryPreview={() => void loadPreview(selectedTarget || undefined)} + selectedTarget={selectedTarget} + onSelectTarget={selectTarget} + starting={starting} /> ); @@ -403,7 +510,7 @@ export function AgentRuntimeUpdateControl({ ) : ( diff --git a/apps/web/components/settings/installed-agent-card.tsx b/apps/web/components/settings/installed-agent-card.tsx index ee9fea42c0..d6468d316b 100644 --- a/apps/web/components/settings/installed-agent-card.tsx +++ b/apps/web/components/settings/installed-agent-card.tsx @@ -27,7 +27,7 @@ type Props = { updateJob?: AgentUpdateJob; installJob?: InstallJob; onPreview?: (agentName: string) => Promise; - onUpdate?: (agentName: string) => Promise; + onUpdate?: (agentName: string, targetVersion: string) => Promise; /** * Called when the auth/shell dialog closes so the page can refresh * discovery + availability. Without this the yellow lock stays put even diff --git a/apps/web/components/settings/use-agent-update-dialog-state.test.ts b/apps/web/components/settings/use-agent-update-dialog-state.test.ts index 6ac63baf99..e23a6ea1fa 100644 --- a/apps/web/components/settings/use-agent-update-dialog-state.test.ts +++ b/apps/web/components/settings/use-agent-update-dialog-state.test.ts @@ -70,11 +70,20 @@ describe("useAgentUpdateDialogState", () => { const { result } = renderHook(() => useAgentUpdateDialogState({ agentName: AGENT_NAME, - onPreview: vi.fn().mockResolvedValue(FIRST_PREVIEW), + onPreview: vi.fn().mockImplementation((_agentName: string, targetVersion?: string) => + Promise.resolve({ + ...FIRST_PREVIEW, + target_version: targetVersion ?? FIRST_PREVIEW.target_version, + }), + ), onUpdate, }), ); + await act(async () => { + await result.current.loadPreview(); + }); + await waitFor(() => expect(result.current.preview).toEqual(FIRST_PREVIEW)); act(() => { void result.current.approve(); }); @@ -99,6 +108,10 @@ describe("useAgentUpdateDialogState", () => { }), ); + await act(async () => { + await result.current.loadPreview(); + }); + await waitFor(() => expect(result.current.preview).toEqual(FIRST_PREVIEW)); await act(async () => { await result.current.approve(); }); @@ -107,3 +120,150 @@ describe("useAgentUpdateDialogState", () => { expect(result.current.approveError).toBe(UPDATE_ALREADY_RUNNING); }); }); + +describe("useAgentUpdateDialogState target selection", () => { + it("keeps the current preview while a selected target is loading", async () => { + const selectedTargetPreview = deferred(); + const onPreview = vi + .fn<(agentName: string, targetVersion?: string) => Promise>() + .mockResolvedValueOnce(FIRST_PREVIEW) + .mockReturnValueOnce(selectedTargetPreview.promise); + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + onPreview, + onUpdate: vi.fn(), + }), + ); + + await act(async () => { + await result.current.loadPreview(); + }); + act(() => result.current.selectTarget("0.61.0")); + + await waitFor(() => expect(onPreview).toHaveBeenCalledWith(AGENT_NAME, "0.61.0")); + expect(result.current.preview).toEqual(FIRST_PREVIEW); + expect(result.current.loading).toBe(true); + + await act(async () => { + selectedTargetPreview.resolve({ ...FIRST_PREVIEW, target_version: "0.61.0" }); + await selectedTargetPreview.promise; + }); + await waitFor(() => expect(result.current.preview?.target_version).toBe("0.61.0")); + }); + + it("refreshes a selected target and ignores an older target response", async () => { + const first = deferred(); + const olderTarget = deferred(); + const newerTarget = deferred(); + const onPreview = vi + .fn<(agentName: string, targetVersion?: string) => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(olderTarget.promise) + .mockReturnValueOnce(newerTarget.promise); + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + onPreview, + onUpdate: vi.fn(), + }), + ); + + act(() => void result.current.loadPreview()); + await waitFor(() => expect(onPreview).toHaveBeenCalledWith(AGENT_NAME, undefined)); + await act(async () => { + first.resolve(FIRST_PREVIEW); + await first.promise; + }); + + act(() => result.current.selectTarget("0.61.0")); + act(() => result.current.selectTarget("0.60.0")); + await waitFor(() => expect(onPreview).toHaveBeenCalledTimes(3)); + + await act(async () => { + olderTarget.resolve({ ...FIRST_PREVIEW, target_version: "0.61.0" }); + await olderTarget.promise; + }); + expect(result.current.preview).toEqual(FIRST_PREVIEW); + + await act(async () => { + newerTarget.resolve({ ...FIRST_PREVIEW, target_version: "0.60.0" }); + await newerTarget.promise; + }); + expect(result.current.selectedTarget).toBe("0.60.0"); + expect(result.current.preview?.target_version).toBe("0.60.0"); + }); + + it("approves the selected exact target", async () => { + const onUpdate = vi.fn().mockResolvedValue({ + job_id: "job-1", + agent_name: AGENT_NAME, + status: "queued", + started_at: "2026-01-01T00:00:00.000Z", + } satisfies AgentUpdateJob); + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + onPreview: vi.fn().mockImplementation((_agentName: string, targetVersion?: string) => + Promise.resolve({ + ...FIRST_PREVIEW, + target_version: targetVersion ?? FIRST_PREVIEW.target_version, + }), + ), + onUpdate, + }), + ); + await act(async () => { + await result.current.loadPreview(); + }); + act(() => result.current.selectTarget("0.61.0")); + await waitFor(() => expect(result.current.selectedTarget).toBe("0.61.0")); + await act(async () => { + await result.current.approve(); + }); + expect(onUpdate).toHaveBeenLastCalledWith(AGENT_NAME, "0.61.0"); + }); +}); + +describe("useAgentUpdateDialogState failed target selection", () => { + it("clears a failed active job when selecting a new target", async () => { + const failedJob: AgentUpdateJob = { + job_id: "runtime-update-job-1", + agent_name: AGENT_NAME, + status: "failed", + operation: "update", + current_version: "0.62.0", + target_version: "0.63.0", + started_at: "2026-01-01T00:00:00.000Z", + finished_at: "2026-01-01T00:01:00.000Z", + error: "The package registry is unavailable", + }; + const onPreview = vi.fn().mockImplementation((_agentName: string, targetVersion?: string) => + Promise.resolve({ + ...FIRST_PREVIEW, + target_version: targetVersion ?? FIRST_PREVIEW.target_version, + operation: targetVersion ? "rollback" : "update", + }), + ); + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + job: failedJob, + onPreview, + onUpdate: vi.fn().mockResolvedValue(failedJob), + }), + ); + + await act(async () => { + await result.current.loadPreview(); + }); + await act(async () => { + await result.current.approve(); + }); + await waitFor(() => expect(result.current.activeJob?.job_id).toBe(failedJob.job_id)); + + act(() => result.current.selectTarget("0.61.0")); + await waitFor(() => expect(result.current.preview?.target_version).toBe("0.61.0")); + expect(result.current.activeJob).toBeUndefined(); + }); +}); diff --git a/apps/web/components/settings/use-agent-update-dialog-state.ts b/apps/web/components/settings/use-agent-update-dialog-state.ts index f132ed866b..29286f3588 100644 --- a/apps/web/components/settings/use-agent-update-dialog-state.ts +++ b/apps/web/components/settings/use-agent-update-dialog-state.ts @@ -10,8 +10,8 @@ function errorMessage(error: unknown) { type UseAgentUpdateDialogStateOptions = { agentName: string; job?: AgentUpdateJob; - onPreview: (agentName: string) => Promise; - onUpdate: (agentName: string) => Promise; + onPreview: (agentName: string, targetVersion?: string) => Promise; + onUpdate: (agentName: string, targetVersion: string) => Promise; }; export function useAgentUpdateDialogState({ @@ -26,6 +26,7 @@ export function useAgentUpdateDialogState({ const [approveError, setApproveError] = useState(null); const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); + const [selectedTarget, setSelectedTarget] = useState(""); const [activeJobID, setActiveJobID] = useState(null); const previewRequestID = useRef(0); const activeJob = activeJobID === job?.job_id ? job : undefined; @@ -37,24 +38,39 @@ export function useAgentUpdateDialogState({ setApproveError(null); setLoading(false); setStarting(false); + setSelectedTarget(""); setActiveJobID(null); }, []); - const loadPreview = useCallback(async () => { - const requestID = ++previewRequestID.current; - setLoading(true); - setPreview(null); - setPreviewError(null); - setApproveError(null); - try { - const nextPreview = await onPreview(agentName); - if (requestID === previewRequestID.current) setPreview(nextPreview); - } catch (error) { - if (requestID === previewRequestID.current) setPreviewError(errorMessage(error)); - } finally { - if (requestID === previewRequestID.current) setLoading(false); - } - }, [agentName, onPreview]); + const loadPreview = useCallback( + async (targetVersion?: string) => { + const requestID = ++previewRequestID.current; + setLoading(true); + setPreviewError(null); + setApproveError(null); + try { + const nextPreview = await onPreview(agentName, targetVersion); + if (requestID === previewRequestID.current) { + setPreview(nextPreview); + setSelectedTarget(nextPreview.target_version); + } + } catch (error) { + if (requestID === previewRequestID.current) setPreviewError(errorMessage(error)); + } finally { + if (requestID === previewRequestID.current) setLoading(false); + } + }, + [agentName, onPreview], + ); + + const selectTarget = useCallback( + (targetVersion: string) => { + setActiveJobID(null); + setSelectedTarget(targetVersion); + void loadPreview(targetVersion); + }, + [loadPreview], + ); useEffect(() => { if (open) void loadPreview(); @@ -67,10 +83,12 @@ export function useAgentUpdateDialogState({ const approve = async () => { const requestID = previewRequestID.current; + const targetVersion = selectedTarget || preview?.target_version || ""; + if (!targetVersion) return; setStarting(true); setApproveError(null); try { - const nextJob = await onUpdate(agentName); + const nextJob = await onUpdate(agentName, targetVersion); if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id); } catch (error) { if (requestID === previewRequestID.current) setApproveError(errorMessage(error)); @@ -89,6 +107,8 @@ export function useAgentUpdateDialogState({ open, preview, previewError, + selectTarget, + selectedTarget, starting, }; } diff --git a/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts b/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts index eda8da6aba..f91599b09f 100644 --- a/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts +++ b/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts @@ -4,11 +4,14 @@ const AGENT_NAME = "claude-acp"; const NOW = "2026-07-26T12:00:00.000Z"; type UpdateStatus = "queued" | "resolving" | "updating" | "refreshing" | "succeeded" | "failed"; +type UpdateOperation = "update" | "rollback" | "repair" | "up_to_date"; type UpdateJob = { job_id: string; agent_name: string; status: UpdateStatus; + operation?: UpdateOperation; + active_version?: string; current_version?: string; target_version?: string; output?: string; @@ -22,7 +25,10 @@ type UpdatePreview = { agent_name: string; package: string; current_version: string; + active_version?: string; target_version: string; + operation?: UpdateOperation; + available_versions?: Array<{ version: string; latest: boolean }>; command: string[]; command_string: string; }; @@ -59,6 +65,7 @@ function catalogue(models: Array<{ id: string; name: string }>, displayName = "C supported: true, package: "@agentclientprotocol/claude-agent-acp", current_version: "0.62.0", + active_version: "0.62.0", }, updated_at: NOW, }, @@ -99,10 +106,28 @@ function event(action: string, payload: unknown) { }); } +function compareStableVersions(left: string, right: string) { + const leftParts = left.split(".").map(Number); + const rightParts = right.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) + return leftParts[index] > rightParts[index] ? 1 : -1; + } + return 0; +} + +function operationForTarget(currentVersion: string, targetVersion: string): UpdateOperation { + const comparison = compareStableVersions(targetVersion, currentVersion); + if (comparison === 0) return "up_to_date"; + return comparison > 0 ? "update" : "rollback"; +} + export type RuntimeUpdateFixtureOptions = { retainedJobs?: UpdateJob[]; postResponse?: UpdateJob; previewResponse?: UpdatePreview; + previewFailures?: string[]; + previewDelayMs?: number; }; export async function installRuntimeUpdateFixture( @@ -122,6 +147,10 @@ export async function installRuntimeUpdateFixture( } satisfies UpdateJob); let postCount = 0; let previewCount = 0; + const previewTargets: string[] = []; + const postTargets: string[] = []; + const previewFailures = [...(options.previewFailures ?? [])]; + const previewDelayMs = options.previewDelayMs ?? 0; let previewResponse: UpdatePreview = options.previewResponse ?? ({ @@ -129,6 +158,14 @@ export async function installRuntimeUpdateFixture( package: "@agentclientprotocol/claude-agent-acp", current_version: "0.62.0", target_version: "0.63.0", + operation: "update", + active_version: "0.62.0", + available_versions: [ + { version: "0.64.0", latest: true }, + { version: "0.63.0", latest: false }, + { version: "0.62.0", latest: false }, + { version: "0.61.0", latest: false }, + ], command: [ "npm", "exec", @@ -167,7 +204,7 @@ export async function installRuntimeUpdateFixture( body: JSON.stringify(savedAgents()), }), ); - await page.route("**/api/v1/agent-update/**", (route) => { + await page.route("**/api/v1/agent-update/**", async (route) => { const request = route.request(); const url = new URL(request.url()); if ( @@ -175,10 +212,30 @@ export async function installRuntimeUpdateFixture( url.pathname.endsWith(`/agent-update/${AGENT_NAME}/preview`) ) { previewCount += 1; + previewTargets.push(url.searchParams.get("target_version") ?? ""); + const requestedTarget = url.searchParams.get("target_version"); + if (requestedTarget && previewFailures.includes(requestedTarget)) { + previewFailures.splice(previewFailures.indexOf(requestedTarget), 1); + return route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "preview temporarily unavailable" }), + }); + } + if (requestedTarget && previewDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, previewDelayMs)); + } + const response = requestedTarget + ? { + ...previewResponse, + target_version: requestedTarget, + operation: operationForTarget(previewResponse.current_version, requestedTarget), + } + : previewResponse; return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(previewResponse), + body: JSON.stringify(response), }); } if (request.method() === "GET" && url.pathname.endsWith("/agent-update/jobs")) { @@ -190,6 +247,8 @@ export async function installRuntimeUpdateFixture( } if (request.method() === "POST" && url.pathname.endsWith(`/agent-update/${AGENT_NAME}`)) { postCount += 1; + const body = request.postDataJSON() as { target_version?: string } | null; + postTargets.push(body?.target_version ?? ""); return route.fulfill({ status: 202, contentType: "application/json", @@ -241,6 +300,8 @@ export async function installRuntimeUpdateFixture( }, postCount: () => postCount, previewCount: () => previewCount, + previewTargets: () => [...previewTargets], + postTargets: () => [...postTargets], async emit(action: string, payload: unknown) { await expect.poll(() => Boolean(socket)).toBe(true); await expect.poll(() => clientReady).toBe(true); @@ -274,6 +335,7 @@ export function updateJob(overrides: Partial = {}): UpdateJob { status: "updating", current_version: "0.62.0", target_version: "0.63.0", + operation: "update", output: "Downloading @agentclientprotocol/claude-agent-acp…\n", started_at: NOW, ...overrides, diff --git a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts index 6724a04c8a..7037f0b921 100644 --- a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts +++ b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts @@ -33,6 +33,14 @@ test.describe("managed agent runtime updates", () => { await trigger.click(); const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); await expect(dialog).toBeVisible(); + const dialogBox = await dialog.boundingBox(); + expect(dialogBox).not.toBeNull(); + expect(dialogBox!.width).toBeGreaterThanOrEqual(640); + const selectorBox = await dialog + .getByTestId(`agent-update-version-${runtime.agentName}`) + .boundingBox(); + expect(selectorBox).not.toBeNull(); + expect(selectorBox!.height).toBeLessThanOrEqual(40); await expect(dialog).toContainText("0.62.0 → 0.63.0"); await expect(dialog).toContainText( 'npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e ""', @@ -51,6 +59,7 @@ test.describe("managed agent runtime updates", () => { await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click(); expect(runtime.postCount()).toBe(1); + expect(runtime.postTargets()).toEqual(["0.63.0"]); await runtime.emitUpdate(updateJob()); await runtime.emitOutput("Installed @agentclientprotocol/claude-agent-acp@0.63.0\n"); @@ -115,6 +124,7 @@ test.describe("managed agent runtime updates", () => { package: "@agentclientprotocol/claude-agent-acp", current_version: "0.64.0", target_version: "0.64.0", + operation: "up_to_date", command: ["npm", "exec"], command_string: "npm exec", }, @@ -124,8 +134,12 @@ test.describe("managed agent runtime updates", () => { await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); - await expect(dialog.getByText("0.64.0", { exact: true })).toBeVisible(); - await expect(dialog.getByText("Up to date", { exact: true })).toBeVisible(); + await expect( + dialog.getByTestId(`agent-update-version-summary-${runtime.agentName}`).getByText("0.64.0", { + exact: true, + }), + ).toBeVisible(); + await expect(dialog.getByRole("status").filter({ hasText: "Up to date" })).toBeVisible(); await expect(dialog).not.toContainText("0.64.0 → 0.64.0"); await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toBeDisabled(); expect(runtime.postCount()).toBe(0); @@ -134,10 +148,146 @@ test.describe("managed agent runtime updates", () => { }); }); - test("uses the job versions for a stale retry decision", async ({ testPage }) => { + test("keeps repair distinct when observed and target versions match", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage, { + previewResponse: { + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + current_version: "0.64.0", + target_version: "0.64.0", + operation: "repair", + command: ["npm", "exec"], + command_string: "npm exec", + }, + }); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + const summary = dialog.getByTestId(`agent-update-version-summary-${runtime.agentName}`); + await expect(summary).toContainText("Repair runtime"); + await expect(summary).toContainText("0.64.0 → 0.64.0"); + await expect(summary.getByText("Up to date", { exact: true })).toHaveCount(0); + const confirm = testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`); + await expect(confirm).toBeEnabled(); + await confirm.click(); + await runtime.emitUpdate( + updateJob({ + status: "succeeded", + operation: "repair", + current_version: "0.64.0", + target_version: "0.64.0", + finished_at: "2026-07-26T12:01:00.000Z", + }), + ); + await expect(dialog.getByTestId(`agent-update-result-${runtime.agentName}`)).toContainText( + "Runtime updated successfully", + ); + }); + + test("selects an older stable version for rollback", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0"); + + await expect(dialog).toContainText("0.62.0 → 0.61.0"); + await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText( + "Roll back runtime", + ); + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click(); + expect(runtime.postTargets()).toEqual(["0.61.0"]); + expect(runtime.previewTargets()).toEqual(["", "0.61.0"]); + }); + + test("keeps the dialog content visible while changing versions", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage, { previewDelayMs: 500 }); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + const body = dialog.getByTestId(`agent-update-dialog-body-${runtime.agentName}`); + + await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0"); + + await expect( + dialog.getByTestId(`agent-update-version-loading-${runtime.agentName}`), + ).toBeVisible(); + await expect(body).toContainText("Active sessions keep running"); + await expect(body).toContainText("Command that will run"); + await expect(dialog.getByTestId(`agent-update-version-${runtime.agentName}`)).toBeDisabled(); + await expect( + dialog.getByTestId(`agent-update-version-loading-${runtime.agentName}`), + ).toBeHidden(); + }); + + test("limits the version selector to the ten newest options", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + runtime.setPreviewResponse({ + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + current_version: "0.62.0", + target_version: "0.74.0", + operation: "update", + active_version: "0.62.0", + available_versions: Array.from({ length: 12 }, (_, index) => ({ + version: `0.${74 - index}.0`, + latest: index === 0, + })), + command: ["npm", "exec"], + command_string: "npm exec", + }); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const options = testPage + .getByTestId(`agent-update-version-${runtime.agentName}`) + .locator("option"); + + await expect(options).toHaveCount(10); + expect( + await options.evaluateAll((elements) => + elements.map((element) => (element as HTMLOptionElement).value), + ), + ).toEqual([ + "0.74.0", + "0.73.0", + "0.72.0", + "0.71.0", + "0.70.0", + "0.69.0", + "0.68.0", + "0.67.0", + "0.66.0", + "0.65.0", + ]); + }); + + test("retries a failed preview for the selected target", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage, { + previewFailures: ["0.61.0"], + }); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0"); + + await expect(dialog.getByRole("alert")).toContainText("preview temporarily unavailable"); + await dialog.getByRole("button", { name: "Retry version check" }).click(); + await expect(dialog).toContainText("0.62.0 → 0.61.0"); + expect(runtime.previewTargets()).toEqual(["", "0.61.0", "0.61.0"]); + }); + + test("uses the job operation for a stale retry decision", async ({ testPage }) => { const runtime = await installRuntimeUpdateFixture(testPage, { postResponse: updateJob({ status: "failed", + operation: "up_to_date", + active_version: "0.63.0", current_version: "0.63.0", target_version: "0.63.0", error: "The package registry is unavailable", @@ -151,8 +301,12 @@ test.describe("managed agent runtime updates", () => { const retryUpdate = testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`); await retryUpdate.click(); - await expect(dialog.getByText("0.63.0", { exact: true })).toBeVisible(); - await expect(dialog.getByText("Up to date", { exact: true })).toBeVisible(); + await expect( + dialog.getByTestId(`agent-update-version-summary-${runtime.agentName}`).getByText("0.63.0", { + exact: true, + }), + ).toBeVisible(); + await expect(dialog.getByRole("status").filter({ hasText: "Up to date" })).toBeVisible(); await expect(retryUpdate).toHaveText("Retry update"); await expect(retryUpdate).toBeDisabled(); expect(runtime.postCount()).toBe(1); @@ -180,4 +334,60 @@ test.describe("managed agent runtime updates", () => { await retryUpdate.click(); expect(runtime.postCount()).toBe(2); }); + + test("shows the newly activated version after a successful job", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click(); + await runtime.emitUpdate( + updateJob({ + status: "succeeded", + current_version: "0.63.0", + active_version: "0.63.0", + target_version: "0.63.0", + finished_at: "2026-07-26T12:01:00.000Z", + }), + ); + + await expect(dialog).toContainText("Active version: 0.63.0"); + const successOptionText = await dialog + .getByTestId(`agent-update-version-${runtime.agentName}`) + .locator("option") + .allTextContents(); + expect( + successOptionText.some((text) => text.includes("0.63.0") && text.includes("active")), + ).toBe(true); + }); + + test("keeps the previous active version after a failed activation", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click(); + await runtime.emitUpdate( + updateJob({ + status: "failed", + active_version: "0.62.0", + error: "candidate ACP probe failed", + finished_at: "2026-07-26T12:01:00.000Z", + }), + ); + + await expect(dialog).toContainText("Active version: 0.62.0"); + const failedOptionText = await dialog + .getByTestId(`agent-update-version-${runtime.agentName}`) + .locator("option") + .allTextContents(); + expect( + failedOptionText.some((text) => text.includes("0.62.0") && text.includes("active")), + ).toBe(true); + expect( + failedOptionText.some((text) => text.includes("0.63.0") && text.includes("active")), + ).toBe(false); + }); }); diff --git a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts index 3005c2aa1f..6fea1b4ece 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts @@ -26,6 +26,7 @@ test.describe("managed agent runtime updates on mobile", () => { }); await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).tap(); expect(runtime.postCount()).toBe(1); + expect(runtime.postTargets()).toEqual(["0.63.0"]); await runtime.emitUpdate( updateJob({ @@ -60,6 +61,7 @@ test.describe("managed agent runtime updates on mobile", () => { package: "@agentclientprotocol/claude-agent-acp", current_version: "0.64.0", target_version: "0.64.0", + operation: "up_to_date", command: ["npm", "exec"], command_string: "npm exec", }, @@ -69,8 +71,12 @@ test.describe("managed agent runtime updates on mobile", () => { await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).tap(); const drawer = testPage.getByTestId(`agent-update-drawer-${runtime.agentName}`); - await expect(drawer.getByText("0.64.0", { exact: true })).toBeVisible(); - await expect(drawer.getByText("Up to date", { exact: true })).toBeVisible(); + await expect( + drawer.getByTestId(`agent-update-version-summary-${runtime.agentName}`).getByText("0.64.0", { + exact: true, + }), + ).toBeVisible(); + await expect(drawer.getByRole("status").filter({ hasText: "Up to date" })).toBeVisible(); await expect(drawer).not.toContainText("0.64.0 → 0.64.0"); await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toBeDisabled(); expect(runtime.postCount()).toBe(0); @@ -79,6 +85,27 @@ test.describe("managed agent runtime updates on mobile", () => { }); }); + test("selects an older stable version for rollback in the drawer", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).tap(); + const drawer = testPage.getByTestId(`agent-update-drawer-${runtime.agentName}`); + const selector = drawer.getByTestId(`agent-update-version-${runtime.agentName}`); + const selectorBox = await selector.boundingBox(); + expect(selectorBox).not.toBeNull(); + expect(selectorBox!.height).toBeGreaterThanOrEqual(44); + await selector.selectOption("0.61.0"); + + await expect(drawer).toContainText("0.62.0 → 0.61.0"); + await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText( + "Roll back runtime", + ); + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).tap(); + expect(runtime.postTargets()).toEqual(["0.61.0"]); + expect(runtime.previewTargets()).toEqual(["", "0.61.0"]); + }); + test("keeps retry available after an update job fails", async ({ testPage }) => { const runtime = await installRuntimeUpdateFixture(testPage); diff --git a/apps/web/hooks/domains/settings/use-agent-runtime-updates.test.tsx b/apps/web/hooks/domains/settings/use-agent-runtime-updates.test.tsx index 1c408bca64..0e2d85f21d 100644 --- a/apps/web/hooks/domains/settings/use-agent-runtime-updates.test.tsx +++ b/apps/web/hooks/domains/settings/use-agent-runtime-updates.test.tsx @@ -69,7 +69,7 @@ describe("useAgentRuntimeUpdates", () => { const { result } = render(); - await expect(result.current.startUpdate(AGENT)).rejects.toThrow( + await expect(result.current.startUpdate(AGENT, "1.1.0")).rejects.toThrow( "Agent installation is already in progress.", ); expect(getInstallJobMock).toHaveBeenCalledWith("job-1", { cache: "no-store" }); @@ -83,7 +83,9 @@ describe("useAgentRuntimeUpdates", () => { const { result } = render(); - await expect(result.current.startUpdate(AGENT)).resolves.toMatchObject({ job_id: "job-1" }); + await expect(result.current.startUpdate(AGENT, "1.1.0")).resolves.toMatchObject({ + job_id: "job-1", + }); expect(getAgentUpdateJobMock).toHaveBeenCalledWith("job-1", { cache: "no-store" }); }); @@ -92,7 +94,7 @@ describe("useAgentRuntimeUpdates", () => { const { result } = render(); - await expect(result.current.startUpdate(AGENT)).rejects.toThrow("network down"); + await expect(result.current.startUpdate(AGENT, "1.1.0")).rejects.toThrow("network down"); expect(getInstallJobMock).not.toHaveBeenCalled(); expect(getAgentUpdateJobMock).not.toHaveBeenCalled(); }); @@ -102,6 +104,8 @@ describe("useAgentRuntimeUpdates", () => { const { result } = render(); - await expect(result.current.startUpdate(AGENT)).resolves.toMatchObject({ status: "queued" }); + await expect(result.current.startUpdate(AGENT, "1.1.0")).resolves.toMatchObject({ + status: "queued", + }); }); }); diff --git a/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts b/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts index af8db78550..7a523ab2e2 100644 --- a/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts +++ b/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts @@ -52,9 +52,9 @@ export function useAgentRuntimeUpdates() { ); const startUpdate = useCallback( - async (agentName: string) => { + async (agentName: string, targetVersion: string) => { try { - const job = await updateAgent(agentName); + const job = await updateAgent(agentName, targetVersion); store.getState().upsertAgentUpdateJob(job); return job; } catch (error) { @@ -69,7 +69,8 @@ export function useAgentRuntimeUpdates() { ); const previewUpdate = useCallback( - (agentName: string) => previewAgentUpdate(agentName, { cache: "no-store" }), + (agentName: string, targetVersion?: string) => + previewAgentUpdate(agentName, targetVersion, { cache: "no-store" }), [], ); diff --git a/apps/web/lib/agent-runtime-update.test.ts b/apps/web/lib/agent-runtime-update.test.ts index aef375c17c..a2dd5299a1 100644 --- a/apps/web/lib/agent-runtime-update.test.ts +++ b/apps/web/lib/agent-runtime-update.test.ts @@ -1,7 +1,25 @@ import { afterEach, describe, expect, it } from "vitest"; import i18n from "i18next"; import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api"; -import { canApproveAgentRuntimeUpdate, resolveRuntimeVersionPair } from "./agent-runtime-update"; +import { + canApproveAgentRuntimeUpdate, + latestRuntimeVersions, + resolveRuntimeActiveVersion, + resolveRuntimeOperation, + resolveRuntimeVersionPair, + runtimeOperationLabelKey, +} from "./agent-runtime-update"; + +describe("latestRuntimeVersions", () => { + it("limits the selector to the ten newest options", () => { + const versions = Array.from({ length: 12 }, (_, index) => ({ + version: `1.0.${12 - index}`, + latest: index === 0, + })); + + expect(latestRuntimeVersions(versions)).toEqual(versions.slice(0, 10)); + }); +}); const preview = (overrides: Partial = {}): AgentUpdatePreview => ({ agent_name: "claude-acp", @@ -46,6 +64,26 @@ describe("resolveRuntimeVersionPair", () => { }); }); +describe("resolveRuntimeActiveVersion", () => { + it("uses the successful job activation snapshot", () => { + expect( + resolveRuntimeActiveVersion( + preview({ active_version: "0.62.0" }), + job({ status: "succeeded", active_version: "0.63.0" }), + ), + ).toBe("0.63.0"); + }); + + it("keeps the preview selection when activation fails", () => { + expect( + resolveRuntimeActiveVersion( + preview({ active_version: "0.62.0" }), + job({ status: "failed", active_version: "0.62.0" }), + ), + ).toBe("0.62.0"); + }); +}); + describe("canApproveAgentRuntimeUpdate", () => { const ready = { preview: preview(), @@ -60,6 +98,7 @@ describe("canApproveAgentRuntimeUpdate", () => { it.each([ ["differing versions", ready, true], ["equal versions", { ...ready, preview: preview({ target_version: "0.62.0" }) }, false], + ["up-to-date operation", { ...ready, preview: preview({ operation: "up_to_date" }) }, false], ["missing current version", { ...ready, preview: preview({ current_version: "" }) }, false], ["missing target version", { ...ready, preview: preview({ target_version: "" }) }, false], ["loading preview", { ...ready, loading: true }, false], @@ -75,6 +114,26 @@ describe("canApproveAgentRuntimeUpdate", () => { ])("returns %s = %s", (_name, state, expected) => { expect(canApproveAgentRuntimeUpdate(state)).toBe(expected); }); + + it("allows a repair when the backend says the observed version is unknown", () => { + expect( + canApproveAgentRuntimeUpdate({ + ...ready, + preview: preview({ current_version: "", operation: "repair" }), + }), + ).toBe(true); + }); + + it("uses operation state instead of translated labels", () => { + const state = preview({ operation: "rollback" }); + expect(resolveRuntimeOperation(state)).toBe("rollback"); + expect( + resolveRuntimeOperation(preview({ operation: "update" }), job({ operation: "repair" })), + ).toBe("repair"); + expect(runtimeOperationLabelKey("rollback")).toBe("agents:rollBackRuntime"); + expect(runtimeOperationLabelKey("repair")).toBe("agents:repairRuntime"); + expect(runtimeOperationLabelKey("up_to_date")).toBe("agents:upToDateRuntime"); + }); }); /** diff --git a/apps/web/lib/agent-runtime-update.ts b/apps/web/lib/agent-runtime-update.ts index a0b083c734..393b40ccbf 100644 --- a/apps/web/lib/agent-runtime-update.ts +++ b/apps/web/lib/agent-runtime-update.ts @@ -1,6 +1,17 @@ -import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api"; +import type { + AgentUpdateJob, + AgentUpdateOperation, + AgentUpdatePreview, + AgentUpdateVersion, +} from "@/lib/api"; import { t } from "@/lib/i18n"; +const MAX_VISIBLE_RUNTIME_VERSIONS = 10; + +export function latestRuntimeVersions(versions: AgentUpdateVersion[]): AgentUpdateVersion[] { + return versions.slice(0, MAX_VISIBLE_RUNTIME_VERSIONS); +} + export type RuntimeVersionPair = { /** Display copy — localized, and therefore never compared. */ currentVersion: string; @@ -28,6 +39,34 @@ export function resolveRuntimeVersionPair( return { currentVersion, hasCurrentVersion: Boolean(reported), targetVersion, versionsMatch }; } +export function resolveRuntimeActiveVersion( + preview: AgentUpdatePreview, + job?: AgentUpdateJob, +): string | undefined { + return job?.active_version ?? preview.active_version; +} + +export function resolveRuntimeOperation( + preview: AgentUpdatePreview | null, + job?: AgentUpdateJob, +): AgentUpdateOperation | undefined { + return job?.operation ?? preview?.operation; +} + +export function runtimeOperationLabelKey(operation: AgentUpdateOperation | undefined): string { + switch (operation) { + case "rollback": + return "agents:rollBackRuntime"; + case "repair": + return "agents:repairRuntime"; + case "up_to_date": + return "agents:upToDateRuntime"; + case "update": + default: + return "agents:updateRuntime"; + } +} + export function canApproveAgentRuntimeUpdate({ preview, job, @@ -45,12 +84,16 @@ export function canApproveAgentRuntimeUpdate({ starting: boolean; installInFlight: boolean; }): boolean { - const { currentVersion, hasCurrentVersion, targetVersion } = resolveRuntimeVersionPair( + const { hasCurrentVersion, targetVersion, versionsMatch } = resolveRuntimeVersionPair( preview, job, ); + const operation = resolveRuntimeOperation(preview, job); + const operationAllowsApproval = operation + ? operation !== "up_to_date" + : hasCurrentVersion && !versionsMatch; return ( - Boolean(preview && hasCurrentVersion && targetVersion && currentVersion !== targetVersion) && + Boolean(preview && targetVersion && operationAllowsApproval) && !previewError && !loading && !updateInFlight && diff --git a/apps/web/lib/api/domains/agent-update-api.test.ts b/apps/web/lib/api/domains/agent-update-api.test.ts new file mode 100644 index 0000000000..07fd094a1c --- /dev/null +++ b/apps/web/lib/api/domains/agent-update-api.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), +})); + +import { previewAgentUpdate, updateAgent } from "./agent-update-api"; + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + agent_name: "opencode-acp", + package: "opencode-ai", + target_version: "1.18.5", + operation: "rollback", + available_versions: [], + command: [], + command_string: "", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("managed runtime update API", () => { + it("encodes an optional exact target in the preview query", async () => { + await previewAgentUpdate("opencode-acp", "1.18.5", { cache: "no-store" }); + + const [input, init] = fetchSpy.mock.calls[0] ?? []; + expect(String(input)).toBe( + "http://api.test/api/v1/agent-update/opencode-acp/preview?target_version=1.18.5", + ); + expect(init?.cache).toBe("no-store"); + }); + + it("sends only the exact target version in the update body", async () => { + await updateAgent("opencode-acp", "1.18.5"); + + const [, init] = fetchSpy.mock.calls[0] ?? []; + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ target_version: "1.18.5" })); + }); +}); diff --git a/apps/web/lib/api/domains/agent-update-api.ts b/apps/web/lib/api/domains/agent-update-api.ts index 24dd2df3c8..1760b138d9 100644 --- a/apps/web/lib/api/domains/agent-update-api.ts +++ b/apps/web/lib/api/domains/agent-update-api.ts @@ -8,11 +8,20 @@ export type AgentUpdateJobStatus = | "succeeded" | "failed"; +export type AgentUpdateOperation = "update" | "rollback" | "repair" | "up_to_date"; + +export type AgentUpdateVersion = { + version: string; + latest: boolean; +}; + export type AgentUpdateJob = { job_id: string; agent_name: string; status: AgentUpdateJobStatus; + operation?: AgentUpdateOperation; current_version?: string; + active_version?: string; target_version?: string; output?: string; error?: string; @@ -25,25 +34,38 @@ export type AgentUpdatePreview = { agent_name: string; package: string; current_version?: string; + active_version?: string; target_version: string; + operation?: AgentUpdateOperation; + available_versions?: AgentUpdateVersion[]; command: string[]; command_string: string; }; export async function previewAgentUpdate( agentName: string, + targetVersion?: string, options?: ApiRequestOptions, ): Promise { - return fetchJson(`/api/v1/agent-update/${agentName}/preview`, options); + const query = targetVersion ? `?${new URLSearchParams({ target_version: targetVersion })}` : ""; + return fetchJson( + `/api/v1/agent-update/${encodeURIComponent(agentName)}/preview${query}`, + options, + ); } export async function updateAgent( agentName: string, + targetVersion: string, options?: ApiRequestOptions, ): Promise { - return fetchJson(`/api/v1/agent-update/${agentName}`, { + return fetchJson(`/api/v1/agent-update/${encodeURIComponent(agentName)}`, { ...options, - init: { method: "POST", ...(options?.init ?? {}) }, + init: { + method: "POST", + body: JSON.stringify({ target_version: targetVersion }), + ...(options?.init ?? {}), + }, }); } diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index e9f21c85f7..88f53c7d61 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -217,6 +217,7 @@ export type RuntimeUpdate = { supported: boolean; package: string; current_version?: string; + active_version?: string; }; export type AvailableAgent = { diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index d16b6d20d5..ce0c846bba 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -181,6 +181,16 @@ "runtimeUpdatedRefreshFailed": "Runtime updated, but capabilities could not be refreshed: {{error}}", "runtimeUpdatedSuccess": "Runtime updated successfully.", "runtimeVersion": "Runtime version", + "activeRuntimeVersion": "Active version: {{version}}", + "activeRuntimeVersionMarker": "active", + "latestRuntimeVersion": "latest", + "runtimeVersionMarkerGroup": "({{markers}})", + "runtimeVersionOption": "{{version}} {{markers}}", + "selectRuntimeVersion": "Version to activate", + "updateRuntime": "Update runtime", + "rollBackRuntime": "Roll back runtime", + "repairRuntime": "Repair runtime", + "upToDateRuntime": "Up to date", "scanningForInstalledAgents": "Scanning for installed agents...", "searchModels": "Search models...", "searchModes": "Search modes...", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index f9b7b19369..bb32d12ab9 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -181,6 +181,16 @@ "runtimeUpdatedRefreshFailed": "Ŕũńţĩḿē ũƥďàţēď, ƀũţ ćàƥàƀĩĺĩţĩēś ćōũĺď ńōţ ƀē ŕēƒŕēśĥēď: {{error}}", "runtimeUpdatedSuccess": "Ŕũńţĩḿē ũƥďàţēď śũććēśśƒũĺĺŷ.", "runtimeVersion": "Ŕũńţĩḿē vēŕśĩōń", + "activeRuntimeVersion": "Àćţĩvē vēŕśĩōń: {{version}}", + "activeRuntimeVersionMarker": "àćţĩvē", + "latestRuntimeVersion": "ĺàţēśţ", + "runtimeVersionMarkerGroup": "({{markers}})", + "runtimeVersionOption": "{{version}} {{markers}}", + "selectRuntimeVersion": "Vēŕśĩōń ţō àćţĩvàţē", + "updateRuntime": "Ũƥďàţē ŕũńţĩḿē", + "rollBackRuntime": "Ŕōĺĺ ƀàćķ ŕũńţĩḿē", + "repairRuntime": "Ŕēƥàĩŕ ŕũńţĩḿē", + "upToDateRuntime": "Ũƥ ţō ďàţē", "scanningForInstalledAgents": "Śćàńńĩńĝ ƒōŕ ĩńśţàĺĺēď àĝēńţś...", "searchModels": "Śēàŕćĥ ḿōďēĺś...", "searchModes": "Śēàŕćĥ ḿōďēś...", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index a26ebdaf91..fbc016a938 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -173,6 +173,16 @@ "runtimeUpdatedRefreshFailed": "O runtime foi atualizado, mas não foi possível atualizar as capacidades: {{error}}", "runtimeUpdatedSuccess": "Runtime atualizado com êxito.", "runtimeVersion": "Versão do runtime", + "activeRuntimeVersion": "Versão ativa: {{version}}", + "activeRuntimeVersionMarker": "ativa", + "latestRuntimeVersion": "mais recente", + "runtimeVersionMarkerGroup": "({{markers}})", + "runtimeVersionOption": "{{version}} {{markers}}", + "selectRuntimeVersion": "Versão a ativar", + "updateRuntime": "Atualizar runtime", + "rollBackRuntime": "Reverter runtime", + "repairRuntime": "Reparar runtime", + "upToDateRuntime": "Atualizado", "scanningForInstalledAgents": "À procura de agentes instalados...", "searchModels": "Pesquisar modelos...", "searchModes": "Pesquisar modos...", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index a5be3fc0b5..8b27314961 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -181,6 +181,16 @@ "runtimeUpdatedRefreshFailed": "运行时已更新,但无法刷新能力:{{error}}", "runtimeUpdatedSuccess": "运行时更新成功。", "runtimeVersion": "运行时版本", + "activeRuntimeVersion": "活动版本:{{version}}", + "activeRuntimeVersionMarker": "活动", + "latestRuntimeVersion": "最新", + "runtimeVersionMarkerGroup": "({{markers}})", + "runtimeVersionOption": "{{version}} {{markers}}", + "selectRuntimeVersion": "要激活的版本", + "updateRuntime": "更新运行时", + "rollBackRuntime": "回滚运行时", + "repairRuntime": "修复运行时", + "upToDateRuntime": "已是最新", "scanningForInstalledAgents": "正在扫描已安装的代理…", "searchModels": "搜索模型…", "searchModes": "搜索模式…", diff --git a/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md b/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md index d971259db9..b125865965 100644 --- a/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md +++ b/docs/decisions/2026-07-26-user-managed-agent-runtime-updates.md @@ -1,6 +1,7 @@ # ADR-2026-07-26-user-managed-agent-runtime-updates: User-Managed Agent Runtime Updates -**Status:** accepted +**Status:** superseded by +[ADR-2026-08-12-validated-managed-runtime-version-selection](2026-08-12-validated-managed-runtime-version-selection.md) **Date:** 2026-07-26 **Area:** backend, frontend, protocol diff --git a/docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md b/docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md new file mode 100644 index 0000000000..f5e2ddbb0e --- /dev/null +++ b/docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md @@ -0,0 +1,87 @@ +# ADR-2026-08-12-validated-managed-runtime-version-selection: Validate and Persist Managed Runtime Version Selection + +**Status:** accepted +**Date:** 2026-08-12 +**Area:** backend, frontend, protocol +**Supersedes:** +[ADR-2026-07-26-user-managed-agent-runtime-updates](2026-07-26-user-managed-agent-runtime-updates.md) + +## Context + +Kandev originally invoked managed npm ACP runtimes by an unversioned package +name. The Settings action could prepare the current npm `latest` target, but it +could not select an older version. This made recovery impossible when an npm +release was only partly published, platform artifacts were missing, or the +latest runtime failed ACP initialization. Clearing the matching `_npx` tree did +not help because the next unversioned invocation selected the same broken +release. + +The recovery surface must not turn into an arbitrary package installer. Package +identity, registry location, and ACP arguments are trusted Kandev metadata. +Users need to choose among valid versions of that package, validate the +candidate, and keep a known-good version across Kandev restarts. + +## Decision + +Kandev lets an operator select an exact stable version for a built-in managed +npm runtime. The backend obtains the version catalogue from npm metadata, +validates strict stable SemVer values, and accepts only a version published for +the trusted package. Callers cannot provide a package name, registry URL, tag, +prerelease, command argument, or shell text. + +Kandev prepares the candidate under its exact `package@version` npm execution +key and probes it through ACP before activation. Candidate preparation or probe +failure leaves the existing active selection and capability catalogue +unchanged. Kandev persists the trusted package identity and candidate as the +install-wide active version only after a successful ACP probe, then publishes +the candidate capabilities. +This ordering makes the persisted active version the last known good version; +automatic silent rollback is unnecessary. + +Future standalone host probes, utility calls, and local agent sessions resolve +the persisted exact version. Existing sessions keep their current process. +SSH, container, and other remote runtimes do not inherit the host selection and +continue to resolve their own runtime. When no selection record exists, Kandev +retains the legacy unversioned host behavior until the first candidate is +successfully activated. + +The selection is install-wide and per built-in agent. A saved selection applies +only while its stored package identity matches the agent's current trusted +package metadata; a package change starts with no selection and requires fresh +validation. The selection survives backend and browser restarts independently +of npm's best-effort cache. If reading a saved +selection fails, Kandev fails the new host-managed launch or probe visibly +instead of bypassing the selection with an unversioned package. If npm evicts +the selected version's cache, the exact version is prepared again when needed. + +## Consequences + +Operators can recover from a broken latest release in Settings without shell +access or a Kandev restart. Runtime changes become attributable: after the +first activation, host launches use the selected exact version until another +candidate passes validation. + +The backend now owns a small durable selection record and must route it through +every host-local managed-runtime command path. Version catalogue lookup remains +dependent on registry availability, and cached versions are not a Kandev-owned +artifact inventory. A version that has disappeared from the registry can still +fail to reinstall after npm cache eviction. + +The ACP probe is the activation boundary, not an assertion that every provider, +model, or future prompt will succeed. Authentication-required or failed probes +do not activate a candidate because Kandev cannot validate and publish its +capabilities. + +## Alternatives Considered + +- Latest-only repair was rejected because cache replacement selects the same + broken release and gives the operator no recovery path. +- Free-form package specs or versions were rejected because they would let a + Settings request execute an arbitrary npm package. +- Automatic fallback after a failed launch was rejected because it hides the + version actually used and can make separate launches behave differently. +- A Kandev-owned package directory and lockfile were rejected for this + iteration. Exact npm package specs and version-specific execution keys provide + transactional selection without reimplementing package storage. +- Applying the host selection to remote executors was rejected because each + remote runtime has separate platform, cache, credentials, and ownership. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 7a17d3bf3c..52d3ee9a17 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -83,7 +83,8 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-22-runtime-mutable-task-workspace-sources | [Runtime-Mutable Task Workspace Sources](2026-07-22-runtime-mutable-task-workspace-sources.md) | accepted (amended by 2026-07-27) | backend, frontend, protocol | 2026-07-22 | | 2026-07-24-opt-in-authentication | [Opt-in Authentication and Per-User Workspace Scoping](2026-07-24-opt-in-authentication.md) | accepted | backend, frontend, protocol, security | 2026-07-24 | | 2026-07-24-semantic-notification-events | [Semantic notification events come from domain occurrences](2026-07-24-semantic-notification-events.md) | accepted | backend, frontend, desktop | 2026-07-24 | -| 2026-07-26-user-managed-agent-runtime-updates | [User-Managed Agent Runtime Updates](2026-07-26-user-managed-agent-runtime-updates.md) | accepted | backend, frontend, protocol | 2026-07-26 | +| 2026-07-26-user-managed-agent-runtime-updates | [User-Managed Agent Runtime Updates](2026-07-26-user-managed-agent-runtime-updates.md) | superseded by 2026-08-12-validated-managed-runtime-version-selection | backend, frontend, protocol | 2026-07-26 | +| 2026-08-12-validated-managed-runtime-version-selection | [Validate and Persist Managed Runtime Version Selection](2026-08-12-validated-managed-runtime-version-selection.md) | accepted | backend, frontend, protocol | 2026-08-12 | | 2026-07-26-single-session-model-switching | [Single-Session Model Switching](2026-07-26-single-session-model-switching.md) | accepted (amended 2026-07-27) | workflow, infra | 2026-07-26 | | 2026-07-27-task-git-credential-policy | [Separate GitHub Automation From Task Git Credential Policy](2026-07-27-task-git-credential-policy.md) | accepted | backend, frontend, security | 2026-07-27 | | 2026-07-27-spa-failure-containment-and-deployment-recovery | [SPA Failure Containment and Deployment Recovery](2026-07-27-spa-failure-containment-and-deployment-recovery.md) | accepted | frontend | 2026-07-27 | diff --git a/docs/plans/managed-runtime-recovery/plan.md b/docs/plans/managed-runtime-recovery/plan.md new file mode 100644 index 0000000000..fbbcae5c36 --- /dev/null +++ b/docs/plans/managed-runtime-recovery/plan.md @@ -0,0 +1,202 @@ +--- +spec: docs/specs/agents/runtime-updates.md +decision: docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md +created: 2026-08-12 +updated: 2026-08-12 +status: implemented +--- + +# Implementation Plan: Managed Runtime Version Recovery + +## Overview + +Turn the managed-runtime update dialog into a safe version recovery surface. +The backend will enumerate stable versions for each trusted built-in npm +package, stage and ACP-probe an exact candidate, and persist it only after +validation. The selected exact version will then drive host-local probes, +utility calls, and future standalone sessions. Desktop and mobile Settings will +support update, rollback, repair, and up-to-date outcomes without exposing +free-form package execution. + +## Confirmed root cause + +OpenCode's unversioned command selected `opencode-ai@1.18.16`. The wrapper +package existed, but its Linux x64 binary packages returned npm `E404`, so the +process exited before its first ACP response. The failed probe had no runtime +version, and the current update UI disabled approval. Rebuilding the unversioned +`_npx` tree selected the same incomplete release, so cache repair and a Kandev +restart could not recover the runtime. + +The reproducible product failure is therefore not only an unknown-version UI +gate. It is the absence of an exact, validated, durable version choice. + +## Architecture + +### Trusted version foundation + +- Add `internal/agent/managedruntime` as the owner of active-version records, + stable SemVer validation using the existing Masterminds SemVer dependency, + and the small selection-store interface. +- Persist one package-and-version system setting per built-in agent, keyed by + trusted agent ID, so parallel changes for separate agents cannot lose each + other through a shared JSON map and a future package-identity change cannot + inherit an unrelated old version. +- Extend `ManagedNPMRuntimeSpec` with exact `package@version` launch, + preparation, and deterministic execution-key helpers. Package identity and + ACP arguments remain built-in metadata. +- Add an internal managed-runtime version option to `agents.CommandOptions`. + The five managed agents use it only for their ACP `BuildCommand`; an empty + option preserves current unversioned behavior. + +### Host-local command routing + +- Wire one active-version store through the backend composition root to the + lifecycle manager, host utility manager, and settings controller. +- Resolve the active version immediately before standalone command building. + Propagate context into the launch command builder where needed. Do not set an + override for SSH or container runtimes, and preserve supported native-binary + preference. +- Centralize host-utility inference command resolution so boot probes, manual + refresh, model configuration, profile prompts, and sessionless prompts all + use the same exact active version. +- Split candidate probing from capability publication. A candidate probe must + return capabilities without replacing the live cache; the update job + publishes them only after persistence succeeds. +- Treat saved-selection read errors as launch or probe errors. Do not silently + fall back to an unversioned runtime when an active record exists but cannot be + read. + +### Version catalogue and transactional activation + +- Resolve npm `versions` and `dist-tags.latest` for the trusted package. Parse + strict stable SemVer, deduplicate, sort descending, and return the newest 50 + plus active/current versions. +- Add optional `target_version` to preview and a required JSON target to POST. + Re-resolve metadata after approval and reject unpublished, prerelease, tag, + malformed, or package-like input. +- Return backend-derived `operation` (`update`, `rollback`, `repair`, or + `up_to_date`) and the exact preparation command. +- Stage the target under the deterministic key for `package@version`. On the + first preparation failure, invalidate only that exact tree and retry once. +- Probe the candidate without mutating live capabilities. On success, save the + active version and then publish capabilities. Every earlier failure leaves + the prior active version and catalogue untouched. +- Keep existing process-local job retention, maintenance exclusion, bounded + output, and WebSocket event names. + +### Responsive Settings experience + +- Keep the agent-card update icon as the desktop and mobile entry point. +- Add a shared version selector and render backend operation state. Desktop + remains a dialog; phone remains the existing bottom drawer, with no nested + drawer. +- Use **Update runtime**, **Roll back runtime**, **Repair runtime**, and **Up to + date** from structural operation state. A failed job can retry its exact + target or the operator can select another version. +- Keep one scroll owner, the safe-area footer, 44 px controls, keyboard access, + and non-color latest/active markers. Localize all copy in every agents + catalogue. + +## API and data changes + +- `RuntimeUpdateDTO`: add optional `active_version` while retaining observed + `current_version`. +- `AgentUpdatePreviewDTO`: add `active_version`, `operation`, and + `available_versions`; accept an optional selected target query. +- `POST /api/v1/agent-update/:agentName`: require + `{ "target_version": "X.Y.Z" }`. +- `AgentUpdateJobDTO`: add `operation` and `active_version`; update the active + version only at successful activation. +- Existing job statuses and WebSocket action names remain stable. + +## Test strategy + +- TDD the persistence, exact command generation, version filtering, operation + classification, and targeted cache key before controller orchestration. +- Prove every normal host-utility command path and standalone lifecycle launch + uses the active exact version, while SSH/container and no-selection paths do + not. +- Prove candidate preparation, one exact-tree retry, failed-probe preservation, + persistence-before-publication, restart reads, and request validation in + backend controller/handler tests. +- TDD frontend API payloads, stale preview cancellation, selection changes, + operation labels, disabled states, localization, and failed-target retry. +- Add desktop and `mobile-chrome` E2E coverage for rollback from an unknown + OpenCode version and viewport containment. + +## Mobile design contract + +- Entry point: the existing 44 px agent-card runtime action. +- Presentation: existing `Dialog` on desktop and existing `Drawer` on phone. +- Hierarchy: active/current summary, version selector, operation explanation, + trusted command, progress/output, fixed action footer. +- Scrolling: shared body is the only vertical scroll owner; long output wraps + or scrolls internally without horizontal page overflow. +- Parity: desktop and mobile can select the same versions, start the same + operation, observe progress, and recover from the same errors. + +## Verification results + +- Backend focused tests: 2,321 passed across managedruntime, agents, + hostutility, lifecycle, settings controller/handlers, and backendapp. +- Backend lint: `rtk make -C apps/backend lint` — 0 issues. +- Frontend focused Vitest suite: 31 passed across 4 files. +- Frontend typecheck and lint — passed. +- i18n check and ratchet — passed; the checker retained its existing advisory + real-locale parity warnings. +- Desktop Playwright: 6 passed. Mobile Playwright: 4 passed. +- Public documentation tests: 60 passed. Public validator: 41 pages validated. +- Fresh desktop/mobile PR screenshots were captured from synthetic fixtures, + visually inspected, compressed, and validated against their manifest. + +## Post-implementation fixup verification + +- Preserved the selected target across failed preview retries and cleared stale + failed-job state when selecting a new target. +- Kept repair distinct from up-to-date state, reused active jobs before npm + metadata lookup, skipped redundant exact-target metadata fetches, propagated + selection-store errors, and shared npm cache-root validation. +- Backend fixup tests: `rtk go test ./internal/agent/settings/controller + ./internal/agent/runtime/lifecycle` — 1,705 passed. +- Frontend fixup tests: 27 passed in the runtime-operation and dialog-state + suites. +- Fixup E2E: desktop 8 passed; mobile 4 passed. + +## Implementation waves + +Wave 1: + +- [x] [Task 01: Build exact-version foundation](task-01-build-exact-version-foundation.md) — done + +Wave 2, after Task 01: + +- [x] [Task 02: Route active host runtime](task-02-route-active-host-runtime.md) — done + +Wave 3, after Tasks 01 and 02: + +- [x] [Task 03: Activate validated runtime versions](task-03-activate-validated-runtime-versions.md) — done + +Wave 4, after Task 03: + +- [x] [Task 04: Add responsive version recovery UI](task-04-add-responsive-version-recovery-ui.md) — done + +Wave 5, after Task 04: + +- [x] [Task 05: Prove rollback on desktop and mobile](task-05-prove-rollback-desktop-mobile.md) — done +- [x] [Task 06: Document runtime version recovery](task-06-document-runtime-version-recovery.md) — done + +Execution remains in the primary conversation unless the user explicitly +authorizes implementation subagents. + +## Risks and boundaries + +- An ACP probe proves initialization and advertised capabilities, not every + model/provider interaction. Authentication-required probes do not activate. +- Exact npm caches remain best-effort. Persisted selection prevents version + drift but does not guarantee offline artifact retention. +- Avoid a second command-resolution path. Every host utility caller must use + the shared resolver, including model configuration and profile prompts. +- Do not let request input reach package names, registry URLs, ACP arguments, + or shell parsing. +- Do not change remote/container runtime ownership, active sessions, native-only + installers, automatic update scheduling, or job persistence. diff --git a/docs/plans/managed-runtime-recovery/task-01-build-exact-version-foundation.md b/docs/plans/managed-runtime-recovery/task-01-build-exact-version-foundation.md new file mode 100644 index 0000000000..ab3934c27c --- /dev/null +++ b/docs/plans/managed-runtime-recovery/task-01-build-exact-version-foundation.md @@ -0,0 +1,79 @@ +--- +id: "01-build-exact-version-foundation" +title: "Build exact-version foundation" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 01: Build exact-version foundation + +## Acceptance + +- A typed install-wide store reads and writes one trusted package and active + exact version per built-in agent through `internal/system/settings`; a + package mismatch cannot apply an old version to replacement metadata. +- Strict stable SemVer parsing rejects prereleases, tags, package specs, and + malformed values and provides deterministic descending ordering. +- `ManagedNPMRuntimeSpec` builds launch, preparation, and execution-cache keys + from exact `package@version` while preserving the empty-version legacy path. +- Each managed agent honors an internal exact-version command option for ACP + commands; callers still cannot choose package identity or ACP arguments. + +## TDD sequence + +1. Add failing tests for selection-store round trips, missing values, invalid + stored values, and separate-agent writes. +2. Add failing table tests for stable version validation and ordering. +3. Add failing exact command/key tests, including scoped npm packages. +4. Implement the typed store and helpers, then minimally update the five + managed agent command builders. +5. Run focused packages, then backend lint for touched code. + +## Files likely touched + +- `apps/backend/internal/agent/managedruntime/selection.go` +- `apps/backend/internal/agent/managedruntime/selection_test.go` +- `apps/backend/internal/agent/managedruntime/versions.go` +- `apps/backend/internal/agent/managedruntime/versions_test.go` +- `apps/backend/internal/agent/agents/agent.go` +- `apps/backend/internal/agent/agents/managed_npm_runtime.go` +- `apps/backend/internal/agent/agents/managed_npm_runtime_test.go` +- `apps/backend/internal/agent/agents/{claude_acp,codex_acp,opencode_acp,copilot_acp,gemini}.go` +- `apps/backend/go.mod` +- `apps/backend/go.sum` + +## Verification + +```bash +make -C apps/backend test ARGS='./internal/agent/managedruntime ./internal/agent/agents' +make -C apps/backend lint +``` + +## Risks + +- Use a key per trusted agent ID instead of a shared read-modify-write map. +- Do not use the update subsystem's Kandev-specific loose SemVer parser. +- Do not add a user-provided package or command field. + +## Output contract + +Record RED/GREEN evidence, files, checks, and risks in Results. Update this task +and `plan.md` status in the same implementation conversation. + +## Results + +RED covered the new selection, strict-version, exact-command, scoped-package, +and per-agent isolation contracts before their implementations were complete. +GREEN verification: + +- `rtk go test ./internal/agent/managedruntime ./internal/agent/agents ./internal/agent/hostutility ./internal/agent/runtime/lifecycle ./internal/agent/settings/controller ./internal/agent/settings/handlers ./internal/backendapp` — 2,321 tests passed across 7 packages. +- `rtk make -C apps/backend lint` — 0 issues. + +The implementation adds the typed install-wide selection store, strict stable +catalogue helpers, exact `package@version` command/key builders, and exact ACP +command support for all five managed npm agents. The remaining risk is the +normal npm availability boundary: persisted selection prevents version drift, +but does not promise offline artifact retention. diff --git a/docs/plans/managed-runtime-recovery/task-02-route-active-host-runtime.md b/docs/plans/managed-runtime-recovery/task-02-route-active-host-runtime.md new file mode 100644 index 0000000000..1176406e68 --- /dev/null +++ b/docs/plans/managed-runtime-recovery/task-02-route-active-host-runtime.md @@ -0,0 +1,84 @@ +--- +id: "02-route-active-host-runtime" +title: "Route active host runtime" +status: done +wave: 2 +depends_on: ["01-build-exact-version-foundation"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 02: Route active host runtime + +## Acceptance + +- Standalone managed-agent launches use the persisted exact active version; + no-selection launches preserve legacy unversioned behavior. +- Boot/manual probes, model-configuration resolution, profile prompts, and + sessionless utility prompts use the same host command resolver. +- SSH and container launches receive no host version override; supported native + binary preference remains unchanged. +- A saved-selection read failure fails the new host launch/probe visibly rather + than silently selecting npm latest. +- Candidate probes remain able to supply a trusted exact command override + without changing the active resolver. + +## TDD sequence + +1. Add lifecycle tests that expect exact standalone argv and unchanged SSH, + container, native, and no-selection argv. +2. Add host-utility tests for every call site currently reading + `InferenceConfig().Command` directly. +3. Add the active-version provider to lifecycle and host utility, make launch + command building context-aware where required, and centralize utility + command resolution. +4. Wire one store instance from backend startup into lifecycle, host utility, + and the settings controller; add composition tests. +5. Run focused packages and backend lint. + +## Files likely touched + +- `apps/backend/internal/agent/runtime/lifecycle/manager.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go` +- `apps/backend/internal/agent/runtime/lifecycle/*_test.go` +- `apps/backend/internal/agent/hostutility/manager.go` +- `apps/backend/internal/agent/hostutility/public.go` +- `apps/backend/internal/agent/hostutility/manager_test.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/agents.go` +- `apps/backend/internal/backendapp/*_test.go` + +## Verification + +```bash +make -C apps/backend test ARGS='./internal/agent/runtime/lifecycle ./internal/agent/hostutility ./internal/backendapp' +make -C apps/backend lint +``` + +## Risks + +- Cover restart/fresh-command rebuilding as well as initial launches. +- Do not rewrite static `Runtime().Cmd` globally; route only trusted host-local + dynamic commands. +- Preserve command prefixes, CLI flags, resume arguments, and native preference. + +## Output contract + +Record RED/GREEN evidence, exact command assertions, checks, and risks in +Results. Update this task and `plan.md` status. + +## Results + +RED covered exact standalone/worktree command routing, legacy no-selection +behavior, SSH/container exclusions, and selection read errors at the lifecycle +and host-utility boundaries. GREEN verification: + +- `rtk go test ./internal/agent/managedruntime ./internal/agent/agents ./internal/agent/hostutility ./internal/agent/runtime/lifecycle ./internal/agent/settings/controller ./internal/agent/settings/handlers ./internal/backendapp` — 2,321 tests passed across 7 packages. +- `rtk make -C apps/backend lint` — 0 issues. + +One selection store instance is wired through backend startup into lifecycle, +host utility, and update orchestration. Host-local probes, model resolution, +profile prompts, sessionless prompts, and standalone launches share the exact +version resolver. Remote/container command ownership and native preference are +unchanged. diff --git a/docs/plans/managed-runtime-recovery/task-03-activate-validated-runtime-versions.md b/docs/plans/managed-runtime-recovery/task-03-activate-validated-runtime-versions.md new file mode 100644 index 0000000000..b9c1ca0df9 --- /dev/null +++ b/docs/plans/managed-runtime-recovery/task-03-activate-validated-runtime-versions.md @@ -0,0 +1,83 @@ +--- +id: "03-activate-validated-runtime-versions" +title: "Activate validated runtime versions" +status: done +wave: 3 +depends_on: ["01-build-exact-version-foundation", "02-route-active-host-runtime"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 03: Activate validated runtime versions + +## Acceptance + +- Preview returns the newest 50 stable versions plus active/current values, + validates an optional target, and derives operation and exact command. +- POST accepts only `target_version`, re-resolves trusted npm metadata, and + rejects malformed, prerelease, unpublished, disappeared, or package-like + input before mutation. +- A candidate is prepared under its exact execution key, one failed preparation + invalidates only that tree, and a successful ACP probe is required. +- Persistence happens before capability publication. Every failure preserves + the previous active version and capability catalogue. +- Jobs and catalogue DTOs expose active version and operation while retaining + current status, maintenance, output-bound, retention, and WebSocket behavior. + +## TDD sequence + +1. Extend fake-updater tests for catalogue parsing, operation classification, + target request validation, and exact command/key construction. +2. Add orchestration tests for update, rollback, unknown-version repair, + up-to-date no-op, targeted retry, failed probe, auth-required probe, + persistence failure, and success ordering. +3. Add handler tests for query/body decoding and rejection before enqueue. +4. Implement DTOs, registry resolver, controller, job lifecycle, persistence, + and catalogue projection in that order. +5. Run focused tests, backend lint, and generated/API compatibility checks used + by the touched packages. + +## Files likely touched + +- `apps/backend/internal/agent/settings/dto/dto.go` +- `apps/backend/internal/agent/settings/controller/agent_update.go` +- `apps/backend/internal/agent/settings/controller/agent_update_job.go` +- `apps/backend/internal/agent/settings/controller/agent_update_test.go` +- `apps/backend/internal/agent/settings/controller/agent_discovery.go` +- `apps/backend/internal/agent/settings/handlers/handlers.go` +- `apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go` + +## Verification + +```bash +make -C apps/backend test ARGS='./internal/agent/settings/controller ./internal/agent/settings/handlers' +make -C apps/backend lint +``` + +## Risks + +- Resolve registry metadata again after approval; do not trust a stale browser + list as authorization. +- Do not publish candidate capabilities before the active selection is durable. +- Authentication-required is not a successful activation boundary. +- Keep raw registry credentials and unbounded output out of DTOs. + +## Output contract + +Record RED/GREEN evidence, ordering assertions, API examples, checks, and risks +in Results. Update this task and `plan.md` status. + +## Results + +RED covered catalogue projection, operation classification, target validation, +exact preparation retry, failed-probe preservation, persistence-before- +publication, and required request-body behavior. GREEN verification: + +- `rtk go test ./internal/agent/managedruntime ./internal/agent/agents ./internal/agent/hostutility ./internal/agent/runtime/lifecycle ./internal/agent/settings/controller ./internal/agent/settings/handlers ./internal/backendapp` — 2,321 tests passed across 7 packages. +- `rtk make -C apps/backend lint` — 0 issues. + +The controller re-resolves trusted npm metadata after approval, the candidate +probe does not replace live capabilities, and the active selection is saved +before capabilities are published. Failed preparation, probe, authentication, +or persistence leaves the previous active selection and capability cache in +place. Handler tests cover JSON target decoding and invalid-target responses. diff --git a/docs/plans/managed-runtime-recovery/task-04-add-responsive-version-recovery-ui.md b/docs/plans/managed-runtime-recovery/task-04-add-responsive-version-recovery-ui.md new file mode 100644 index 0000000000..343d5f28ef --- /dev/null +++ b/docs/plans/managed-runtime-recovery/task-04-add-responsive-version-recovery-ui.md @@ -0,0 +1,97 @@ +--- +id: "04-add-responsive-version-recovery-ui" +title: "Add responsive version recovery UI" +status: done +wave: 4 +depends_on: ["03-activate-validated-runtime-versions"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 04: Add responsive version recovery UI + +## Acceptance + +- The API client previews a selected target and sends it explicitly in POST. +- The shared dialog state defaults to backend latest, refreshes the trusted + preview when selection changes, ignores stale responses, and preserves the + selected target through job progress and retry. +- Structural operation state renders **Update runtime**, **Roll back runtime**, + **Repair runtime**, or disabled **Up to date** in every locale. +- Desktop dialog and phone drawer share version selection and business logic; + the picker is keyboard/touch accessible with visible latest/active markers. +- Registry, candidate, and activation failures allow retry or another version + selection without exposing package/command input. + +## TDD sequence + +1. Add failing API tests for encoded preview query and JSON POST body. +2. Add failing hook tests for default selection, target changes, stale preview + cancellation, active-job idempotency, and exact-target retry. +3. Add failing pure-state tests for operation labels and approval gates without + translated-string comparison. +4. Implement the shared selector/body/footer and all localized copy. +5. Run focused tests, typecheck, lint, i18n check, and i18n ratchet. + +## Files likely touched + +- `apps/web/lib/api/domains/agent-update-api.ts` +- `apps/web/lib/api/domains/agent-update-api.test.ts` +- `apps/web/lib/agent-runtime-update.ts` +- `apps/web/lib/agent-runtime-update.test.ts` +- `apps/web/components/settings/use-agent-update-dialog-state.ts` +- `apps/web/components/settings/use-agent-update-dialog-state.test.ts` +- `apps/web/components/settings/agent-runtime-update-control.tsx` +- `apps/web/src/locales/{en,pt-pt,zh-cn,pseudo}/agents.json` + +## Mobile requirements + +- Keep the existing bottom drawer and introduce no nested drawer. +- Keep all interactive controls at least 44 px high. +- Keep the shared body as the only vertical scroll owner and the footer inside + the safe-area inset. +- Avoid browser-chrome collisions and horizontal page overflow for long + versions, commands, and output. + +## Verification + +Fresh-worktree bootstrap, when needed: + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +```bash +cd apps && pnpm --filter @kandev/web test -- --run lib/api/domains/agent-update-api.test.ts lib/agent-runtime-update.test.ts components/settings/use-agent-update-dialog-state.test.ts +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web lint +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm run i18n:ratchet +``` + +## Risks + +- Do not classify versions in TypeScript; render backend operation state. +- Do not compare localized copy to decide state. +- Avoid refetch loops and stale selection overwrites when targets change fast. + +## Output contract + +Record RED/GREEN evidence, responsive behavior, checks, and risks in Results. +Update this task and `plan.md` status. + +## Results + +RED covered encoded target queries, exact POST bodies, stale preview response +handling, exact-target approval, operation labels, and localized missing-version +gates. GREEN verification: + +- `rtk pnpm --filter @kandev/web test -- --run lib/api/domains/agent-update-api.test.ts lib/agent-runtime-update.test.ts components/settings/use-agent-update-dialog-state.test.ts hooks/domains/settings/use-agent-runtime-updates.test.tsx` — 31 tests passed across 4 files. +- `rtk pnpm run typecheck` — passed. +- `rtk pnpm --filter @kandev/web lint` — passed. +- `rtk pnpm run i18n:check` — passed; existing 117 real-locale parity warnings remain advisory. +- `rtk pnpm run i18n:ratchet` — passed with 0 added and 8 modified files clean. + +The desktop dialog and mobile drawer share the selector and state machine, +submit only trusted exact targets, derive action copy from backend operation +state, and retain 44 px mobile controls with one scroll owner. diff --git a/docs/plans/managed-runtime-recovery/task-05-prove-rollback-desktop-mobile.md b/docs/plans/managed-runtime-recovery/task-05-prove-rollback-desktop-mobile.md new file mode 100644 index 0000000000..abfa30c5bc --- /dev/null +++ b/docs/plans/managed-runtime-recovery/task-05-prove-rollback-desktop-mobile.md @@ -0,0 +1,74 @@ +--- +id: "05-prove-rollback-desktop-mobile" +title: "Prove rollback on desktop and mobile" +status: done +wave: 5 +depends_on: ["04-add-responsive-version-recovery-ui"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 05: Prove rollback on desktop and mobile + +## Acceptance + +- Desktop E2E covers an unknown-version repair state and selects an older stable + version for rollback, reviews the exact command, and submits one rollback. +- `mobile-chrome` completes the same behavior through the bottom drawer with no + nested overlay, clipped action, browser-chrome collision, or page overflow. +- Failed candidate validation keeps the previous active marker and lets the + operator select another version. +- Existing update, same-version, install-conflict, output, and page-reload + scenarios continue to pass with explicit targets. + +## TDD sequence + +1. Update mock routes and helpers to require target query/body values. +2. Add the desktop rollback scenario and confirm RED before UI wiring. +3. Add the mobile parity and containment scenario. +4. Add failed-candidate preservation coverage where it is not already proven + by component tests. +5. Run focused projects, inspect artifacts on failure, and clean generated + screenshots/traces/videos not intentionally retained. + +## Files likely touched + +- `apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts` +- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts` + +## Verification + +```bash +cd apps/web && pnpm e2e:raw --project=chromium e2e/tests/settings/agent-runtime-update.spec.ts +cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts +``` + +## Risks + +- Assert request payload and rendered outcome, not only button visibility. +- Keep deterministic mocked registry data; do not contact public npm in E2E. +- Verify one scroll owner and touch reachability at the narrow shipped viewport. + +## Output contract + +Record test counts, viewport, artifacts, cleanup evidence, checks, and risks in +Results. Update this task and `plan.md` status. + +## Results + +GREEN verification: + +- `rtk pnpm e2e:raw --project=chromium e2e/tests/settings/agent-runtime-update.spec.ts` — 6 passed. +- `rtk pnpm e2e:raw --project=mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts` — 4 passed. +- Request assertions verified target query/body values for update and rollback, + operation labels, up-to-date disabling, failed-job retry, mobile drawer + containment, and long-output scrolling. +- Fresh disposable capture runs passed once per viewport. The compressed + synthetic assets are `desktop-runtime-version-selector` and + `mobile-runtime-version-selector`; both manifest entries map to non-empty + PNGs under ignored `apps/web/.pr-assets`. + +Temporary capture specs were removed after publication assets were validated. +The only environment cleanup was disposal of the recoverable Go build cache +after an `ENOSPC` during the first capture attempt. diff --git a/docs/plans/managed-runtime-recovery/task-06-document-runtime-version-recovery.md b/docs/plans/managed-runtime-recovery/task-06-document-runtime-version-recovery.md new file mode 100644 index 0000000000..5f7bee452e --- /dev/null +++ b/docs/plans/managed-runtime-recovery/task-06-document-runtime-version-recovery.md @@ -0,0 +1,69 @@ +--- +id: "06-document-runtime-version-recovery" +title: "Document runtime version recovery" +status: done +wave: 5 +depends_on: ["04-add-responsive-version-recovery-ui"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 06: Document runtime version recovery + +## Acceptance + +- Public agent/profile guidance explains selecting update, rollback, and repair + targets and that activation follows a successful ACP validation. +- The guide states that the active exact version survives Kandev restarts, + affects only later host-local probes/utility calls/sessions, and does not + change active or remote/container sessions. +- Troubleshooting covers incomplete latest releases, failed candidates, + authentication-required validation, and retrying another stable version. +- The guide does not recommend global npm cache cleanup or imply Kandev owns an + offline artifact inventory. + +## Implementation + +1. Replace the current latest-only update steps in the existing how-to section. +2. Add a concise troubleshooting entry for selecting the previous stable + version when latest cannot initialize. +3. Check all labels and guarantees against the implemented API/UI and spec. +4. Run documentation link and formatting checks available in the repository. + +## Files likely touched + +- `docs/public/agents-and-profiles.md` + +## Verification + +```bash +rtk rg -n "Update a managed agent runtime|Roll back runtime|Repair runtime|npm cache" docs/public/agents-and-profiles.md +rtk rg -n "docs/public|markdown" Makefile apps/package.json package.json +``` + +Run the repository documentation check discovered by the second command and +record the exact command/result. + +## Risks + +- Describe host scope and persistence precisely; do not promise remote updates + or cached offline reinstall. +- Keep this as an operator how-to, not an implementation narrative. + +## Output contract + +Record changed sections, checks, and risks in Results. Update this task and +`plan.md` status. + +## Results + +Updated `docs/public/agents-and-profiles.md` with exact-version selection, +update/rollback/repair behavior, ACP validation, restart and host-local scope, +active-session and remote/container boundaries, failed-candidate preservation, +and retry guidance. GREEN verification: + +- `rtk node --test scripts/validate-public-docs.test.mjs` — 60 tests passed. +- `rtk node scripts/validate-public-docs.mjs` — 41 published docs pages validated. + +The guide does not promise global npm cache cleanup or an offline artifact +inventory. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index c252491112..3067d842d5 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -37,17 +37,24 @@ The update icon is available on managed Claude, Codex, OpenCode, Copilot, and Gemini agent cards. It updates the runtime on the Kandev host. 1. Select the update icon. -2. Review the current version, target version, and command. -3. Select **Approve update**. If the update fails, select **Retry update**. -4. Wait for the capability refresh to finish. +2. Review the current version, active version, available stable versions, and command. +3. Keep the latest version selected for a normal update, or select an older stable version to roll back. +4. Select **Update runtime**, **Roll back runtime**, or **Repair runtime**. +5. Wait for the exact version to prepare and pass its ACP capability probe. -Kandev disables approval when the versions already match or the version check -is incomplete. A successful refresh updates the advertised models, modes, +Kandev enables the action only after the backend validates the selected version +against the trusted package catalogue. It does not accept package names, npm +tags, prereleases, registry URLs, or command text. When the active version, +observed version, and target version match, the action is disabled as **Up to +date**. A successful activation updates the advertised models, modes, configuration options, commands, and runtime version without a page reload. -- Active sessions keep running; later probes and launches use the refreshed runtime. +- The active exact version survives Kandev and browser restarts. +- Later host-local probes, utility calls, and standalone sessions use the active exact version. +- Active sessions keep running. They are not restarted or hot-swapped. - Passthrough agents, authentication helpers, remote executors, and running containers are unchanged. -- If the update or refresh fails, Kandev keeps the previous capability catalogue and shows the error. Authenticate the host agent, then retry. +- If preparation, ACP validation, authentication, or persistence fails, Kandev keeps the previous active version and capability catalogue. Select another stable version or retry the same target. +- Kandev may prepare the exact version again if npm removes its cache entry. Kandev does not own an offline package inventory, and global npm cache cleanup is not required.
Add a custom terminal agent diff --git a/docs/specs/agents/runtime-updates.md b/docs/specs/agents/runtime-updates.md index fc5ddf1c5a..ae73389ac6 100644 --- a/docs/specs/agents/runtime-updates.md +++ b/docs/specs/agents/runtime-updates.md @@ -1,53 +1,49 @@ --- status: approved created: 2026-07-26 +updated: 2026-08-12 owner: Kandev --- -# Managed Agent Runtime Updates +# Managed Agent Runtime Version Recovery ## Why -Operators need newly released agent capabilities and models without waiting for -a Kandev release. They also need normal agent launches to avoid paying an -upstream package-resolution cost on every session when a usable npm execution -cache is already present. +Operators need newly released agent models without waiting for a Kandev +release. They also need a UI recovery path when the newest npm release is +partly published, incompatible with ACP, or otherwise cannot start. Rebuilding +an npm cache is not sufficient when an unversioned command selects the same +broken release again. ## What -- Kandev launches the managed npm runtime for Claude, Codex, OpenCode, Copilot, - and Gemini by package name without an exact version or an explicit `latest` - tag. -- Normal launches may reuse npm's execution cache. Cache reuse is best-effort; - Kandev does not present it as a durable installed-version guarantee. -- Each supported installed agent has a compact, accessible update icon action - on its Settings > Agents card on desktop and mobile. Update versions, - command details, progress, output, and results are not rendered in the card. -- Opening the update action is read-only. It opens an update dialog that - resolves and shows the current runtime version, upstream target version, - exact built-in command, host-only scope, capability refresh, and active - session behavior before an update can start. -- When the resolved current and upstream target versions are identical, the - update dialog shows the version once with an **Up to date** status and keeps - approval disabled because running the package update would not advance the - managed runtime. -- The update starts only after the operator approves it in the dialog. The - dialog then shows live stdout/stderr, progress, and the terminal success or - failure state. -- A successful package update automatically starts a fresh host capability - probe. The returned runtime version, models, modes, configuration options, - commands, and capability status replace the previous cached values. -- An update affects new host probes, utility calls, and sessions. It does not - restart or mutate an already-running agent session. -- Kandev uses the ACP initialization protocol version and advertised - capabilities as the compatibility boundary. Kandev does not gate managed - npm runtimes by a repository-maintained package-version allowlist. -- Package update commands are defined by Kandev's built-in agent metadata. - Callers cannot submit package names, versions, registry URLs, or shell text. -- Update jobs for the same agent are idempotent while queued or running. An - install and update for the same agent cannot run concurrently. +- Settings exposes version management for the built-in managed npm runtimes + used by Claude, Codex, OpenCode, Copilot, and Gemini. +- The update dialog lists stable versions published for the trusted package. + The list contains the newest 50 stable versions plus the active and last + observed versions when either falls outside that window. The upstream + `latest` stable version is selected initially. +- The backend classifies the selected action as `update`, `rollback`, `repair`, + or `up_to_date`. The UI uses this structural state for copy and approval; it + never compares translated labels or version strings itself. +- Kandev stages the exact trusted `package@version`, ACP-probes that candidate, + and activates it only after a successful probe. Candidate failure preserves + the prior active version and capability catalogue. +- A successful activation persists the exact version for this Kandev install. + Later standalone host probes, utility calls, and local sessions use that + exact package spec. Active sessions continue unchanged. +- When no active selection exists, legacy host launches remain unversioned. + The first successful update, rollback, or repair establishes the durable + active version. +- SSH, containers, and other remote executors do not use the host selection. +- Package name, ACP arguments, and command shape remain trusted built-in + metadata. A caller can submit only a version returned by the trusted + package's npm metadata. Tags, prereleases, package specs, registry URLs, and + shell text are rejected. +- Jobs for one agent are idempotent while queued or running. Installation and + version management for the same agent cannot run concurrently. -The initial managed package set is: +The managed package set is: | Agent | Managed runtime package | | --- | --- | @@ -57,11 +53,35 @@ The initial managed package set is: | Copilot | `@github/copilot` | | Gemini | `@google/gemini-cli` | -The managed package is the runtime Kandev uses for ACP capability discovery. -Separately configured passthrough commands and native authentication helpers -remain outside this update action when they use another package or installer. +Passthrough commands, native authentication helpers, and native-only agents +remain outside this version action when they use another installer or package. -Decision: [ADR-2026-07-26-user-managed-agent-runtime-updates](../../decisions/2026-07-26-user-managed-agent-runtime-updates.md). +Decision: +[ADR-2026-08-12-validated-managed-runtime-version-selection](../../decisions/2026-08-12-validated-managed-runtime-version-selection.md). + +## Version and operation semantics + +Kandev distinguishes three version values: + +- `current_version` is the version reported by the last successful host ACP + probe. It can be absent after a failed probe. +- `active_version` is the exact version persisted for future host-local + managed-runtime commands. It can be absent before the first activation. +- `target_version` is the stable version selected by the operator. + +The backend derives the operation from `active_version` when present and +otherwise from `current_version`: + +| Condition | Operation | Approval | +| --- | --- | --- | +| Target is newer than the effective version. | `update` | **Update runtime** | +| Target is older than the effective version. | `rollback` | **Roll back runtime** | +| Current is unknown, versions cannot be compared, or the observed version matches but has not been activated exactly. | `repair` | **Repair runtime** | +| Active, current, and target versions match. | `up_to_date` | Disabled as **Up to date** | + +Only strict stable SemVer values from npm's published version list are +selectable. Kandev does not offer prereleases. Version ordering and operation +classification are backend responsibilities. ## API surface @@ -73,37 +93,73 @@ Installed-agent catalogue entries expose optional runtime-management metadata: { "runtime_update": { "supported": true, - "package": "@agentclientprotocol/claude-agent-acp", - "current_version": "0.62.0" + "package": "opencode-ai", + "current_version": "1.18.5", + "active_version": "1.18.5" } } ``` -`current_version` is omitted when no successful capability probe has reported a -runtime version. Unmanaged agents omit `runtime_update`. +`current_version` and `active_version` are independently omitted when unknown +or not yet established. Unmanaged agents omit `runtime_update`. -### Update jobs +### Preview and jobs -- `GET /api/v1/agent-update/:agentName/preview` resolves the current and - upstream target versions and returns the exact trusted built-in update - command without starting an update. -- `POST /api/v1/agent-update/:agentName` starts or returns the active update - job for a built-in managed agent. -- `GET /api/v1/agent-update/jobs` returns active and recently completed update - jobs. -- `GET /api/v1/agent-update/jobs/:id` returns one retained update job. -- State-changing update requests use the same Settings interlock as agent - installation and profile mutation. +- `GET /api/v1/agent-update/:agentName/preview` returns the version catalogue + and previews the upstream latest stable target. +- `GET /api/v1/agent-update/:agentName/preview?target_version=1.18.5` + validates and previews one selected version without mutation. +- `POST /api/v1/agent-update/:agentName` accepts JSON + `{ "target_version": "1.18.5" }` and starts or returns the active job. +- `GET /api/v1/agent-update/jobs` and + `GET /api/v1/agent-update/jobs/:id` retain their current polling behavior. +- State-changing requests use the Settings mutation interlock. -An update job contains: +A preview contains: + +```json +{ + "agent_name": "opencode-acp", + "package": "opencode-ai", + "current_version": "1.18.5", + "active_version": "1.18.5", + "target_version": "1.18.16", + "operation": "update", + "available_versions": [ + { "version": "1.18.16", "latest": true }, + { "version": "1.18.5", "latest": false } + ], + "command": [ + "npm", + "exec", + "--yes", + "--prefer-online", + "--package=opencode-ai@1.18.16", + "--", + "node", + "-e", + "" + ], + "command_string": "npm exec --yes --prefer-online --package=opencode-ai@1.18.16 -- node -e \"\"" +} +``` + +The POST endpoint resolves npm metadata again and rejects a target that is no +longer a published stable version. The request never controls the package, +registry, command, or ACP arguments. + +A job retains the existing timestamps, output, and errors and adds the +authoritative operation and active version: ```json { "job_id": "uuid", - "agent_name": "claude-acp", - "status": "resolving", - "current_version": "0.62.0", - "target_version": "0.63.0", + "agent_name": "opencode-acp", + "status": "refreshing", + "operation": "rollback", + "current_version": "1.18.16", + "active_version": "1.18.16", + "target_version": "1.18.5", "output": "", "error": "", "refresh_error": "", @@ -112,154 +168,120 @@ An update job contains: } ``` -`current_version`, `target_version`, `finished_at`, `error`, and -`refresh_error` are optional until known. The backend emits -`agent.update.started`, `agent.update.output`, and `agent.update.finished` -notifications with the same job identity and state. Output notifications carry -only the appended output chunk. +`active_version` reflects the persisted selection at that job snapshot. It +changes to the target only after successful validation and persistence. The +existing `agent.update.started`, `agent.update.output`, and +`agent.update.finished` WebSocket notifications carry the same job fields. -An update preview contains: +## Activation lifecycle -```json -{ - "agent_name": "claude-acp", - "package": "@agentclientprotocol/claude-agent-acp", - "current_version": "0.62.0", - "target_version": "0.63.0", - "command": [ - "npm", - "exec", - "--yes", - "--prefer-online", - "--package=@agentclientprotocol/claude-agent-acp", - "--", - "node", - "-e", - "" - ], - "command_string": "npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e \"\"" -} -``` - -The preview endpoint accepts only the built-in agent name. Package names, -versions, registry URLs, and command arguments are not caller-controlled. - -## State machine - -| State | Trigger | Observable behavior | +| State | Backend behavior | Observable behavior | | --- | --- | --- | -| `queued` | The backend accepts an update request. | The action is disabled and shows that the update is queued. | -| `resolving` | A worker starts the job. | Kandev discovers the current runtime version and upstream npm target. | -| `updating` | Version resolution succeeds. | Kandev streams package-update output and shows current → target. | -| `refreshing` | The package update succeeds. | Kandev re-probes the host runtime and keeps the action disabled. | -| `succeeded` | The capability probe succeeds, or the package updated but capability refresh returned a recoverable error. | The UI shows the installed target. When refresh succeeded, it replaces model and mode data. A refresh-only error is shown without claiming the package update was rolled back. | -| `failed` | Registry lookup or package update fails, the command times out, or ACP initialization is incompatible. | The UI retains the previous model list and shows the captured error and output. | +| `queued` | Accept the selected version and maintenance claim. | The version picker and action are disabled. | +| `resolving` | Re-read npm versions, validate the exact target, and classify the operation. | The UI shows the selected target and resolving progress. | +| `updating` | Prepare `package@target` in its version-specific npm execution tree. On first failure, invalidate only that exact tree and retry once. | Bounded stdout and stderr stream into the dialog. | +| `refreshing` | Probe the candidate command without replacing cached capabilities. On success, persist the target and then publish the candidate capabilities. | The UI explains that Kandev is validating before activation. | +| `succeeded` | The exact target is active and its capabilities are published. | The catalogue and models refresh without a page reload. | +| `failed` | Resolution, preparation, probe, or persistence failed. The previous active version and capabilities remain unchanged. | The UI shows the captured error and permits a new selection or retry. | -Jobs are terminal after `succeeded` or `failed`. Retrying creates a new job. +Jobs are terminal after `succeeded` or `failed`. Selecting the active, healthy +version produces `up_to_date` in preview and starts no job. Selecting the active +version while its current probe is unknown produces a repair job. -## Failure modes +## Host command routing -- If npm registry metadata cannot be resolved, the job fails before changing - the runtime and retains the prior capability data. -- If the first package update command fails, Kandev may remove only the - deterministic npm execution-cache directory for that built-in package and - retry the update once. It never runs a global npm cache clean. If the repair - or retry fails or times out, the job fails and retains the prior capability - data. -- If preview version resolution fails, the dialog shows the error and keeps - approval disabled. No update job or package command starts. -- If the package update succeeds but the capability probe fails because - authentication is required or another recoverable probe error occurs, the - job reports package-update success plus `refresh_error`; the previous model - list remains visible and the operator can authenticate or retry the refresh. -- If the updated runtime negotiates an unsupported ACP protocol version or - cannot initialize, the job fails visibly. Kandev does not silently fall back - to a repository-pinned runtime. -- Raw process output is bounded using the existing in-memory job output limit. - Package-manager credentials and configured registry authentication are never - returned as structured fields. -- Loss of the browser connection does not cancel a running job. Reopening the - page recovers retained job progress through the jobs endpoint. +- A standalone managed-agent launch reads the active selection immediately + before building its command and passes the exact version through trusted + command options. +- Boot probes, manual capability refreshes, model-configuration resolution, and + sessionless utility prompts use the same active-version resolver. +- Native-binary preference continues to win when an agent deliberately selects + its supported native binary path. +- SSH and container command builders receive no host version override. +- A saved selection read error fails the new host command with an actionable + error. It does not fall back to an unversioned package. +- Candidate validation bypasses the active selection only for the trusted exact + candidate command created by the version job. + +## Failure and recovery behavior + +- Registry failure during preview keeps approval disabled and runs no command. +- Registry failure or target disappearance after approval fails before staging. +- Preparation failure invalidates only the deterministic `_npx` tree for the + exact `package@version` and retries once. Kandev never runs a global npm cache + clean. +- ACP initialization failure, unsupported protocol behavior, authentication + required, or an unsuccessful capability probe does not activate the + candidate. The staged npm cache may remain for a later retry. +- Persistence failure after a successful candidate probe does not publish the + candidate capabilities and leaves the previous active selection unchanged. +- Browser disconnect does not cancel a running job. The jobs endpoint can + recover process-local progress while the backend remains running. +- Active sessions are never restarted, replaced, or hot-swapped. ## Persistence guarantees -- Update jobs and capability data are process-local and do not survive a Kandev - backend restart. -- Completed jobs remain queryable for the existing short job-retention window. -- Settings does not rehydrate retained update jobs after a browser page - restart. Update dialog state, output, and results are intentionally - page-local and disappear when the page is restarted. -- npm's host cache may survive Kandev restarts, but Kandev does not own or - guarantee that cache. -- After a backend restart, normal host capability probing reports whichever - runtime npm resolves in that environment. +- The trusted package identity and active version are stored install-wide per + built-in agent in the system settings store and survive backend and browser + restarts. A record whose package no longer matches the agent's built-in + metadata is treated as having no active selection and is not applied to the + replacement package. +- The active version is written only after successful candidate validation, so + it is also the last known good selection. +- Jobs, process output, and capability cache remain process-local and do not + survive a backend restart. +- npm's execution cache is best-effort and is not Kandev-owned inventory. If an + exact selected cache entry disappears, npm may prepare that same exact + version again; it must not advance to another version. +- Dialog selection, output, and result remain page-local after a browser page + restart. + +## Desktop and mobile behavior + +- The existing agent-card update icon remains the entry point on desktop and + mobile and keeps a minimum 44 px touch target. +- Desktop uses the existing dialog. Phone layouts use the existing bottom + drawer; no nested drawer is introduced. +- The version selector is inside the shared body, is keyboard and touch + accessible, and shows latest and active markers without encoding state in + color alone. +- The body is the single internal scroll owner. The safe-area-aware footer + keeps the operation action reachable while long version lists and process + output remain viewport-contained. +- Selection state, preview loading, operation labels, request payloads, and + terminal results are shared across desktop and mobile presentations. ## Scenarios -- **GIVEN** a managed agent with a cached runtime, **WHEN** a new session starts - without an explicit update, **THEN** Kandev invokes the unversioned package - spec and does not require a repository-maintained version pin. -- **GIVEN** Claude reports runtime version `0.62.0` and npm reports `0.63.0`, - **WHEN** the operator opens its update action, **THEN** the dialog shows - `0.62.0 → 0.63.0`, the exact built-in command, and how the host update - affects capabilities and active sessions without starting the command. -- **GIVEN** the current runtime and upstream target both report version - `0.64.0`, **WHEN** the operator opens the update action, **THEN** the dialog - shows `0.64.0` once with **Up to date**, does not show a version transition, - keeps **Approve update** disabled on desktop and mobile, and starts no update - job. -- **GIVEN** the update dialog has a resolved preview, **WHEN** the operator - approves the update, **THEN** Kandev starts exactly one update and the dialog - streams stdout/stderr until the job is terminal. -- **GIVEN** an update succeeds and the new runtime advertises an additional - model, **WHEN** the automatic capability probe completes, **THEN** the new - model appears without a page reload or manual Rescan. -- **GIVEN** an update is already queued, resolving, updating, or refreshing, - **WHEN** the operator selects the action again, **THEN** Kandev returns the - existing job and does not run a second update. -- **GIVEN** an install is active for an agent, **WHEN** an update is requested - for that agent, **THEN** Kandev returns the active maintenance job rather - than running install and update concurrently. -- **GIVEN** npm registry lookup fails while preparing the dialog, **WHEN** the - update action is opened, **THEN** the dialog shows a retryable preview failure, - keeps approval disabled, and starts no update job. -- **GIVEN** npm registry lookup succeeds for the preview but fails after - approval because the registry changed, **WHEN** the update job resolves its - target again, **THEN** the dialog shows a retryable update failure and retains - the previous models. -- **GIVEN** a managed package's extracted `_npx` execution tree is corrupt, - **WHEN** the first update preparation fails, **THEN** Kandev invalidates only - that built-in package's deterministic execution-cache directory, retries - once, and probes the rebuilt runtime before reporting success. -- **GIVEN** the package update succeeds but the fresh probe requires - authentication, **WHEN** the job finishes, **THEN** the dialog reports the new - package version and refresh error, the previous models remain available, and - the card keeps its existing authentication recovery action. -- **GIVEN** an agent is unmanaged or native-only, **WHEN** Settings renders its - installed card, **THEN** no update action is shown. -- **GIVEN** an update dialog has shown progress or a terminal result, **WHEN** - the operator restarts the page, **THEN** the dialog is closed and no prior - update details, output, or result appear on the agent card or in a newly - opened dialog. -- **GIVEN** an update is running on a phone viewport, **WHEN** the operator - views the update surface, **THEN** current and target versions, the command, - progress, output, and retry state are reachable by touch without horizontal - page scrolling. -- **GIVEN** an agent session is already running, **WHEN** its host runtime is - updated, **THEN** the existing session continues unchanged and only later - probes or launches use the updated runtime. +- **GIVEN** OpenCode latest is partly published and its ACP probe fails, + **WHEN** an operator selects an older published stable version and approves + **Roll back runtime**, **THEN** Kandev prepares and probes that exact version, + persists it only after success, and restores its model list without restart. +- **GIVEN** a healthy exact active version, **WHEN** Kandev restarts, **THEN** + boot probes and later standalone sessions use the same exact version. +- **GIVEN** a candidate fails ACP initialization, **WHEN** the job ends, + **THEN** the previous active version and capabilities remain authoritative. +- **GIVEN** the current version is unknown and there is no active selection, + **WHEN** the operator selects a published target, **THEN** the UI offers + **Repair runtime** and validation establishes the first exact active version. +- **GIVEN** active, current, and target versions match, **WHEN** the dialog + opens, **THEN** it shows **Up to date** and starts no job. +- **GIVEN** a different target is submitted while a job is active for the same + agent, **WHEN** the backend receives it, **THEN** it returns the existing job + and does not run a second candidate concurrently. +- **GIVEN** an SSH or container session, **WHEN** the host active version + changes, **THEN** that executor's command remains unchanged. +- **GIVEN** a phone viewport and a long version catalogue or process log, + **WHEN** the operator selects and activates a version, **THEN** the drawer + remains contained and the primary action remains touch-reachable. ## Out of scope -- Scheduled or automatic package updates. -- Automatically deleting or rebuilding npm execution caches during every - ordinary agent launch. -- Exact package-version pins, version allowlists, rollback, or user-selected - historical versions. -- Updating configured remote executors or every running container from the - host Settings action. -- Restarting or hot-swapping active agent sessions. -- Managing native-only update channels such as Cursor. -- Updating separately distributed passthrough or authentication helper - packages when they are not the managed ACP runtime. -- Resuming or recovering update dialog state after a browser page restart. +- Scheduled or automatic updates and automatic rollback after launch failure. +- Prerelease, tag, arbitrary package-spec, registry, or shell-command input. +- Kandev-owned npm artifact retention or a package lockfile. +- Applying host selections to SSH, container, or other remote runtimes. +- Restarting or hot-swapping active sessions. +- Native-only update channels and separately distributed passthrough or + authentication packages. +- Persisting job output or reopening the dialog after a browser restart.