diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07658e843f..f93114d564 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,8 +29,8 @@ repos: pass_filenames: true - id: go-lint - name: Go lint (changed files only) - entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; cd apps/backend && golangci-lint run --new-from-rev=HEAD' + name: Go lint (PR changed code) + entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; base=$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD); cd apps/backend && golangci-lint run ./... --new-from-rev="$base" --timeout=5m' language: system files: ^apps/backend/.*\.go$ pass_filenames: false diff --git a/README.md b/README.md index e7af773f6d..d6709ec139 100644 --- a/README.md +++ b/README.md @@ -70,13 +70,13 @@ Connect Kandev to the tools your team already uses — pull issues into the kanb | Agent | Launch | |:-------:|:----------:| -| **Claude Code** | `npx -y @agentclientprotocol/claude-agent-acp@0.61.0` | -| **Codex** | `npx -y @agentclientprotocol/codex-acp@1.1.5` | -| **GitHub Copilot** | `npx -y @github/copilot --acp` | -| **Gemini CLI** | `npx -y @google/gemini-cli --acp` | +| **Claude Code** | `npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp` | +| **Codex** | `npx --yes --prefer-offline @agentclientprotocol/codex-acp` | +| **GitHub Copilot** | `npx --yes --prefer-offline @github/copilot --acp` | +| **Gemini CLI** | `npx --yes --prefer-offline @google/gemini-cli --acp` | | **Amp** | `npx -y amp-acp` | | **Auggie** | `npx -y @augmentcode/auggie --acp` | -| **OpenCode** | `opencode acp` | +| **OpenCode** | `npx --yes --prefer-offline opencode-ai acp` | | **Cursor** | `cursor-agent acp` *(requires Cursor Pro)* | | **Devin** | `devin acp` *(install Devin CLI from Devin Desktop or standalone installer)* | | **Qwen** | `npx -y @qwen-code/qwen-code --acp` | @@ -93,6 +93,13 @@ Connect Kandev to the tools your team already uses — pull issues into the kanb > All agents communicate via [ACP](https://agentclientprotocol.com) (Agent Client Protocol). Some agents support ACP natively, while others use ACP adapter packages that bridge their native protocols. **CLI Passthrough mode** is available when an integration provides a passthrough command. If your agent isn't supported yet, open an issue or submit a PR with the integration. See [Adding a New Agent CLI](docs/add-agent-cli.md) for a step-by-step guide. +Kandev does not pin the managed npm runtimes for Claude, Codex, OpenCode, +Copilot, or Gemini. Normal launches can reuse npm's best-effort execution +cache. To deliberately fetch an upstream release, use **Update agent** in +**Settings > Agents**; Kandev updates the host runtime and refreshes its +advertised models and modes for future sessions without restarting active +sessions. See [Agents and Profiles](docs/public/agents-and-profiles.md#update-a-managed-agent-runtime). + ### Bring your own TUI agents Kandev can run any agent CLI as a TUI inside a terminal, even when it does not speak ACP. Add the command in agent profile settings and the task starts that agent inside a PTY terminal. CLI passthrough keeps the agent's native terminal UX available while still giving Kandev task tracking, worktrees, review, and session management. diff --git a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md index b81c618678..b6f5ebbab9 100644 --- a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md +++ b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md @@ -1,29 +1,36 @@ -# ACP bridge versions +# Managed npm ACP runtimes -Kandev tests these npm-provided ACP runtimes with the following package pins: +Kandev invokes these managed npm-provided ACP runtimes by unversioned package +name: -| Agent | Package | Tested version | ACP runtime selection | -| --- | --- | --- | --- | -| Claude | `@agentclientprotocol/claude-agent-acp` | `0.61.0` | Exact package spec through `npx` | -| Codex | `@agentclientprotocol/codex-acp` | `1.1.5` | Exact package spec through `npx` | -| OpenCode | `opencode-ai` | `1.18.4` | Installed `opencode` binary | +| Agent | Package | ACP arguments | +| --- | --- | --- | +| Claude | `@agentclientprotocol/claude-agent-acp` | none | +| Codex | `@agentclientprotocol/codex-acp` | none | +| OpenCode | `opencode-ai` | `acp` | +| Copilot | `@github/copilot` | `--acp` | +| Gemini | `@google/gemini-cli` | `--acp` | -Claude and Codex use their exact package specs for normal sessions, container -commands, one-shot inference, and remote installation. The resolved spec is -part of `AgentCommand`, which existing lifecycle diagnostics log as -`agent_command`. +Normal capability probes, sessions, container commands, and one-shot +inference use `npx --yes --prefer-offline` with the package name and arguments +above. This lets npm reuse its execution cache without making the cache a +durable installed-version guarantee. Kandev records the version reported by +the ACP initialize response instead of inferring it from source. -OpenCode intentionally uses the direct `opencode acp` command for discovery, -normal sessions, container commands, and one-shot inference. This keeps startup -offline-compatible and ensures discovery validates the executable actually -launched. Its installer remains pinned to `opencode-ai@1.18.4`, but discovery -accepts any `opencode` executable found on `PATH`; the ACP probe surfaces auth -or protocol-compatibility failures. The ACP initialize response separately -records the runtime-reported agent name and version. +The **Update agent** action in Settings is the explicit freshness boundary for +the Kandev host. It resolves the upstream npm version, refreshes the +unversioned execution-cache entry with online preference, and then launches a +fresh ACP capability probe. Successful probes replace the advertised version, +models, modes, commands, and configuration options used for later launches. +Already-running sessions continue with their existing process. -To update a bridge, change one version constant at a time, confirm the exact -version exists in the configured npm registry, capture only sanitized ACP wire -fixtures, and run the agent command-surface and ACP dialect tests before -changing the documented tested version. For OpenCode, also install the candidate -globally and confirm `opencode --version` before capturing fixtures. Do not add -prompts, file contents, credentials, or other user data to protocol fixtures. +ACP protocol negotiation and advertised capabilities are the compatibility +boundary. Kandev does not maintain an exact package-version allowlist or +silently roll back a runtime whose initialization fails. Package selection and +update commands come only from built-in agent metadata; callers cannot supply +package names, versions, registry URLs, or shell text. + +Separately configured passthrough commands, native authentication helpers, and +native-only agents such as Cursor are outside this managed update path. Remote +executors and containers resolve their own unversioned runtime when they +launch; the host Settings action does not update those environments. diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 3374595f94..53785432a3 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -72,6 +72,13 @@ type InferenceAgent interface { InferenceConfig() *InferenceConfig } +// ManagedNPMRuntimeAgent is an optional capability for built-in agents whose +// ACP runtime is resolved through npm. Package names and ACP arguments are +// defined by the agent implementation rather than caller-provided input. +type ManagedNPMRuntimeAgent interface { + ManagedNPMRuntime() ManagedNPMRuntimeSpec +} + // PassthroughAgent is an optional capability for agents that support CLI passthrough mode. type PassthroughAgent interface { PassthroughConfig() PassthroughConfig diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index 254bf1f42a..ebce7badc9 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -16,11 +16,7 @@ var claudeACPLogoLight []byte //go:embed logos/claude_code_dark.svg var claudeACPLogoDark []byte -const ( - claudeACPPackage = "@agentclientprotocol/claude-agent-acp" - claudeACPVersion = "0.61.0" - claudeACPPackageSpec = claudeACPPackage + "@" + claudeACPVersion -) +const claudeACPPackage = "@agentclientprotocol/claude-agent-acp" // claudeACPPermSettings maps the profile DangerouslySkipPermissions toggle to // the Claude Code CLI's --dangerously-skip-permissions flag in passthrough mode. @@ -38,9 +34,10 @@ var claudeACPPermSettings = map[string]PermissionSetting{ } var ( - _ Agent = (*ClaudeACP)(nil) - _ PassthroughAgent = (*ClaudeACP)(nil) - _ InferenceAgent = (*ClaudeACP)(nil) + _ Agent = (*ClaudeACP)(nil) + _ PassthroughAgent = (*ClaudeACP)(nil) + _ InferenceAgent = (*ClaudeACP)(nil) + _ ManagedNPMRuntimeAgent = (*ClaudeACP)(nil) ) // ClaudeACP implements Agent for the agentclientprotocol claude-agent-acp package. @@ -110,7 +107,11 @@ func (a *ClaudeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { } func (a *ClaudeACP) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", claudeACPPackageSpec).Build() + return a.ManagedNPMRuntime().CachedACPCommand() +} + +func (a *ClaudeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { + return ManagedNPMRuntimeSpec{Package: claudeACPPackage} } func (a *ClaudeACP) Runtime() *RuntimeConfig { @@ -118,7 +119,7 @@ func (a *ClaudeACP) Runtime() *RuntimeConfig { return &RuntimeConfig{ Image: "kandev/multi-agent", Tag: "latest", - Cmd: Cmd("npx", "-y", claudeACPPackageSpec).Build(), + Cmd: a.ManagedNPMRuntime().CachedACPCommand(), WorkingDir: "{workspace}", RequiredEnv: []string{}, // Auth via ANTHROPIC_API_KEY or OAuth credentials file (see RemoteAuth) Env: map[string]string{ @@ -173,7 +174,7 @@ func (a *ClaudeACP) LoginCommand() *LoginCommand { func (a *ClaudeACP) InstallScript() string { // Install both the user-facing Anthropic CLI (which IsInstalled probes for // and which `claude /login` runs against) and the ACP bridge package. - return "npm install -g @anthropic-ai/claude-code " + claudeACPPackageSpec + return "npm install -g @anthropic-ai/claude-code " + claudeACPPackage } func (a *ClaudeACP) BillingType() usage.BillingType { return claudeBillingType() } @@ -186,6 +187,6 @@ func (a *ClaudeACP) PermissionSettings() map[string]PermissionSetting { func (a *ClaudeACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, - Command: NewCommand("npx", "-y", claudeACPPackageSpec), + Command: a.ManagedNPMRuntime().CachedACPCommand(), } } diff --git a/apps/backend/internal/agent/agents/claude_acp_test.go b/apps/backend/internal/agent/agents/claude_acp_test.go index bf105f56bf..3bc228cfa5 100644 --- a/apps/backend/internal/agent/agents/claude_acp_test.go +++ b/apps/backend/internal/agent/agents/claude_acp_test.go @@ -6,9 +6,9 @@ import ( "testing" ) -func TestClaudeACPUsesPinnedBridge(t *testing.T) { +func TestClaudeACPUsesManagedRuntime(t *testing.T) { a := NewClaudeACP() - want := []string{"npx", "-y", "@agentclientprotocol/claude-agent-acp@0.61.0"} + want := []string{"npx", "--yes", "--prefer-offline", "@agentclientprotocol/claude-agent-acp"} if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) { t.Fatalf("BuildCommand = %#v, want %#v", got, want) @@ -19,7 +19,7 @@ func TestClaudeACPUsesPinnedBridge(t *testing.T) { if got := a.InferenceConfig().Command.Args(); !slices.Equal(got, want) { t.Fatalf("Inference Command = %#v, want %#v", got, want) } - wantInstall := "npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp@0.61.0" + wantInstall := "npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp" if got := a.InstallScript(); got != wantInstall { t.Fatalf("InstallScript = %q, want %q", got, wantInstall) } diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index 5952a10d6d..a5ff6cd286 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -16,16 +16,13 @@ var codexACPLogoLight []byte //go:embed logos/codex_dark.svg var codexACPLogoDark []byte -const ( - codexACPPackage = "@agentclientprotocol/codex-acp" - codexACPVersion = "1.1.5" - codexACPPackageSpec = codexACPPackage + "@" + codexACPVersion -) +const codexACPPackage = "@agentclientprotocol/codex-acp" var ( - _ Agent = (*CodexACP)(nil) - _ PassthroughAgent = (*CodexACP)(nil) - _ InferenceAgent = (*CodexACP)(nil) + _ Agent = (*CodexACP)(nil) + _ PassthroughAgent = (*CodexACP)(nil) + _ InferenceAgent = (*CodexACP)(nil) + _ ManagedNPMRuntimeAgent = (*CodexACP)(nil) ) // CodexACP implements Agent for the Agent Client Protocol codex-acp package. @@ -99,7 +96,11 @@ func (a *CodexACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { } func (a *CodexACP) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", codexACPPackageSpec).Build() + return a.ManagedNPMRuntime().CachedACPCommand() +} + +func (a *CodexACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { + return ManagedNPMRuntimeSpec{Package: codexACPPackage} } func (a *CodexACP) Runtime() *RuntimeConfig { @@ -107,7 +108,7 @@ func (a *CodexACP) Runtime() *RuntimeConfig { return &RuntimeConfig{ Image: "kandev/multi-agent", Tag: "latest", - Cmd: Cmd("npx", "-y", codexACPPackageSpec).Build(), + Cmd: a.ManagedNPMRuntime().CachedACPCommand(), WorkingDir: "{workspace}", RequiredEnv: []string{"OPENAI_API_KEY"}, Env: map[string]string{}, @@ -166,7 +167,7 @@ func (a *CodexACP) LoginCommand() *LoginCommand { // Install both the user-facing OpenAI codex CLI (which `codex login` runs // against) and the ACP bridge package used for chat sessions. func (a *CodexACP) InstallScript() string { - return "npm install -g @openai/codex " + codexACPPackageSpec + return "npm install -g @openai/codex " + codexACPPackage } func (a *CodexACP) BillingType() usage.BillingType { return codexBillingType() } @@ -179,6 +180,6 @@ func (a *CodexACP) PermissionSettings() map[string]PermissionSetting { func (a *CodexACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, - Command: NewCommand("npx", "-y", codexACPPackageSpec), + Command: a.ManagedNPMRuntime().CachedACPCommand(), } } diff --git a/apps/backend/internal/agent/agents/codex_acp_test.go b/apps/backend/internal/agent/agents/codex_acp_test.go index 5801c24f25..bcd8252385 100644 --- a/apps/backend/internal/agent/agents/codex_acp_test.go +++ b/apps/backend/internal/agent/agents/codex_acp_test.go @@ -25,7 +25,7 @@ func TestCodexACP_PermissionSettings_NoBridgeCLIFlags(t *testing.T) { } func TestCodexACP_BuildCommand_NoCodexCLIFlags(t *testing.T) { - want := []string{"npx", "-y", codexACPPackageSpec} + want := []string{"npx", "--yes", "--prefer-offline", codexACPPackage} cmd := NewCodexACP().BuildCommand(CommandOptions{ PermissionValues: map[string]bool{PermissionKeyAutoApprove: true}, }) @@ -36,7 +36,7 @@ func TestCodexACP_BuildCommand_NoCodexCLIFlags(t *testing.T) { func TestCodexACP_UsesAgentClientProtocolBridge(t *testing.T) { a := NewCodexACP() - want := []string{"npx", "-y", "@agentclientprotocol/codex-acp@1.1.5"} + want := []string{"npx", "--yes", "--prefer-offline", "@agentclientprotocol/codex-acp"} if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) { t.Fatalf("BuildCommand = %#v, want %#v", got, want) @@ -47,7 +47,7 @@ func TestCodexACP_UsesAgentClientProtocolBridge(t *testing.T) { if got := a.InferenceConfig().Command.Args(); !slices.Equal(got, want) { t.Fatalf("Inference Command = %#v, want %#v", got, want) } - wantInstall := "npm install -g @openai/codex @agentclientprotocol/codex-acp@1.1.5" + wantInstall := "npm install -g @openai/codex @agentclientprotocol/codex-acp" if got := a.InstallScript(); got != wantInstall { t.Fatalf("InstallScript = %q, want %q", got, wantInstall) } diff --git a/apps/backend/internal/agent/agents/copilot_acp.go b/apps/backend/internal/agent/agents/copilot_acp.go index 137b85f47f..ca7d447cb3 100644 --- a/apps/backend/internal/agent/agents/copilot_acp.go +++ b/apps/backend/internal/agent/agents/copilot_acp.go @@ -15,18 +15,19 @@ var copilotACPLogoLight []byte //go:embed logos/copilot_dark.svg var copilotACPLogoDark []byte -const copilotACPPkg = "@github/copilot" +const copilotACPPackage = "@github/copilot" // copilotNativeBinary is the standalone CLI installed by the @github/copilot -// npm package. When present on PATH we run it directly instead of paying the -// per-launch `npx` registry round-trip. +// npm package. It remains available for discovery and preflight checks, but +// ACP launches always use the exact package pin. const copilotNativeBinary = "copilot" var ( - _ Agent = (*CopilotACP)(nil) - _ PassthroughAgent = (*CopilotACP)(nil) - _ InferenceAgent = (*CopilotACP)(nil) - _ NativeBinaryAgent = (*CopilotACP)(nil) + _ Agent = (*CopilotACP)(nil) + _ PassthroughAgent = (*CopilotACP)(nil) + _ InferenceAgent = (*CopilotACP)(nil) + _ NativeBinaryAgent = (*CopilotACP)(nil) + _ ManagedNPMRuntimeAgent = (*CopilotACP)(nil) ) // CopilotACP implements Agent for GitHub Copilot using ACP protocol mode. @@ -44,7 +45,7 @@ func NewCopilotACP() *CopilotACP { Supported: true, Label: "CLI Passthrough", Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "-y", copilotACPPkg), + PassthroughCmd: NewCommand("npx", "--yes", "--prefer-offline", copilotACPPackage), ModelFlag: NewParam("--model", "{model}"), IdleTimeout: 3 * time.Second, BufferMaxBytes: DefaultBufferMaxBytes, @@ -89,17 +90,18 @@ func (a *CopilotACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) // execution environment. See NativeBinaryAgent. func (a *CopilotACP) NativeBinaryName() string { return copilotNativeBinary } -func (a *CopilotACP) BuildCommand(opts CommandOptions) Command { - if opts.PreferNativeBinary { - return Cmd(copilotNativeBinary, "--acp").Build() - } - return Cmd("npx", "-y", copilotACPPkg, "--acp").Build() +func (a *CopilotACP) BuildCommand(CommandOptions) Command { + return a.ManagedNPMRuntime().CachedACPCommand() +} + +func (a *CopilotACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { + return ManagedNPMRuntimeSpec{Package: copilotACPPackage, ACPArgs: []string{"--acp"}} } func (a *CopilotACP) Runtime() *RuntimeConfig { canRecover := true return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", copilotACPPkg, "--acp").Build(), + Cmd: a.ManagedNPMRuntime().CachedACPCommand(), WorkingDir: "{workspace}", Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, @@ -126,7 +128,7 @@ func (a *CopilotACP) LoginCommand() *LoginCommand { } func (a *CopilotACP) InstallScript() string { - return "npm install -g " + copilotACPPkg + return "npm install -g " + copilotACPPackage } func (a *CopilotACP) BillingType() usage.BillingType { return defaultBillingType() } @@ -186,6 +188,6 @@ var copilotPermSettings = map[string]PermissionSetting{ func (a *CopilotACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, - Command: NewCommand("npx", "-y", copilotACPPkg, "--acp"), + Command: a.ManagedNPMRuntime().CachedACPCommand(), } } diff --git a/apps/backend/internal/agent/agents/copilot_acp_test.go b/apps/backend/internal/agent/agents/copilot_acp_test.go index 4ef9f2dd8e..640ab4b852 100644 --- a/apps/backend/internal/agent/agents/copilot_acp_test.go +++ b/apps/backend/internal/agent/agents/copilot_acp_test.go @@ -1,6 +1,9 @@ package agents -import "testing" +import ( + "slices" + "testing" +) // TestCopilotACP_PermissionSettings_Curated verifies the four curated flag // suggestions surfaced to the profile-creation UI. `allow_all_tools` is @@ -47,7 +50,7 @@ func TestCopilotACP_BuildCommand_NoCLIFlagSpecialCasing(t *testing.T) { ag := NewCopilotACP() cmd := ag.BuildCommand(CommandOptions{}) got := cmd.Args() - want := []string{"npx", "-y", "@github/copilot", "--acp"} + want := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} if len(got) != len(want) { t.Fatalf("argv length mismatch\n got: %#v\n want: %#v", got, want) } @@ -58,12 +61,29 @@ func TestCopilotACP_BuildCommand_NoCLIFlagSpecialCasing(t *testing.T) { } } -// TestCopilotACP_BuildCommand_PreferNativeBinary verifies that when the -// lifecycle has found the standalone `copilot` CLI in the execution -// environment, BuildCommand emits it directly instead of `npx -y ` — -// skipping the npm registry round-trip that makes launches slow behind a -// private registry. -func TestCopilotACP_BuildCommand_PreferNativeBinary(t *testing.T) { +func TestCopilotACP_UsesManagedPackageOnEveryNPMCommandSurface(t *testing.T) { + ag := NewCopilotACP() + wantACP := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} + wantPassthrough := []string{"npx", "--yes", "--prefer-offline", "@github/copilot"} + + if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, wantACP) { + t.Fatalf("Runtime Cmd = %#v, want %#v", got, wantACP) + } + if got := ag.InferenceConfig().Command.Args(); !slices.Equal(got, wantACP) { + t.Fatalf("Inference Command = %#v, want %#v", got, wantACP) + } + if got := ag.PassthroughConfig().PassthroughCmd.Args(); !slices.Equal(got, wantPassthrough) { + t.Fatalf("Passthrough Cmd = %#v, want %#v", got, wantPassthrough) + } + if got, want := ag.InstallScript(), "npm install -g @github/copilot"; got != want { + t.Fatalf("InstallScript = %q, want %q", got, want) + } +} + +// TestCopilotACP_BuildCommand_KeepsManagedPackageWhenNativeBinaryIsPresent +// prevents a globally installed Copilot binary from bypassing the managed npm +// runtime selected for capability discovery and future launches. +func TestCopilotACP_BuildCommand_KeepsManagedPackageWhenNativeBinaryIsPresent(t *testing.T) { ag := NewCopilotACP() if name := ag.NativeBinaryName(); name != "copilot" { @@ -71,7 +91,7 @@ func TestCopilotACP_BuildCommand_PreferNativeBinary(t *testing.T) { } native := ag.BuildCommand(CommandOptions{PreferNativeBinary: true}).Args() - wantNative := []string{"copilot", "--acp"} + wantNative := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} if len(native) != len(wantNative) { t.Fatalf("native argv mismatch\n got: %#v\n want: %#v", native, wantNative) } diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index 920b23ee06..c7e3a4ca1d 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -15,12 +15,13 @@ var geminiLogoLight []byte //go:embed logos/gemini_dark.svg var geminiLogoDark []byte -const geminiPkg = "@google/gemini-cli" +const geminiPackage = "@google/gemini-cli" var ( - _ Agent = (*Gemini)(nil) - _ PassthroughAgent = (*Gemini)(nil) - _ InferenceAgent = (*Gemini)(nil) + _ Agent = (*Gemini)(nil) + _ PassthroughAgent = (*Gemini)(nil) + _ InferenceAgent = (*Gemini)(nil) + _ ManagedNPMRuntimeAgent = (*Gemini)(nil) ) type Gemini struct { @@ -35,7 +36,7 @@ func NewGemini() *Gemini { Supported: true, Label: "CLI Passthrough", Description: "Show terminal directly instead of chat interface", - PassthroughCmd: NewCommand("npx", "@google/gemini-cli"), + PassthroughCmd: NewCommand("npx", "--yes", "--prefer-offline", geminiPackage), ModelFlag: NewParam("--model", "{model}"), PromptFlag: NewParam("--prompt-interactive", "{prompt}"), IdleTimeout: 3 * time.Second, @@ -77,13 +78,17 @@ func (a *Gemini) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { } func (a *Gemini) BuildCommand(opts CommandOptions) Command { - return Cmd("npx", "-y", geminiPkg, "--acp").Build() + return a.ManagedNPMRuntime().CachedACPCommand() +} + +func (a *Gemini) ManagedNPMRuntime() ManagedNPMRuntimeSpec { + return ManagedNPMRuntimeSpec{Package: geminiPackage, ACPArgs: []string{"--acp"}} } func (a *Gemini) Runtime() *RuntimeConfig { canRecover := false return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", geminiPkg, "--acp").Build(), + Cmd: a.ManagedNPMRuntime().CachedACPCommand(), WorkingDir: "{workspace}", Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, @@ -127,7 +132,7 @@ func (a *Gemini) LoginCommand() *LoginCommand { } func (a *Gemini) InstallScript() string { - return "npm install -g " + geminiPkg + return "npm install -g " + geminiPackage } func (a *Gemini) BillingType() usage.BillingType { return defaultBillingType() } @@ -142,6 +147,6 @@ func (a *Gemini) PermissionSettings() map[string]PermissionSetting { func (a *Gemini) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, - Command: NewCommand("npx", "-y", geminiPkg, "--acp"), + Command: a.ManagedNPMRuntime().CachedACPCommand(), } } diff --git a/apps/backend/internal/agent/agents/gemini_test.go b/apps/backend/internal/agent/agents/gemini_test.go new file mode 100644 index 0000000000..c29680a33d --- /dev/null +++ b/apps/backend/internal/agent/agents/gemini_test.go @@ -0,0 +1,28 @@ +package agents + +import ( + "slices" + "testing" +) + +func TestGeminiUsesManagedPackageOnEveryNPMCommandSurface(t *testing.T) { + ag := NewGemini() + wantACP := []string{"npx", "--yes", "--prefer-offline", "@google/gemini-cli", "--acp"} + wantPassthrough := []string{"npx", "--yes", "--prefer-offline", "@google/gemini-cli"} + + if got := ag.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, wantACP) { + t.Fatalf("BuildCommand = %#v, want %#v", got, wantACP) + } + if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, wantACP) { + t.Fatalf("Runtime Cmd = %#v, want %#v", got, wantACP) + } + if got := ag.InferenceConfig().Command.Args(); !slices.Equal(got, wantACP) { + t.Fatalf("Inference Command = %#v, want %#v", got, wantACP) + } + if got := ag.PassthroughConfig().PassthroughCmd.Args(); !slices.Equal(got, wantPassthrough) { + t.Fatalf("Passthrough Cmd = %#v, want %#v", got, wantPassthrough) + } + if got, want := ag.InstallScript(), "npm install -g @google/gemini-cli"; got != want { + t.Fatalf("InstallScript = %q, want %q", got, want) + } +} diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime.go b/apps/backend/internal/agent/agents/managed_npm_runtime.go new file mode 100644 index 0000000000..c38b95583b --- /dev/null +++ b/apps/backend/internal/agent/agents/managed_npm_runtime.go @@ -0,0 +1,35 @@ +package agents + +// ManagedNPMRuntimeSpec defines a built-in npm-distributed ACP runtime. +// Package and ACPArgs must come from trusted agent metadata, never request +// input, because update jobs execute them directly. +type ManagedNPMRuntimeSpec struct { + Package string + ACPArgs []string +} + +// 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} + 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 { + return NewCommand( + "npm", + "exec", + "--yes", + "--prefer-online", + "--package="+s.Package, + "--", + "node", + "-e", + "", + ) +} diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go new file mode 100644 index 0000000000..417b07fa2a --- /dev/null +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go @@ -0,0 +1,64 @@ +package agents + +import ( + "slices" + "strings" + "testing" +) + +func TestManagedNPMRuntimeContracts(t *testing.T) { + tests := []struct { + name string + agent ManagedNPMRuntimeAgent + wantPackage string + wantACPArgs []string + }{ + {"claude", NewClaudeACP(), "@agentclientprotocol/claude-agent-acp", nil}, + {"codex", NewCodexACP(), "@agentclientprotocol/codex-acp", nil}, + {"opencode", NewOpenCodeACP(), "opencode-ai", []string{"acp"}}, + {"copilot", NewCopilotACP(), "@github/copilot", []string{"--acp"}}, + {"gemini", NewGemini(), "@google/gemini-cli", []string{"--acp"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := tt.agent.ManagedNPMRuntime() + if got := spec.Package; got != tt.wantPackage { + t.Fatalf("Package = %q, want %q", got, tt.wantPackage) + } + if got := spec.ACPArgs; !slices.Equal(got, tt.wantACPArgs) { + t.Fatalf("ACPArgs = %#v, want %#v", got, tt.wantACPArgs) + } + + cached := spec.CachedACPCommand() + wantCached := append([]string{"npx", "--yes", "--prefer-offline", tt.wantPackage}, tt.wantACPArgs...) + if !slices.Equal(cached.Args(), wantCached) { + t.Fatalf("CachedACPCommand = %#v, want %#v", cached.Args(), wantCached) + } + assertUnversionedPackage(t, cached.Args(), tt.wantPackage) + + update := spec.CacheUpdateCommand() + wantUpdate := []string{ + "npm", "exec", "--yes", "--prefer-online", + "--package=" + tt.wantPackage, "--", "node", "-e", "", + } + if !slices.Equal(update.Args(), wantUpdate) { + t.Fatalf("CacheUpdateCommand = %#v, want %#v", update.Args(), wantUpdate) + } + if strings.Contains(strings.Join(update.Args(), " "), "latest") { + t.Fatalf("CacheUpdateCommand contains explicit latest: %#v", update.Args()) + } + }) + } +} + +func assertUnversionedPackage(t *testing.T, argv []string, wantPackage string) { + t.Helper() + packageArg := argv[3] + if packageArg != wantPackage { + t.Fatalf("package argv = %q, want unversioned %q", packageArg, wantPackage) + } + if packageArg == wantPackage+"@latest" { + t.Fatalf("package argv contains explicit latest: %q", packageArg) + } +} diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index 2b72a9e6b6..bc59b16c66 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -16,16 +16,13 @@ var opencodeACPLogoLight []byte //go:embed logos/opencode_dark.svg var opencodeACPLogoDark []byte -const ( - opencodeACPPackage = "opencode-ai" - opencodeACPVersion = "1.18.4" - opencodeACPPackageSpec = opencodeACPPackage + "@" + opencodeACPVersion -) +const opencodeACPPackage = "opencode-ai" var ( - _ Agent = (*OpenCodeACP)(nil) - _ PassthroughAgent = (*OpenCodeACP)(nil) - _ InferenceAgent = (*OpenCodeACP)(nil) + _ Agent = (*OpenCodeACP)(nil) + _ PassthroughAgent = (*OpenCodeACP)(nil) + _ InferenceAgent = (*OpenCodeACP)(nil) + _ ManagedNPMRuntimeAgent = (*OpenCodeACP)(nil) ) // OpenCodeACP is the ACP protocol variant of OpenCode. @@ -88,13 +85,17 @@ func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) } func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command { - return Cmd("opencode", "acp").Build() + return a.ManagedNPMRuntime().CachedACPCommand() +} + +func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { + return ManagedNPMRuntimeSpec{Package: opencodeACPPackage, ACPArgs: []string{"acp"}} } func (a *OpenCodeACP) Runtime() *RuntimeConfig { canRecover := true return &RuntimeConfig{ - Cmd: Cmd("opencode", "acp").Build(), + Cmd: a.ManagedNPMRuntime().CachedACPCommand(), WorkingDir: "{workspace}", Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, @@ -132,7 +133,7 @@ func (a *OpenCodeACP) RemoteAuth() *RemoteAuth { } func (a *OpenCodeACP) InstallScript() string { - return "npm install -g " + opencodeACPPackageSpec + return "npm install -g " + opencodeACPPackage } func (a *OpenCodeACP) BillingType() usage.BillingType { return defaultBillingType() } @@ -145,6 +146,6 @@ func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting { func (a *OpenCodeACP) InferenceConfig() *InferenceConfig { return &InferenceConfig{ Supported: true, - Command: NewCommand("opencode", "acp"), + Command: a.ManagedNPMRuntime().CachedACPCommand(), } } diff --git a/apps/backend/internal/agent/agents/opencode_acp_test.go b/apps/backend/internal/agent/agents/opencode_acp_test.go index d923c290e5..f306733cc0 100644 --- a/apps/backend/internal/agent/agents/opencode_acp_test.go +++ b/apps/backend/internal/agent/agents/opencode_acp_test.go @@ -8,9 +8,9 @@ import ( "testing" ) -func TestOpenCodeACPUsesInstalledBinaryAndPinnedInstaller(t *testing.T) { +func TestOpenCodeACPUsesManagedRuntime(t *testing.T) { a := NewOpenCodeACP() - want := []string{"opencode", "acp"} + want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai", "acp"} if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) { t.Fatalf("BuildCommand = %#v, want %#v", got, want) @@ -21,12 +21,12 @@ func TestOpenCodeACPUsesInstalledBinaryAndPinnedInstaller(t *testing.T) { if got := a.InferenceConfig().Command.Args(); !slices.Equal(got, want) { t.Fatalf("Inference Command = %#v, want %#v", got, want) } - if got, wantInstall := a.InstallScript(), "npm install -g opencode-ai@1.18.4"; got != wantInstall { + if got, wantInstall := a.InstallScript(), "npm install -g opencode-ai"; got != wantInstall { t.Fatalf("InstallScript = %q, want %q", got, wantInstall) } } -func TestOpenCodeACPDiscoveryMatchesRuntimeExecutable(t *testing.T) { +func TestOpenCodeACPDiscoveryRecognizesAuthenticationHelper(t *testing.T) { binaryPath := writeOpenCodeTestBinary(t, "exit 7") a := NewOpenCodeACP() @@ -40,9 +40,6 @@ func TestOpenCodeACPDiscoveryMatchesRuntimeExecutable(t *testing.T) { if result.MatchedPath != binaryPath { t.Fatalf("IsInstalled() MatchedPath = %q, want %q", result.MatchedPath, binaryPath) } - if got := a.BuildCommand(CommandOptions{}).Args()[0]; got != filepath.Base(binaryPath) { - t.Fatalf("runtime executable = %q, discovered executable = %q", got, filepath.Base(binaryPath)) - } } func TestOpenCodeACPDiscoveryDoesNotRunVersionCommand(t *testing.T) { diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index 4b82bf7300..461d661fd3 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -523,10 +523,20 @@ const probeTimeout = 60 * time.Second // probe runs an ACP probe against the given instance and translates the result // into an AgentCapabilities record suitable for the cache. func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent, refresh bool) AgentCapabilities { + return m.probeWithCommand(ctx, inst, ia, refresh, agents.Command{}) +} + +func (m *Manager) probeWithCommand( + ctx context.Context, + inst *instance, + ia agents.InferenceAgent, + refresh bool, + command agents.Command, +) AgentCapabilities { probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - req := buildProbeRequest(inst, ia, refresh) + req := buildProbeRequest(inst, ia, refresh, command) resp, err := inst.client.Probe(probeCtx, req) now := time.Now() if err != nil { @@ -600,13 +610,18 @@ func buildProbeRequest( inst *instance, ia agents.InferenceAgent, refresh bool, + command agents.Command, ) *agentctlutil.ProbeRequest { cfg := ia.InferenceConfig() + probeCommand := cfg.Command + if !command.IsEmpty() { + probeCommand = command + } return &agentctlutil.ProbeRequest{ AgentID: inst.agentType, Refresh: refresh, InferenceConfig: &agentctlutil.InferenceConfigDTO{ - Command: cfg.Command.Args(), + Command: probeCommand.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, StripEnv: agents.StripEnvFor(ia), diff --git a/apps/backend/internal/agent/hostutility/manager_test.go b/apps/backend/internal/agent/hostutility/manager_test.go index d565cbbd9a..0b19a23b18 100644 --- a/apps/backend/internal/agent/hostutility/manager_test.go +++ b/apps/backend/internal/agent/hostutility/manager_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strconv" "strings" "sync" @@ -25,6 +26,114 @@ import ( "github.com/stretchr/testify/require" ) +func TestManagerExposesRefreshWithCommand(t *testing.T) { + _, ok := reflect.TypeOf((*Manager)(nil)).MethodByName("RefreshWithCommand") + require.True(t, ok, "managed runtime updates need a command-override refresh") +} + +func TestRefreshWithCommandUsesOverrideAndPreservesCacheOnAuthFailure(t *testing.T) { + log := newTestLogger(t) + reg := registry.NewRegistry(log) + const agentType = "managed-acp" + require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType})) + + var requestedCommand []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/inference/probe": + var request agentctlutil.ProbeRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + requestedCommand = append([]string(nil), request.InferenceConfig.Command...) + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{ + Success: false, + Error: "login required", + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + host, port := serverHostPort(t, server) + + mgr := NewManager(reg, host, port, nil, log) + mgr.cache.set(AgentCapabilities{ + AgentType: agentType, + AgentVersion: "1.0.0", + Status: StatusOK, + Models: []Model{{ID: "old-model", Name: "Old model"}}, + }) + mgr.instances[agentType] = &instance{ + agentType: agentType, + workDir: t.TempDir(), + client: agentctlclient.NewClient(host, port, log), + } + + caps, err := mgr.RefreshWithCommand( + context.Background(), + agentType, + agents.NewCommand("npx", "--yes", "--prefer-offline", "@example/managed-acp"), + ) + require.NoError(t, err) + require.Equal(t, StatusAuthRequired, caps.Status) + require.Equal(t, + []string{"npx", "--yes", "--prefer-offline", "@example/managed-acp"}, + requestedCommand, + ) + cached, ok := mgr.Get(agentType) + require.True(t, ok) + require.Equal(t, "1.0.0", cached.AgentVersion) + require.Equal(t, "old-model", cached.Models[0].ID) +} + +func TestRefreshWithCommandReplacesCacheAfterSuccessfulProbe(t *testing.T) { + log := newTestLogger(t) + reg := registry.NewRegistry(log) + const agentType = "managed-acp" + require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType})) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/inference/probe": + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{ + Success: true, + AgentVersion: "1.1.0", + Models: []agentctlutil.ProbeModel{{ + ID: "new-model", + Name: "New model", + }}, + })) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + host, port := serverHostPort(t, server) + mgr := NewManager(reg, host, port, nil, log) + mgr.cache.set(AgentCapabilities{AgentType: agentType, AgentVersion: "1.0.0", Status: StatusOK}) + mgr.instances[agentType] = &instance{ + agentType: agentType, + workDir: t.TempDir(), + client: agentctlclient.NewClient(host, port, log), + } + + _, err := mgr.RefreshWithCommand( + context.Background(), + agentType, + agents.NewCommand("npx", "@example/managed-acp"), + ) + require.NoError(t, err) + cached, ok := mgr.Get(agentType) + require.True(t, ok) + require.Equal(t, "1.1.0", cached.AgentVersion) + require.Equal(t, "new-model", cached.Models[0].ID) +} + func TestStopCancelsInFlightBootstrap(t *testing.T) { log := newTestLogger(t) reg := registry.NewRegistry(log) diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index 866722699b..941302fe5f 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -55,6 +55,26 @@ func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilit return caps, nil } +// RefreshWithCommand probes an agent with a trusted command override. Unlike +// Refresh, it preserves the last successful capability record unless the +// override probe succeeds. Runtime-update callers use this after priming npm's +// cache so a failed new runtime cannot erase the model list currently shown. +func (m *Manager) RefreshWithCommand( + ctx context.Context, + agentType string, + command agents.Command, +) (AgentCapabilities, error) { + inst, ia, err := m.getInstance(ctx, agentType) + if err != nil { + return AgentCapabilities{}, err + } + caps := m.probeWithCommand(ctx, inst, ia, true, command) + if caps.Status == StatusOK { + m.cache.set(caps) + } + return caps, nil +} + // 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 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 be9ca367ce..9477b377e7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -129,7 +129,7 @@ func TestBuildAgentCommand_ResumeFlag(t *testing.T) { }) } -func TestBuildAgentCommand_PreservesPinnedACPBridgeSpecs(t *testing.T) { +func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) { mgr := newTestManager(t) tests := []struct { name string @@ -139,23 +139,33 @@ func TestBuildAgentCommand_PreservesPinnedACPBridgeSpecs(t *testing.T) { { name: "claude", agent: agents.NewClaudeACP(), - want: "npx -y @agentclientprotocol/claude-agent-acp@0.61.0", + want: "npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp", }, { name: "codex", agent: agents.NewCodexACP(), - want: "npx -y @agentclientprotocol/codex-acp@1.1.5", + want: "npx --yes --prefer-offline @agentclientprotocol/codex-acp", }, { name: "opencode", agent: agents.NewOpenCodeACP(), - want: "opencode acp", + want: "npx --yes --prefer-offline opencode-ai acp", + }, + { + name: "copilot", + agent: agents.NewCopilotACP(), + want: "npx --yes --prefer-offline @github/copilot --acp", + }, + { + name: "gemini", + agent: agents.NewGemini(), + want: "npx --yes --prefer-offline @google/gemini-cli --acp", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, nil, tt.agent, false) + cmds, err := mgr.buildAgentCommand(&LaunchRequest{}, nil, tt.agent, true) require.NoError(t, err) require.Equal(t, tt.want, cmds.initial) }) diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 1643490779..56959a59fc 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -132,6 +132,7 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent } loginCommand := buildLoginCommandDTO(ag) + runtimeUpdate := c.buildRuntimeUpdateDTO(ag, availability.Available) return dto.AvailableAgentDTO{ Name: ag.ID(), @@ -148,10 +149,38 @@ func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent PermissionSettings: permissionSettings, PassthroughConfig: passthroughConfig, LoginCommand: loginCommand, + RuntimeUpdate: runtimeUpdate, UpdatedAt: now, } } +func (c *Controller) buildRuntimeUpdateDTO(ag agents.Agent, available bool) *dto.RuntimeUpdateDTO { + if !available { + return nil + } + managed, ok := ag.(agents.ManagedNPMRuntimeAgent) + if !ok { + return nil + } + spec := managed.ManagedNPMRuntime() + if spec.Package == "" { + return nil + } + item := &dto.RuntimeUpdateDTO{Supported: true, Package: spec.Package} + if c.runtimeUpdater != nil { + if caps, found := c.runtimeUpdater.CurrentCapabilities(ag.ID()); found { + item.CurrentVersion = caps.AgentVersion + } + return item + } + if c.hostUtility != nil { + if caps, found := c.hostUtility.Get(ag.ID()); found { + item.CurrentVersion = caps.AgentVersion + } + } + return item +} + // buildLoginCommandDTO surfaces the interactive login command for agents that // implement LoginAgent. Nil for agents without an interactive login. func buildLoginCommandDTO(ag agents.Agent) *dto.LoginCommandDTO { diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery_test.go b/apps/backend/internal/agent/settings/controller/agent_discovery_test.go index 5bdf6bed59..18c8b23f62 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery_test.go @@ -1,12 +1,89 @@ package controller import ( + "context" "encoding/json" "testing" + "time" + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/hostutility" ) +type managedTestAgent struct { + testAgent + spec agents.ManagedNPMRuntimeSpec +} + +func (a *managedTestAgent) ManagedNPMRuntime() agents.ManagedNPMRuntimeSpec { + return a.spec +} + +func TestAvailableAgentIncludesManagedRuntimeMetadata(t *testing.T) { + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: agents.ManagedNPMRuntimeSpec{Package: "@example/managed-acp"}, + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + ctrl.SetRuntimeUpdater(&fakeRuntimeUpdater{ + current: hostutility.AgentCapabilities{AgentVersion: "1.2.3"}, + currentFound: true, + }) + + item := ctrl.buildAvailableAgentDTO(context.Background(), ag, discovery.Availability{ + Name: ag.ID(), + Available: true, + }, time.Now()) + + raw, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal available agent: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal available agent: %v", err) + } + runtimeUpdate, ok := payload["runtime_update"].(map[string]any) + if !ok { + t.Fatal("runtime_update missing, want managed runtime metadata") + } + if runtimeUpdate["package"] != "@example/managed-acp" { + t.Fatalf("package = %#v, want @example/managed-acp", runtimeUpdate["package"]) + } + if runtimeUpdate["supported"] != true { + t.Fatalf("supported = %#v, want true", runtimeUpdate["supported"]) + } + if runtimeUpdate["current_version"] != "1.2.3" { + t.Fatalf("current_version = %#v, want 1.2.3", runtimeUpdate["current_version"]) + } +} + +func TestUnavailableManagedAgentOmitsRuntimeMetadata(t *testing.T) { + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: agents.ManagedNPMRuntimeSpec{Package: "@example/managed-acp"}, + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + item := ctrl.buildAvailableAgentDTO( + context.Background(), + ag, + discovery.Availability{Name: ag.ID(), Available: false}, + time.Now(), + ) + raw, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal available agent: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal available agent: %v", err) + } + if _, exists := payload["runtime_update"]; exists { + t.Fatal("runtime_update present for unavailable agent") + } +} + func TestConfigOptionDTOsPreserveDescriptions(t *testing.T) { dtos := configOptionDTOs([]hostutility.ConfigOption{{ Type: "select", diff --git a/apps/backend/internal/agent/settings/controller/agent_install.go b/apps/backend/internal/agent/settings/controller/agent_install.go index 8b702ae768..e8ae5e88d6 100644 --- a/apps/backend/internal/agent/settings/controller/agent_install.go +++ b/apps/backend/internal/agent/settings/controller/agent_install.go @@ -31,7 +31,10 @@ func (c *Controller) EnqueueInstall(name string) (*dto.InstallJobDTO, error) { if script == "" { return nil, ErrInstallScriptEmpty } - job := c.jobStore.Enqueue(name, script) + job, err := c.jobStore.Enqueue(name, script) + if err != nil { + return nil, err + } // Get() takes the store mutex, so it's race-free; calling job.snapshot() // directly here would read job.Status without the lock while the spawned // goroutine may be writing it. diff --git a/apps/backend/internal/agent/settings/controller/agent_update.go b/apps/backend/internal/agent/settings/controller/agent_update.go new file mode 100644 index 0000000000..1c5491f4ba --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_update.go @@ -0,0 +1,208 @@ +package controller + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "sync" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +var ( + ErrRuntimeUpdateUnsupported = errors.New("agent runtime update unsupported") + ErrRuntimeUpdaterUnavailable = errors.New("agent runtime updater unavailable") +) + +// RuntimeUpdater is the external-process and host-probe boundary used by +// managed runtime jobs. Implementations must execute commands as direct argv. +type RuntimeUpdater interface { + CurrentCapabilities(agentName string) (hostutility.AgentCapabilities, bool) + ResolveTarget(ctx context.Context, packageName string) (string, error) + RunUpdate(ctx context.Context, command agents.Command, onChunk func(string)) error + Refresh( + ctx context.Context, + agentName string, + command agents.Command, + ) (hostutility.AgentCapabilities, error) +} + +type hostRuntimeUpdater struct { + host *hostutility.Manager + executor directCommandExecutor +} + +type directCommandExecutor interface { + Output(ctx context.Context, command agents.Command) (string, error) + Stream(ctx context.Context, command agents.Command, onChunk func(string)) error +} + +type execDirectCommandExecutor struct{} + +func (execDirectCommandExecutor) Output( + ctx context.Context, + command agents.Command, +) (string, error) { + return runDirectCommandOutput(ctx, command) +} + +func (execDirectCommandExecutor) Stream( + ctx context.Context, + command agents.Command, + onChunk func(string), +) error { + return runDirectCommand(ctx, command, onChunk) +} + +func (u *hostRuntimeUpdater) CurrentCapabilities(agentName string) (hostutility.AgentCapabilities, bool) { + return u.host.Get(agentName) +} + +func (u *hostRuntimeUpdater) ResolveTarget(ctx context.Context, packageName string) (string, error) { + command := agents.NewCommand("npm", "view", packageName, "dist-tags.latest", "--json") + output, err := u.executor.Output(ctx, command) + if err != nil { + return "", err + } + var target string + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &target); err != nil { + return "", fmt.Errorf("parse npm target version: %w", err) + } + if strings.TrimSpace(target) == "" { + return "", errors.New("npm target version is empty") + } + return strings.TrimSpace(target), nil +} + +func runDirectCommandOutput(ctx context.Context, command agents.Command) (string, error) { + argv := command.Args() + if len(argv) == 0 { + return "", errors.New("runtime update command is empty") + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Env = filteredInstallEnv() + output, err := cmd.Output() + if err != nil { + return "", err + } + return string(output), nil +} + +func (u *hostRuntimeUpdater) RunUpdate( + ctx context.Context, + command agents.Command, + onChunk func(string), +) error { + return u.executor.Stream(ctx, command, onChunk) +} + +func (u *hostRuntimeUpdater) Refresh( + ctx context.Context, + agentName string, + command agents.Command, +) (hostutility.AgentCapabilities, error) { + return u.host.RefreshWithCommand(ctx, agentName, command) +} + +func runDirectCommand(ctx context.Context, command agents.Command, onChunk func(string)) error { + argv := command.Args() + if len(argv) == 0 { + return errors.New("runtime update command is empty") + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Env = filteredInstallEnv() + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + + errCh := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go scanCommandOutput(stdout, onChunk, &wg, errCh) + go scanCommandOutput(stderr, onChunk, &wg, errCh) + wg.Wait() + close(errCh) + if err := cmd.Wait(); err != nil { + return err + } + for scanErr := range errCh { + if scanErr != nil { + return scanErr + } + } + return nil +} + +func scanCommandOutput( + reader io.Reader, + onChunk func(string), + wg *sync.WaitGroup, + errCh chan<- error, +) { + defer wg.Done() + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + onChunk(scanner.Text() + "\n") + } + if err := scanner.Err(); err != nil { + errCh <- err + } +} + +// EnqueueAgentUpdate starts or reuses an update for a built-in managed agent. +func (c *Controller) EnqueueAgentUpdate(name string) (*dto.AgentUpdateJobDTO, error) { + if c.updateJobStore == nil || c.runtimeUpdater == nil { + return nil, ErrRuntimeUpdaterUnavailable + } + ag, ok := c.agentRegistry.Get(name) + if !ok { + return nil, ErrAgentNotFound + } + managed, ok := ag.(agents.ManagedNPMRuntimeAgent) + if !ok { + return nil, ErrRuntimeUpdateUnsupported + } + spec := managed.ManagedNPMRuntime() + if strings.TrimSpace(spec.Package) == "" { + return nil, ErrRuntimeUpdateUnsupported + } + job, err := c.updateJobStore.Enqueue(name, spec) + if err != nil { + return nil, err + } + if snapshot, found := c.updateJobStore.Get(job.ID); found { + return snapshot, nil + } + snapshot := job.snapshot() + return &snapshot, nil +} + +func (c *Controller) ListAgentUpdateJobs() []dto.AgentUpdateJobDTO { + if c.updateJobStore == nil { + return nil + } + return c.updateJobStore.ListAll() +} + +func (c *Controller) GetAgentUpdateJob(id string) (*dto.AgentUpdateJobDTO, bool) { + if c.updateJobStore == nil { + return nil, false + } + return c.updateJobStore.Get(id) +} diff --git a/apps/backend/internal/agent/settings/controller/agent_update_job.go b/apps/backend/internal/agent/settings/controller/agent_update_job.go new file mode 100644 index 0000000000..a5b012c7af --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_update_job.go @@ -0,0 +1,343 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "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/settings/dto" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type AgentUpdateJob struct { + ID string + AgentName string + Status dto.AgentUpdateJobStatus + CurrentVersion string + TargetVersion string + Output *ringBuffer + StartedAt time.Time + FinishedAt *time.Time + Error string + RefreshError string +} + +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() +} + +func NewAgentUpdateJobStore( + hub JobBroadcaster, + log *zap.Logger, + updater RuntimeUpdater, + maintenance *maintenanceCoordinator, + onRefresh func(), +) *AgentUpdateJobStore { + if maintenance == nil { + maintenance = newMaintenanceCoordinator() + } + 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, + } +} + +func (s *AgentUpdateJobStore) Enqueue( + agentName string, + spec agents.ManagedNPMRuntimeSpec, +) (*AgentUpdateJob, error) { + s.mu.Lock() + if existing, ok := s.activeByAgt[agentName]; ok { + s.mu.Unlock() + return existing, nil + } + job := &AgentUpdateJob{ + ID: uuid.NewString(), + AgentName: agentName, + Status: dto.AgentUpdateJobStatusQueued, + Output: newRingBuffer(jobOutputRingSize), + StartedAt: time.Now().UTC(), + } + ref, claimed, err := s.maintenance.claim(agentName, MaintenanceKindUpdate, job.ID) + if err != nil { + s.mu.Unlock() + return nil, err + } + if !claimed { + existing := s.jobs[ref.JobID] + s.mu.Unlock() + return existing, nil + } + s.jobs[job.ID] = job + s.activeByAgt[agentName] = job + s.mu.Unlock() + + s.broadcast(ws.ActionAgentUpdateStarted, job.snapshot()) + go s.run(job, spec, ref) + return job, nil +} + +func (s *AgentUpdateJobStore) Get(jobID string) (*dto.AgentUpdateJobDTO, bool) { + s.mu.Lock() + defer s.mu.Unlock() + job, ok := s.jobs[jobID] + if !ok { + return nil, false + } + snapshot := job.snapshot() + return &snapshot, true +} + +func (s *AgentUpdateJobStore) ListAll() []dto.AgentUpdateJobDTO { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]dto.AgentUpdateJobDTO, 0, len(s.jobs)) + for _, job := range s.jobs { + out = append(out, job.snapshot()) + } + return out +} + +func (s *AgentUpdateJobStore) run( + job *AgentUpdateJob, + spec agents.ManagedNPMRuntimeSpec, + ref MaintenanceJobRef, +) { + s.semaphore <- struct{}{} + defer func() { <-s.semaphore }() + + ctx, cancel := context.WithTimeout(context.Background(), jobHardTimeout) + defer cancel() + s.setStatus(job, dto.AgentUpdateJobStatusResolving) + + if caps, ok := s.updater.CurrentCapabilities(job.AgentName); ok { + s.mu.Lock() + job.CurrentVersion = caps.AgentVersion + s.mu.Unlock() + } + target, err := s.updater.ResolveTarget(ctx, spec.Package) + if err != nil { + s.finishFailed(job, ctx, fmt.Errorf("resolve target version: %w", err), ref) + return + } + s.mu.Lock() + job.TargetVersion = target + s.mu.Unlock() + + s.setStatus(job, dto.AgentUpdateJobStatusUpdating) + flusher := newUpdateOutputFlusher(s, job) + err = s.updater.RunUpdate(ctx, spec.CacheUpdateCommand(), flusher.append) + flusher.flush() + if err != nil { + s.finishFailed(job, ctx, fmt.Errorf("update runtime: %w", err), ref) + return + } + + s.setStatus(job, dto.AgentUpdateJobStatusRefreshing) + caps, refreshErr := s.updater.Refresh(ctx, job.AgentName, spec.CachedACPCommand()) + s.finishRefresh(job, ctx, caps, refreshErr, ref) +} + +func (s *AgentUpdateJobStore) setStatus(job *AgentUpdateJob, status dto.AgentUpdateJobStatus) { + s.mu.Lock() + job.Status = status + snapshot := job.snapshot() + s.mu.Unlock() + // A single in-progress action carries every queued-to-active transition so + // clients can upsert one job snapshot without subscribing to phase-specific + // actions. Terminal states use ActionAgentUpdateFinished below. + s.broadcast(ws.ActionAgentUpdateStarted, snapshot) +} + +func (s *AgentUpdateJobStore) finishFailed( + job *AgentUpdateJob, + ctx context.Context, + err error, + ref MaintenanceJobRef, +) { + s.mu.Lock() + job.Status = dto.AgentUpdateJobStatusFailed + job.Error = formatUpdateJobError(ctx, err) + // Release the shared claim while the local active entry is still present. + // That makes same-agent retries atomic from the perspective of Enqueue. + s.maintenance.release(job.AgentName, ref) + s.finishLocked(job) + snapshot := job.snapshot() + s.mu.Unlock() + s.broadcast(ws.ActionAgentUpdateFinished, snapshot) + s.scheduleEviction(job.ID) +} + +func (s *AgentUpdateJobStore) finishRefresh( + job *AgentUpdateJob, + ctx context.Context, + caps hostutility.AgentCapabilities, + err error, + ref MaintenanceJobRef, +) { + refreshed := false + s.mu.Lock() + switch { + case err != nil: + job.Status = dto.AgentUpdateJobStatusFailed + job.Error = formatUpdateJobError(ctx, fmt.Errorf("refresh capabilities: %w", err)) + case caps.Status == hostutility.StatusOK: + job.Status = dto.AgentUpdateJobStatusSucceeded + refreshed = true + case caps.Status == hostutility.StatusAuthRequired: + job.Status = dto.AgentUpdateJobStatusSucceeded + job.RefreshError = caps.Error + if job.RefreshError == "" { + job.RefreshError = "authentication required" + } + default: + job.Status = dto.AgentUpdateJobStatusFailed + job.Error = capabilityRefreshError(caps) + } + // See finishFailed: a retry must not see a stale shared claim after the + // local active entry has been removed. + s.maintenance.release(job.AgentName, ref) + s.finishLocked(job) + snapshot := job.snapshot() + s.mu.Unlock() + if refreshed && s.onRefresh != nil { + s.onRefresh() + } + s.broadcast(ws.ActionAgentUpdateFinished, snapshot) + s.scheduleEviction(job.ID) +} + +func (s *AgentUpdateJobStore) finishLocked(job *AgentUpdateJob) { + now := time.Now().UTC() + job.FinishedAt = &now + delete(s.activeByAgt, job.AgentName) +} + +func capabilityRefreshError(caps hostutility.AgentCapabilities) string { + if caps.Error != "" { + return "refresh capabilities: " + caps.Error + } + return "refresh capabilities failed with status " + string(caps.Status) +} + +func formatUpdateJobError(ctx context.Context, err error) string { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fmt.Sprintf("update timed out after %s", jobHardTimeout) + } + return err.Error() +} + +func (s *AgentUpdateJobStore) scheduleEviction(jobID string) { + time.AfterFunc(jobRetention, func() { + s.mu.Lock() + delete(s.jobs, jobID) + s.mu.Unlock() + }) +} + +func (s *AgentUpdateJobStore) broadcast(action string, payload dto.AgentUpdateJobDTO) { + if s.hub == nil { + return + } + message, _ := ws.NewNotification(action, payload) + //ws:global Managed runtime updates are host-wide Settings state. + s.hub.Broadcast(message) +} + +func (j *AgentUpdateJob) snapshot() dto.AgentUpdateJobDTO { + snapshot := dto.AgentUpdateJobDTO{ + JobID: j.ID, + AgentName: j.AgentName, + Status: j.Status, + CurrentVersion: j.CurrentVersion, + TargetVersion: j.TargetVersion, + Output: j.Output.String(), + Error: j.Error, + RefreshError: j.RefreshError, + StartedAt: j.StartedAt, + } + if j.FinishedAt != nil { + finished := *j.FinishedAt + snapshot.FinishedAt = &finished + } + return snapshot +} + +type updateOutputFlusher struct { + store *AgentUpdateJobStore + job *AgentUpdateJob + mu sync.Mutex + buf bytes.Buffer + timer *time.Timer +} + +func newUpdateOutputFlusher(store *AgentUpdateJobStore, job *AgentUpdateJob) *updateOutputFlusher { + return &updateOutputFlusher{store: store, job: job} +} + +func (f *updateOutputFlusher) append(chunk string) { + if chunk == "" { + return + } + f.mu.Lock() + f.buf.WriteString(chunk) + _, _ = f.job.Output.Write([]byte(chunk)) + size := f.buf.Len() + if size >= outputFlushMaxSize { + f.mu.Unlock() + f.flush() + return + } + if f.timer == nil { + f.timer = time.AfterFunc(outputFlushPeriod, f.flush) + } + f.mu.Unlock() +} + +func (f *updateOutputFlusher) flush() { + f.mu.Lock() + if f.timer != nil { + f.timer.Stop() + f.timer = nil + } + if f.buf.Len() == 0 { + f.mu.Unlock() + return + } + chunk := f.buf.String() + f.buf.Reset() + f.mu.Unlock() + + if f.store.hub == nil { + return + } + payload := struct { + JobID string `json:"job_id"` + AgentName string `json:"agent_name"` + Chunk string `json:"chunk"` + }{JobID: f.job.ID, AgentName: f.job.AgentName, Chunk: chunk} + message, _ := ws.NewNotification(ws.ActionAgentUpdateOutput, payload) + //ws:global Managed runtime update output is host-wide Settings state. + f.store.hub.Broadcast(message) +} diff --git a/apps/backend/internal/agent/settings/controller/agent_update_test.go b/apps/backend/internal/agent/settings/controller/agent_update_test.go new file mode 100644 index 0000000000..44f451ea88 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_update_test.go @@ -0,0 +1,404 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/settings/dto" + ws "github.com/kandev/kandev/pkg/websocket" + "go.uber.org/zap" +) + +type fakeRuntimeUpdater struct { + mu sync.Mutex + current hostutility.AgentCapabilities + currentFound bool + target string + resolveErr error + runErr error + refreshCaps hostutility.AgentCapabilities + refreshErr error + runCommand []string + refreshCommand []string + updateOutput string + runStarted chan struct{} + releaseRun chan struct{} + runCalls int + refreshCalls int + resolvedPackage string +} + +type recordingCommandExecutor struct { + outputCommand []string + output string +} + +func (e *recordingCommandExecutor) Output( + _ context.Context, + command agents.Command, +) (string, error) { + e.outputCommand = append([]string(nil), command.Args()...) + return e.output, nil +} + +func (e *recordingCommandExecutor) Stream( + context.Context, + agents.Command, + func(string), +) error { + return nil +} + +func TestHostRuntimeUpdaterResolvesTargetWithDirectNPMArgv(t *testing.T) { + executor := &recordingCommandExecutor{output: "\"1.2.3\"\n"} + updater := &hostRuntimeUpdater{executor: executor} + + target, err := updater.ResolveTarget(context.Background(), "@example/managed-acp") + if err != nil { + t.Fatalf("ResolveTarget: %v", err) + } + if target != "1.2.3" { + t.Fatalf("target = %q, want 1.2.3", target) + } + want := []string{"npm", "view", "@example/managed-acp", "dist-tags.latest", "--json"} + if got := strings.Join(executor.outputCommand, "\x00"); got != strings.Join(want, "\x00") { + t.Fatalf("command = %v, want %v", executor.outputCommand, want) + } +} + +func (f *fakeRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentCapabilities, bool) { + return f.current, f.currentFound +} + +func (f *fakeRuntimeUpdater) ResolveTarget(_ context.Context, packageName string) (string, error) { + f.mu.Lock() + f.resolvedPackage = packageName + f.mu.Unlock() + return f.target, f.resolveErr +} + +func (f *fakeRuntimeUpdater) RunUpdate( + ctx context.Context, + command agents.Command, + onChunk func(string), +) error { + f.mu.Lock() + f.runCalls++ + f.runCommand = append([]string(nil), command.Args()...) + started := f.runStarted + release := f.releaseRun + output := f.updateOutput + err := f.runErr + f.mu.Unlock() + if started != nil { + select { + case <-started: + default: + close(started) + } + } + if release != nil { + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + } + onChunk(output) + return err +} + +func (f *fakeRuntimeUpdater) Refresh( + _ context.Context, + _ string, + command agents.Command, +) (hostutility.AgentCapabilities, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.refreshCalls++ + f.refreshCommand = append([]string(nil), command.Args()...) + return f.refreshCaps, f.refreshErr +} + +type updateTerminalBroadcaster struct { + completed chan dto.AgentUpdateJobDTO +} + +func newUpdateTerminalBroadcaster() *updateTerminalBroadcaster { + return &updateTerminalBroadcaster{completed: make(chan dto.AgentUpdateJobDTO, 2)} +} + +func (b *updateTerminalBroadcaster) Broadcast(message *ws.Message) { + if message.Action != ws.ActionAgentUpdateFinished { + return + } + var job dto.AgentUpdateJobDTO + if json.Unmarshal(message.Payload, &job) != nil { + return + } + select { + case b.completed <- job: + default: + } +} + +func newUpdateTestStore( + updater RuntimeUpdater, + maintenance *maintenanceCoordinator, + onRefresh func(), +) (*AgentUpdateJobStore, <-chan dto.AgentUpdateJobDTO) { + hub := newUpdateTerminalBroadcaster() + return NewAgentUpdateJobStore(hub, zap.NewNop(), updater, maintenance, onRefresh), hub.completed +} + +func waitForUpdateStatus( + t *testing.T, + completed <-chan dto.AgentUpdateJobDTO, + jobID string, + statuses ...dto.AgentUpdateJobStatus, +) *dto.AgentUpdateJobDTO { + t.Helper() + var snapshot dto.AgentUpdateJobDTO + select { + case snapshot = <-completed: + case <-time.After(2 * time.Second): + t.Fatalf("job %s did not finish in time", jobID) + } + if snapshot.JobID != jobID { + t.Fatalf("finished job = %s, want %s", snapshot.JobID, jobID) + } + for _, status := range statuses { + if snapshot.Status == status { + return &snapshot + } + } + t.Fatalf("job %s finished with status %s, want %v", jobID, snapshot.Status, statuses) + return nil +} + +func managedRuntimeSpec() agents.ManagedNPMRuntimeSpec { + return agents.ManagedNPMRuntimeSpec{ + Package: "@example/managed-acp", + ACPArgs: []string{"--acp"}, + } +} + +func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) { + updater := &fakeRuntimeUpdater{ + current: hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, + currentFound: true, + target: "1.1.0", + refreshCaps: hostutility.AgentCapabilities{ + Status: hostutility.StatusOK, + AgentVersion: "1.1.0", + }, + updateOutput: "npm prepared runtime\n", + } + refreshed := make(chan struct{}, 1) + store, completed := newUpdateTestStore( + updater, newMaintenanceCoordinator(), func() { refreshed <- struct{}{} }, + ) + + job, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + if final.CurrentVersion != "1.0.0" || final.TargetVersion != "1.1.0" { + t.Fatalf("versions = %q -> %q, want 1.0.0 -> 1.1.0", final.CurrentVersion, final.TargetVersion) + } + if final.Output != "npm prepared runtime\n" { + t.Fatalf("Output = %q", final.Output) + } + select { + case <-refreshed: + default: + t.Fatal("successful refresh did not invoke catalogue callback") + } + + updater.mu.Lock() + defer updater.mu.Unlock() + if updater.resolvedPackage != "@example/managed-acp" { + t.Fatalf("resolved package = %q", updater.resolvedPackage) + } + wantUpdate := "npm exec --yes --prefer-online --package=@example/managed-acp -- node -e " + if got := strings.Join(updater.runCommand, " "); got != wantUpdate { + t.Fatalf("update command = %q, want %q", got, wantUpdate) + } + wantRefresh := "npx --yes --prefer-offline @example/managed-acp --acp" + if got := strings.Join(updater.refreshCommand, " "); got != wantRefresh { + t.Fatalf("refresh command = %q, want %q", got, wantRefresh) + } +} + +func TestAgentUpdateAuthRequiredIsPackageSuccessWithRefreshError(t *testing.T) { + updater := &fakeRuntimeUpdater{ + target: "1.1.0", + refreshCaps: hostutility.AgentCapabilities{ + Status: hostutility.StatusAuthRequired, + Error: "login required", + }, + } + refreshed := false + store, completed := newUpdateTestStore( + updater, newMaintenanceCoordinator(), func() { refreshed = true }, + ) + + job, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + if final.RefreshError != "login required" { + t.Fatalf("RefreshError = %q, want login required", final.RefreshError) + } + if final.Error != "" { + t.Fatalf("Error = %q, want empty", final.Error) + } + if refreshed { + t.Fatal("auth-required refresh must not broadcast a replacement catalogue") + } +} + +func TestAgentUpdateRegistryFailureStopsBeforeMutation(t *testing.T) { + updater := &fakeRuntimeUpdater{resolveErr: errors.New("registry unavailable")} + store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil) + job, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusFailed) + if !strings.Contains(final.Error, "registry unavailable") { + t.Fatalf("Error = %q", final.Error) + } + updater.mu.Lock() + defer updater.mu.Unlock() + if updater.runCalls != 0 || updater.refreshCalls != 0 { + t.Fatalf("calls after registry failure: update=%d refresh=%d", updater.runCalls, updater.refreshCalls) + } +} + +func TestAgentUpdateHardFailuresRemainFailed(t *testing.T) { + tests := []struct { + name string + updater *fakeRuntimeUpdater + wantMessage string + }{ + { + name: "package update", + updater: &fakeRuntimeUpdater{ + target: "1.1.0", + runErr: errors.New("npm exec failed"), + }, + wantMessage: "npm exec failed", + }, + { + name: "ACP initialization", + updater: &fakeRuntimeUpdater{ + target: "1.1.0", + refreshCaps: hostutility.AgentCapabilities{ + Status: hostutility.StatusFailed, + Error: "unsupported protocol version", + }, + }, + wantMessage: "unsupported protocol version", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + refreshed := false + store, completed := newUpdateTestStore( + test.updater, newMaintenanceCoordinator(), func() { refreshed = true }, + ) + job, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusFailed) + if !strings.Contains(final.Error, test.wantMessage) { + t.Fatalf("Error = %q, want %q", final.Error, test.wantMessage) + } + if refreshed { + t.Fatal("hard failure invoked catalogue refresh callback") + } + }) + } +} + +func TestAgentUpdateDeduplicatesAndConflictsWithInstall(t *testing.T) { + coordinator := newMaintenanceCoordinator() + updater := &fakeRuntimeUpdater{ + target: "1.1.0", + refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK}, + runStarted: make(chan struct{}), + releaseRun: make(chan struct{}), + } + store, completed := newUpdateTestStore(updater, coordinator, nil) + t.Cleanup(func() { + select { + case <-updater.releaseRun: + default: + close(updater.releaseRun) + } + }) + + first, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("first enqueue: %v", err) + } + <-updater.runStarted + second, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("second enqueue: %v", err) + } + if first.ID != second.ID { + t.Fatalf("job IDs differ: %s != %s", first.ID, second.ID) + } + + installStore := NewJobStore(&captureBroadcaster{}, zap.NewNop(), nil, coordinator) + _, err = installStore.Enqueue("managed-acp", "echo install") + var conflict *MaintenanceConflictError + if !errors.As(err, &conflict) { + t.Fatalf("install conflict error = %v", err) + } + if conflict.Active.JobID != first.ID || conflict.Active.Kind != MaintenanceKindUpdate { + t.Fatalf("active conflict = %#v", conflict.Active) + } + + close(updater.releaseRun) + waitForUpdateStatus(t, completed, first.ID, dto.AgentUpdateJobStatusSucceeded) + + retry, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("retry enqueue: %v", err) + } + if retry.ID == first.ID { + t.Fatal("retry reused a completed update job") + } + waitForUpdateStatus(t, completed, retry.ID, dto.AgentUpdateJobStatusSucceeded) +} + +func TestAgentUpdateOutputIsBounded(t *testing.T) { + updater := &fakeRuntimeUpdater{ + target: "1.1.0", + refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK}, + updateOutput: strings.Repeat("line contents\n", 7000), + } + store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil) + + job, err := store.Enqueue("managed-acp", managedRuntimeSpec()) + if err != nil { + t.Fatalf("Enqueue: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + if len(final.Output) > jobOutputRingSize { + t.Fatalf("output bytes = %d, limit = %d", len(final.Output), jobOutputRingSize) + } +} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 5d953e5044..2517ca3618 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -57,6 +57,9 @@ type Controller struct { modelCache *modelfetcher.Cache hostUtility *hostutility.Manager jobStore *JobStore + updateJobStore *AgentUpdateJobStore + runtimeUpdater RuntimeUpdater + maintenance *maintenanceCoordinator hub JobBroadcaster logger *logger.Logger } @@ -152,6 +155,23 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, // unset simply causes the model endpoints to report "not_configured". func (c *Controller) SetHostUtility(h *hostutility.Manager) { c.hostUtility = h + if h == nil { + c.runtimeUpdater = nil + c.updateJobStore = nil + return + } + c.SetRuntimeUpdater(&hostRuntimeUpdater{ + host: h, + executor: execDirectCommandExecutor{}, + }) +} + +// SetRuntimeUpdater replaces the process/probe boundary used by update jobs. +// Production wires the host utility implementation; embedders may provide a +// deterministic implementation without invoking npm. +func (c *Controller) SetRuntimeUpdater(updater RuntimeUpdater) { + c.runtimeUpdater = updater + c.initializeUpdateJobStore() } // SetJobBroadcaster initializes the install job store with a WS broadcaster @@ -163,8 +183,11 @@ func (c *Controller) SetJobBroadcaster(hub JobBroadcaster) { c.hub = hub if hub == nil { c.jobStore = nil + c.updateJobStore = nil + c.maintenance = nil return } + c.maintenance = newMaintenanceCoordinator() c.jobStore = NewJobStore(hub, c.logger.Zap(), func(agentName string) { c.InvalidateDiscoveryCache() // Kick a fresh capability probe immediately so the UI doesn't sit on @@ -183,7 +206,25 @@ func (c *Controller) SetJobBroadcaster(hub JobBroadcaster) { }() } c.logger.Info("install succeeded", zap.String("agent", agentName)) - }) + }, c.maintenance) + c.initializeUpdateJobStore() +} + +func (c *Controller) initializeUpdateJobStore() { + if c.hub == nil || c.runtimeUpdater == nil { + c.updateJobStore = nil + return + } + if c.maintenance == nil { + c.maintenance = newMaintenanceCoordinator() + } + c.updateJobStore = NewAgentUpdateJobStore( + c.hub, + c.logger.Zap(), + c.runtimeUpdater, + c.maintenance, + c.BroadcastAvailableAgents, + ) } // BroadcastAvailableAgents fetches the current available-agents snapshot and diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 77d664b266..fe02476a5a 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" @@ -632,16 +633,15 @@ func TestDetectAgents_E2EMockPropagatesSupportsMCP(t *testing.T) { } } -// TestController_PreviewAgentCommand_CopilotPrefersNativeBinary verifies the -// command preview reflects the local copilot CLI when it is on PATH — the -// preview assumes the default standalone host — and falls back to npx when it -// is absent. Regresses the report that the preview always showed npx. -func TestController_PreviewAgentCommand_CopilotPrefersNativeBinary(t *testing.T) { +// TestController_PreviewAgentCommand_CopilotKeepsManagedPackage verifies that +// a globally installed Copilot CLI does not make the command preview bypass +// the managed npm runtime. +func TestController_PreviewAgentCommand_CopilotKeepsManagedPackage(t *testing.T) { controller := newTestController(map[string]agents.Agent{ "copilot-acp": agents.NewCopilotACP(), }) - // copilot on PATH → preview shows the native binary. + // A copilot binary on PATH must not change the previewed launch command. dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "copilot"), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write fake copilot: %v", err) @@ -651,17 +651,8 @@ func TestController_PreviewAgentCommand_CopilotPrefersNativeBinary(t *testing.T) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } - if got := res.Command; len(got) == 0 || got[0] != "copilot" { - t.Errorf("preview with copilot on PATH should start with copilot, got %v", got) - } - - // copilot absent → preview falls back to npx. - t.Setenv("PATH", t.TempDir()) - res, err = controller.PreviewAgentCommand(context.Background(), "copilot-acp", CommandPreviewRequest{}) - if err != nil { - t.Fatalf("PreviewAgentCommand() error = %v", err) - } - if got := res.Command; len(got) == 0 || got[0] != "npx" { - t.Errorf("preview without copilot on PATH should start with npx, got %v", got) + want := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} + if got := res.Command; !slices.Equal(got, want) { + t.Errorf("preview with copilot on PATH = %v, want %v", got, want) } } diff --git a/apps/backend/internal/agent/settings/controller/install_job.go b/apps/backend/internal/agent/settings/controller/install_job.go index 418e8d8534..c4f8e66d08 100644 --- a/apps/backend/internal/agent/settings/controller/install_job.go +++ b/apps/backend/internal/agent/settings/controller/install_job.go @@ -125,6 +125,7 @@ type JobStore struct { hub JobBroadcaster log *zap.Logger onSuccess func(agentName string) // invoked when a job succeeds (used to invalidate discovery) + maintenance *maintenanceCoordinator } // JobBroadcaster is the subset of the gateway Broadcaster we need. @@ -133,7 +134,12 @@ type JobBroadcaster interface { } // NewJobStore returns a fresh job store wired to the given broadcaster. -func NewJobStore(hub JobBroadcaster, log *zap.Logger, onSuccess func(string)) *JobStore { +func NewJobStore( + hub JobBroadcaster, + log *zap.Logger, + onSuccess func(string), + maintenance *maintenanceCoordinator, +) *JobStore { return &JobStore{ jobs: make(map[string]*InstallJob), activeByAgt: make(map[string]*InstallJob), @@ -141,18 +147,19 @@ func NewJobStore(hub JobBroadcaster, log *zap.Logger, onSuccess func(string)) *J hub: hub, log: log, onSuccess: onSuccess, + maintenance: maintenance, } } // Enqueue starts (or returns an existing) install job for the named agent // running the given hard-coded script. The script is supplied by the agent // type, not by the request — no user-supplied shell input. -func (s *JobStore) Enqueue(agentName, script string) *InstallJob { +func (s *JobStore) Enqueue(agentName, script string) (*InstallJob, error) { s.mu.Lock() // Idempotent: a running/queued job for this agent reuses the same job_id. if existing, ok := s.activeByAgt[agentName]; ok { s.mu.Unlock() - return existing + return existing, nil } job := &InstallJob{ ID: uuid.NewString(), @@ -161,13 +168,27 @@ func (s *JobStore) Enqueue(agentName, script string) *InstallJob { Output: newRingBuffer(jobOutputRingSize), StartedAt: time.Now().UTC(), } + ref := MaintenanceJobRef{JobID: job.ID, Kind: MaintenanceKindInstall} + if s.maintenance != nil { + active, claimed, err := s.maintenance.claim(agentName, MaintenanceKindInstall, job.ID) + if err != nil { + s.mu.Unlock() + return nil, err + } + if !claimed { + existing := s.jobs[active.JobID] + s.mu.Unlock() + return existing, nil + } + ref = active + } s.jobs[job.ID] = job s.activeByAgt[agentName] = job s.mu.Unlock() s.broadcast(ws.ActionAgentInstallStarted, job.snapshot()) - go s.run(job, script) - return job + go s.run(job, script, ref) + return job, nil } // Get returns a snapshot of the job by ID. ok=false if not found. @@ -193,7 +214,7 @@ func (s *JobStore) ListAll() []dto.InstallJobDTO { return out } -func (s *JobStore) run(job *InstallJob, script string) { +func (s *JobStore) run(job *InstallJob, script string, ref MaintenanceJobRef) { // Bound parallel installs: queued jobs wait here until a slot frees up. s.semaphore <- struct{}{} defer func() { <-s.semaphore }() @@ -228,9 +249,14 @@ func (s *JobStore) run(job *InstallJob, script string) { job.ExitCode = &exit } } + // Release the shared maintenance claim before its local active entry. This + // keeps a concurrent retry from observing no local job while the shared + // coordinator still points at this completed job. + if s.maintenance != nil { + s.maintenance.release(job.AgentName, ref) + } // Free the per-agent slot before notifying so a retry/auto-rescan - // triggered from onSuccess or the WS broadcast starts a fresh job - // instead of being deduped against this finished one. + // triggered from onSuccess or the WS broadcast starts a fresh job. delete(s.activeByAgt, job.AgentName) jobID := job.ID s.mu.Unlock() diff --git a/apps/backend/internal/agent/settings/controller/maintenance_jobs.go b/apps/backend/internal/agent/settings/controller/maintenance_jobs.go new file mode 100644 index 0000000000..505706d512 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/maintenance_jobs.go @@ -0,0 +1,65 @@ +package controller + +import ( + "fmt" + "sync" +) + +type MaintenanceKind string + +const ( + MaintenanceKindInstall MaintenanceKind = "install" + MaintenanceKindUpdate MaintenanceKind = "update" +) + +type MaintenanceJobRef struct { + JobID string `json:"job_id"` + Kind MaintenanceKind `json:"kind"` +} + +// MaintenanceConflictError identifies the active opposite-kind operation. +type MaintenanceConflictError struct { + AgentName string + Active MaintenanceJobRef +} + +func (e *MaintenanceConflictError) Error() string { + return fmt.Sprintf("%s already has an active %s job", e.AgentName, e.Active.Kind) +} + +// maintenanceCoordinator serializes mutating maintenance per agent while +// allowing independent agents to update or install concurrently. +type maintenanceCoordinator struct { + mu sync.Mutex + byAgent map[string]MaintenanceJobRef +} + +func newMaintenanceCoordinator() *maintenanceCoordinator { + return &maintenanceCoordinator{byAgent: make(map[string]MaintenanceJobRef)} +} + +func (c *maintenanceCoordinator) claim( + agentName string, + kind MaintenanceKind, + jobID string, +) (MaintenanceJobRef, bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + if active, ok := c.byAgent[agentName]; ok { + if active.Kind == kind { + return active, false, nil + } + return active, false, &MaintenanceConflictError{AgentName: agentName, Active: active} + } + ref := MaintenanceJobRef{JobID: jobID, Kind: kind} + c.byAgent[agentName] = ref + return ref, true, nil +} + +func (c *maintenanceCoordinator) release(agentName string, ref MaintenanceJobRef) { + c.mu.Lock() + defer c.mu.Unlock() + if current, ok := c.byAgent[agentName]; ok && current == ref { + delete(c.byAgent, agentName) + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index a2da14a500..13c25fe8f7 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -205,9 +205,18 @@ type AvailableAgentDTO struct { PermissionSettings map[string]PermissionSettingDTO `json:"permission_settings,omitempty"` PassthroughConfig *PassthroughConfigDTO `json:"passthrough_config,omitempty"` LoginCommand *LoginCommandDTO `json:"login_command,omitempty"` + RuntimeUpdate *RuntimeUpdateDTO `json:"runtime_update,omitempty"` UpdatedAt time.Time `json:"updated_at"` } +// RuntimeUpdateDTO describes a Kandev-managed npm runtime. Package is +// informational; update requests select only the built-in agent name. +type RuntimeUpdateDTO struct { + Supported bool `json:"supported"` + Package string `json:"package"` + CurrentVersion string `json:"current_version,omitempty"` +} + type ListAvailableAgentsResponse struct { Agents []AvailableAgentDTO `json:"agents"` Tools []ToolStatusDTO `json:"tools,omitempty"` @@ -249,6 +258,36 @@ type ListInstallJobsResponse struct { Jobs []InstallJobDTO `json:"jobs"` } +// AgentUpdateJobStatus represents one phase of a managed-runtime update. +type AgentUpdateJobStatus string + +const ( + AgentUpdateJobStatusQueued AgentUpdateJobStatus = "queued" + AgentUpdateJobStatusResolving AgentUpdateJobStatus = "resolving" + AgentUpdateJobStatusUpdating AgentUpdateJobStatus = "updating" + AgentUpdateJobStatusRefreshing AgentUpdateJobStatus = "refreshing" + AgentUpdateJobStatusSucceeded AgentUpdateJobStatus = "succeeded" + AgentUpdateJobStatusFailed AgentUpdateJobStatus = "failed" +) + +// AgentUpdateJobDTO is the retained HTTP and WebSocket update snapshot. +type AgentUpdateJobDTO struct { + JobID string `json:"job_id"` + AgentName string `json:"agent_name"` + Status AgentUpdateJobStatus `json:"status"` + CurrentVersion string `json:"current_version,omitempty"` + TargetVersion string `json:"target_version,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + RefreshError string `json:"refresh_error,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +type ListAgentUpdateJobsResponse struct { + Jobs []AgentUpdateJobDTO `json:"jobs"` +} + type AgentProfileMcpConfigDTO struct { ProfileID string `json:"profile_id"` Enabled bool `json:"enabled"` 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 new file mode 100644 index 0000000000..1736a73597 --- /dev/null +++ b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go @@ -0,0 +1,246 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "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/registry" + "github.com/kandev/kandev/internal/agent/settings/controller" + "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/common/httpmw" + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +type handlerRuntimeUpdater struct { + started chan struct{} + release chan struct{} + once sync.Once +} + +func (u *handlerRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentCapabilities, bool) { + return hostutility.AgentCapabilities{AgentVersion: "1.0.0"}, true +} + +func (u *handlerRuntimeUpdater) ResolveTarget(context.Context, string) (string, error) { + return "1.1.0", nil +} + +func (u *handlerRuntimeUpdater) RunUpdate( + ctx context.Context, + _ agents.Command, + onChunk func(string), +) error { + if u.started != nil { + u.once.Do(func() { close(u.started) }) + } + if u.release != nil { + select { + case <-u.release: + case <-ctx.Done(): + return ctx.Err() + } + } + onChunk("updated\n") + return nil +} + +func (u *handlerRuntimeUpdater) Refresh( + context.Context, + string, + agents.Command, +) (hostutility.AgentCapabilities, error) { + return hostutility.AgentCapabilities{ + Status: hostutility.StatusOK, + AgentVersion: "1.1.0", + }, nil +} + +type updateTerminalBroadcaster struct { + completed chan dto.AgentUpdateJobDTO +} + +func newUpdateTerminalBroadcaster() *updateTerminalBroadcaster { + return &updateTerminalBroadcaster{completed: make(chan dto.AgentUpdateJobDTO, 2)} +} + +func (b *updateTerminalBroadcaster) Broadcast(message *ws.Message) { + if message.Action != ws.ActionAgentUpdateFinished { + return + } + var job dto.AgentUpdateJobDTO + if json.Unmarshal(message.Payload, &job) != nil { + return + } + select { + case b.completed <- job: + default: + } +} + +func waitForTerminalUpdate( + t *testing.T, + completed <-chan dto.AgentUpdateJobDTO, + jobID string, +) dto.AgentUpdateJobDTO { + t.Helper() + select { + case job := <-completed: + if job.JobID != jobID { + t.Fatalf("finished job = %s, want %s", job.JobID, jobID) + } + return job + case <-time.After(2 * time.Second): + t.Fatalf("job %s did not finish in time", jobID) + return dto.AgentUpdateJobDTO{} + } +} + +func newAgentUpdateRouter( + t *testing.T, + updater controller.RuntimeUpdater, +) (*gin.Engine, *controller.Controller, <-chan dto.AgentUpdateJobDTO) { + t.Helper() + gin.SetMode(gin.TestMode) + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + reg := registry.NewRegistry(log) + reg.LoadDefaults() + discoveryRegistry, err := discovery.LoadRegistry(context.Background(), reg, log) + if err != nil { + t.Fatalf("LoadRegistry: %v", err) + } + ctrl := controller.NewController(nil, discoveryRegistry, reg, nil, log) + ctrl.SetRuntimeUpdater(updater) + router := gin.New() + broadcaster := newUpdateTerminalBroadcaster() + RegisterRoutes(router, ctrl, broadcaster, log, "test-interlock") + return router, ctrl, broadcaster.completed +} + +func updateRequest(method, path string) *http.Request { + request := httptest.NewRequest(method, path, nil) + request.Header.Set(httpmw.InterimSettingsInterlockHeader, "test-interlock") + return request +} + +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")) + if response.Code != http.StatusAccepted { + t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String()) + } + var accepted dto.AgentUpdateJobDTO + if err := json.Unmarshal(response.Body.Bytes(), &accepted); err != nil { + t.Fatalf("decode accepted job: %v", err) + } + + finished := waitForTerminalUpdate(t, completed, accepted.JobID) + if finished.Status != dto.AgentUpdateJobStatusSucceeded { + t.Fatalf("finished status = %q, want succeeded", finished.Status) + } + + getResponse := httptest.NewRecorder() + router.ServeHTTP(getResponse, updateRequest( + http.MethodGet, + "/api/v1/agent-update/jobs/"+accepted.JobID, + )) + if getResponse.Code != http.StatusOK { + t.Fatalf("GET job status = %d, body = %s", getResponse.Code, getResponse.Body.String()) + } + var retained dto.AgentUpdateJobDTO + if err := json.Unmarshal(getResponse.Body.Bytes(), &retained); err != nil { + t.Fatalf("decode retained job: %v", err) + } + if retained.Status != dto.AgentUpdateJobStatusSucceeded { + t.Fatalf("retained status = %q, want succeeded", retained.Status) + } + + listResponse := httptest.NewRecorder() + router.ServeHTTP(listResponse, updateRequest(http.MethodGet, "/api/v1/agent-update/jobs")) + if listResponse.Code != http.StatusOK || + !strings.Contains(listResponse.Body.String(), accepted.JobID) { + t.Fatalf("list response = %d %s", listResponse.Code, listResponse.Body.String()) + } +} + +func TestAgentUpdateEndpointReturnsMaintenanceConflict(t *testing.T) { + updater := &handlerRuntimeUpdater{ + started: make(chan struct{}), + release: make(chan struct{}), + } + router, _, completed := newAgentUpdateRouter(t, updater) + t.Cleanup(func() { + select { + case <-updater.release: + default: + close(updater.release) + } + }) + updateResponse := httptest.NewRecorder() + router.ServeHTTP(updateResponse, updateRequest( + http.MethodPost, + "/api/v1/agent-update/claude-acp", + )) + if updateResponse.Code != http.StatusAccepted { + t.Fatalf("update status = %d, body = %s", updateResponse.Code, updateResponse.Body.String()) + } + <-updater.started + + installResponse := httptest.NewRecorder() + router.ServeHTTP(installResponse, updateRequest( + http.MethodPost, + "/api/v1/agent-install/claude-acp", + )) + if installResponse.Code != http.StatusConflict { + t.Fatalf("install status = %d, body = %s", installResponse.Code, installResponse.Body.String()) + } + if !strings.Contains(installResponse.Body.String(), `"active_kind":"update"`) { + t.Fatalf("conflict body = %s", installResponse.Body.String()) + } + close(updater.release) + + var accepted dto.AgentUpdateJobDTO + if err := json.Unmarshal(updateResponse.Body.Bytes(), &accepted); err != nil { + t.Fatalf("decode accepted update: %v", err) + } + finished := waitForTerminalUpdate(t, completed, accepted.JobID) + if finished.Status != dto.AgentUpdateJobStatusSucceeded { + t.Fatalf("finished status = %q, want succeeded", finished.Status) + } +} + +func TestAgentUpdateEndpointRejectsUnmanagedAgentAndMissingJob(t *testing.T) { + router, _, _ := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) + + unsupported := httptest.NewRecorder() + router.ServeHTTP(unsupported, updateRequest( + http.MethodPost, + "/api/v1/agent-update/cursor-acp", + )) + if unsupported.Code != http.StatusBadRequest { + t.Fatalf("unsupported status = %d, body = %s", unsupported.Code, unsupported.Body.String()) + } + + missing := httptest.NewRecorder() + router.ServeHTTP(missing, updateRequest( + http.MethodGet, + "/api/v1/agent-update/jobs/missing", + )) + if missing.Code != http.StatusNotFound { + t.Fatalf("missing status = %d, body = %s", missing.Code, missing.Body.String()) + } +} diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 17fa8ef729..9199d6821f 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -66,6 +66,9 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.POST("/agent-install/:agentName", h.interlock, h.httpInstallAgent) api.GET("/agent-install/jobs", h.httpListInstallJobs) api.GET("/agent-install/jobs/:id", h.httpGetInstallJob) + api.POST("/agent-update/:agentName", h.interlock, h.httpUpdateAgentRuntime) + api.GET("/agent-update/jobs", h.httpListAgentUpdateJobs) + api.GET("/agent-update/jobs/:id", h.httpGetAgentUpdateJob) api.PATCH("/agent-profiles/:id", h.interlock, h.httpUpdateProfile) api.DELETE("/agent-profiles/:id", h.interlock, h.httpDeleteProfile) api.GET("/agent-profiles/:id/mcp-config", h.httpGetProfileMcpConfig) @@ -101,27 +104,119 @@ func (h *Handlers) httpListAvailableAgents(c *gin.Context) { // Idempotent: a second POST for the same agent while a job is running returns // the existing job_id rather than starting a duplicate. func (h *Handlers) httpInstallAgent(c *gin.Context) { + name, ok := requireAgentName(c) + if !ok { + return + } + h.enqueueMaintenance(c, name, "install", func() (any, error) { + return h.controller.EnqueueInstall(name) + }, classifyInstallError) +} + +func (h *Handlers) httpUpdateAgentRuntime(c *gin.Context) { + name, ok := requireAgentName(c) + if !ok { + return + } + h.enqueueMaintenance(c, name, "update", func() (any, error) { + return h.controller.EnqueueAgentUpdate(name) + }, classifyUpdateError) +} + +func requireAgentName(c *gin.Context) (string, bool) { name := strings.TrimSpace(c.Param("agentName")) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"}) + return "", false + } + return name, true +} + +func writeMaintenanceConflictIfPresent(c *gin.Context, err error) bool { + var conflict *controller.MaintenanceConflictError + if !errors.As(err, &conflict) { + return false + } + writeMaintenanceConflict(c, conflict) + return true +} + +type maintenanceErrorClassifier func(error) (int, string, bool) + +func (h *Handlers) enqueueMaintenance( + c *gin.Context, + name string, + kind string, + enqueue func() (any, error), + classify maintenanceErrorClassifier, +) { + job, err := enqueue() + if err == nil { + c.JSON(http.StatusAccepted, job) return } - job, err := h.controller.EnqueueInstall(name) - if err != nil { - switch { - case errors.Is(err, controller.ErrAgentNotFound): - c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) - case errors.Is(err, controller.ErrInstallScriptEmpty): - c.JSON(http.StatusBadRequest, gin.H{"error": "agent has no install script"}) - case errors.Is(err, controller.ErrJobStoreUnavailable): - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "install service not ready"}) - default: - h.logger.Error("failed to enqueue install", zap.String("agent", name), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enqueue install"}) - } + if writeMaintenanceConflictIfPresent(c, err) { return } - c.JSON(http.StatusAccepted, job) + if status, message, matched := classify(err); matched { + c.JSON(status, gin.H{"error": message}) + return + } + h.logger.Error("failed to enqueue agent maintenance", + zap.String("agent", name), zap.String("kind", kind), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enqueue agent " + kind}) +} + +func classifyInstallError(err error) (int, string, bool) { + switch { + case errors.Is(err, controller.ErrAgentNotFound): + return http.StatusNotFound, "agent not found", true + case errors.Is(err, controller.ErrInstallScriptEmpty): + return http.StatusBadRequest, "agent has no install script", true + case errors.Is(err, controller.ErrJobStoreUnavailable): + return http.StatusServiceUnavailable, "install service not ready", true + default: + return 0, "", false + } +} + +func classifyUpdateError(err error) (int, string, bool) { + switch { + case errors.Is(err, controller.ErrAgentNotFound): + return http.StatusNotFound, "agent not found", true + case errors.Is(err, controller.ErrRuntimeUpdateUnsupported): + return http.StatusBadRequest, "agent runtime update unsupported", true + case errors.Is(err, controller.ErrRuntimeUpdaterUnavailable): + return http.StatusServiceUnavailable, "agent update service not ready", true + default: + return 0, "", false + } +} + +func (h *Handlers) httpListAgentUpdateJobs(c *gin.Context) { + c.JSON(http.StatusOK, dto.ListAgentUpdateJobsResponse{Jobs: h.controller.ListAgentUpdateJobs()}) +} + +func (h *Handlers) httpGetAgentUpdateJob(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "job id is required"}) + return + } + job, ok := h.controller.GetAgentUpdateJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "job not found"}) + return + } + c.JSON(http.StatusOK, job) +} + +func writeMaintenanceConflict(c *gin.Context, conflict *controller.MaintenanceConflictError) { + c.JSON(http.StatusConflict, gin.H{ + "error": conflict.Error(), + "active_job_id": conflict.Active.JobID, + "active_kind": conflict.Active.Kind, + }) } func (h *Handlers) httpListInstallJobs(c *gin.Context) { diff --git a/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go b/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go index d32f5203ad..d7e75cca8c 100644 --- a/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go +++ b/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go @@ -49,6 +49,7 @@ func TestRegisterRoutesProtectsEveryStateChangingAgentSettingsRoute(t *testing.T {method: http.MethodDelete, path: "/api/v1/agents/agent-1"}, {method: http.MethodPost, path: "/api/v1/agents/agent-1/profiles"}, {method: http.MethodPost, path: "/api/v1/agent-install/agent-1"}, + {method: http.MethodPost, path: "/api/v1/agent-update/agent-1"}, {method: http.MethodPatch, path: "/api/v1/agent-profiles/profile-1"}, {method: http.MethodDelete, path: "/api/v1/agent-profiles/profile-1"}, {method: http.MethodPost, path: "/api/v1/agent-profiles/profile-1/mcp-config"}, diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 259fd88c1a..099f2cb299 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -190,6 +190,9 @@ const ( ActionAgentInstallStarted = "agent.install.started" ActionAgentInstallOutput = "agent.install.output" ActionAgentInstallFinished = "agent.install.finished" + ActionAgentUpdateStarted = "agent.update.started" + ActionAgentUpdateOutput = "agent.update.output" + ActionAgentUpdateFinished = "agent.update.finished" ActionWorkspaceCreated = "workspace.created" ActionWorkspaceUpdated = "workspace.updated" ActionWorkspaceDeleted = "workspace.deleted" diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 1b4669703e..c596229155 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -26,8 +26,9 @@ import { listAvailableAgents, listInstallJobs, } from "@/lib/api"; -import type { InstallJob } from "@/lib/api"; +import type { AgentUpdateJob, InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; +import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { AgentLogo } from "@/components/agent-logo"; import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog"; @@ -40,6 +41,7 @@ import type { Agent, AvailableAgent, AgentProfile, + RuntimeUpdate, ToolStatus, } from "@/lib/types/http"; @@ -199,10 +201,67 @@ type InstalledAgentsSectionProps = { savedAgentsByName: Map; resolveDisplayName: (name: string) => string; resolveCapabilityStatus: (name: string) => string | undefined; + resolveRuntimeUpdate: (name: string) => RuntimeUpdate | undefined; + installJobs: Record; + updateJobs: Record; + handleUpdate: (name: string) => Promise; setTuiDialogOpen: (open: boolean) => void; handleRescan: () => Promise; }; +function InstalledAgentsHeader({ + rescanning, + onOpenShell, + onOpenTuiDialog, + onRescan, +}: { + rescanning: boolean; + onOpenShell: () => void; + onOpenTuiDialog: () => void; + onRescan: () => void; +}) { + return ( +
+
+

Installed Agents

+

+ Agents detected on this machine are ready to configure. +

+
+
+ + + +
+
+ ); +} + function InstalledAgentsSection({ installedAgents, discoveryLoading, @@ -210,6 +269,10 @@ function InstalledAgentsSection({ savedAgentsByName, resolveDisplayName, resolveCapabilityStatus, + resolveRuntimeUpdate, + installJobs, + updateJobs, + handleUpdate, setTuiDialogOpen, handleRescan, }: InstalledAgentsSectionProps) { @@ -217,49 +280,12 @@ function InstalledAgentsSection({ return (
-
-
-

Installed Agents

-

- Agents detected on this machine are ready to configure. -

-
-
- - - -
-
+ setShellOpen(true)} + onOpenTuiDialog={() => setTuiDialogOpen(true)} + onRescan={() => void handleRescan()} + /> void handleUpdate(name)} onAuthComplete={() => void handleRescan()} /> ))} @@ -473,6 +503,8 @@ function useAgentPageState() { availableAgents.find((item: AvailableAgent) => item.name === name)?.display_name ?? name; const resolveCapabilityStatus = (name: string) => availableAgents.find((item: AvailableAgent) => item.name === name)?.model_config?.status; + const resolveRuntimeUpdate = (name: string) => + availableAgents.find((item: AvailableAgent) => item.name === name)?.runtime_update; const handleRescan = async () => { if (rescanning) { @@ -492,6 +524,7 @@ function useAgentPageState() { }; const { installJobs, handleInstall } = useInstallAgent(handleRescan); + const { updateJobs, handleUpdate } = useAgentRuntimeUpdates(); const handleCreateCustomTUI = async (data: { display_name: string; @@ -526,10 +559,13 @@ function useAgentPageState() { setTuiDialogOpen, resolveDisplayName, resolveCapabilityStatus, + resolveRuntimeUpdate, handleRescan, handleCreateCustomTUI, installJobs, handleInstall, + updateJobs, + handleUpdate, }; } @@ -546,10 +582,13 @@ export default function AgentsSettingsPage() { setTuiDialogOpen, resolveDisplayName, resolveCapabilityStatus, + resolveRuntimeUpdate, handleRescan, handleCreateCustomTUI, installJobs, handleInstall, + updateJobs, + handleUpdate, } = useAgentPageState(); const { copiedValue, copy } = useCopyCommand(); @@ -571,6 +610,10 @@ export default function AgentsSettingsPage() { savedAgentsByName={savedAgentsByName} resolveDisplayName={resolveDisplayName} resolveCapabilityStatus={resolveCapabilityStatus} + resolveRuntimeUpdate={resolveRuntimeUpdate} + installJobs={installJobs} + updateJobs={updateJobs} + handleUpdate={handleUpdate} setTuiDialogOpen={setTuiDialogOpen} handleRescan={handleRescan} /> diff --git a/apps/web/components/settings/agent-runtime-update-control.test.tsx b/apps/web/components/settings/agent-runtime-update-control.test.tsx new file mode 100644 index 0000000000..14734d9c62 --- /dev/null +++ b/apps/web/components/settings/agent-runtime-update-control.test.tsx @@ -0,0 +1,143 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { InstalledAgentCard } from "./installed-agent-card"; + +const ManagedCard = InstalledAgentCard as ComponentType>; +const AGENT_NAME = "claude-acp"; + +const discoveryAgent = { + name: AGENT_NAME, + supports_mcp: true, + installation_paths: [], + available: true, + matched_path: "/usr/bin/npx", +}; + +const runtimeUpdate = { + supported: true, + package: "@agentclientprotocol/claude-agent-acp", + current_version: "1.0.0", +}; + +function renderCard(overrides: Record = {}) { + const onUpdate = vi.fn(); + render( + , + ); + return onUpdate; +} + +afterEach(cleanup); + +describe("AgentRuntimeUpdateControl", () => { + it("shows a labeled touch action only for managed installed agents", () => { + const onUpdate = renderCard(); + + const button = screen.getByRole("button", { name: "Update agent" }); + expect(button.className).toContain("min-h-11"); + fireEvent.click(button); + expect(onUpdate).toHaveBeenCalledWith(AGENT_NAME); + + cleanup(); + renderCard({ runtimeUpdate: undefined }); + expect(screen.queryByRole("button", { name: "Update agent" })).toBeNull(); + }); + + it("shows current to target progress and a bounded wrapping log", () => { + renderCard({ + updateJob: { + job_id: "update-1", + agent_name: AGENT_NAME, + status: "updating", + current_version: "1.0.0", + target_version: "1.1.0", + output: "fetching package\n", + started_at: "2026-07-26T10:00:00Z", + }, + }); + + expect(screen.getByText("1.0.0 → 1.1.0")).not.toBeNull(); + expect(screen.getByText("Updating runtime…")).not.toBeNull(); + const log = screen.getByTestId("agent-update-log-claude-acp"); + expect(log.className).toContain("max-h-40"); + expect(log.className).toContain("overflow-y-auto"); + expect(log.className).toContain("break-words"); + expect(screen.getByRole("button", { name: "Updating agent" }).hasAttribute("disabled")).toBe( + true, + ); + }); + + it("distinguishes refresh warnings from retryable update failures", () => { + const { rerender } = render( + , + ); + + expect( + screen.getByText(/runtime updated, but capabilities could not be refreshed/i), + ).not.toBeNull(); + expect(screen.getByText(/authentication required/i)).not.toBeNull(); + + rerender( + , + ); + + expect(screen.getByText("Registry unavailable")).not.toBeNull(); + expect(screen.getByRole("button", { name: "Retry update" }).hasAttribute("disabled")).toBe( + false, + ); + }); + + it("disables updates while the same agent is installing", () => { + renderCard({ + installJob: { + job_id: "install-1", + agent_name: AGENT_NAME, + status: "running", + started_at: "2026-07-26T10:00:00Z", + }, + }); + + expect(screen.getByRole("button", { name: "Update agent" }).hasAttribute("disabled")).toBe( + true, + ); + expect(screen.getByText("Agent installation is in progress.")).not.toBeNull(); + }); +}); diff --git a/apps/web/components/settings/agent-runtime-update-control.tsx b/apps/web/components/settings/agent-runtime-update-control.tsx new file mode 100644 index 0000000000..fc23d95652 --- /dev/null +++ b/apps/web/components/settings/agent-runtime-update-control.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { IconLoader2, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; + +import type { AgentUpdateJob, InstallJob } from "@/lib/api"; +import type { RuntimeUpdate } from "@/lib/types/http"; + +const ACTIVE_UPDATE_STATUSES = new Set([ + "queued", + "resolving", + "updating", + "refreshing", +]); + +function updatePhase(status: AgentUpdateJob["status"] | undefined): string | null { + switch (status) { + case "queued": + return "Update queued…"; + case "resolving": + return "Checking latest version…"; + case "updating": + return "Updating runtime…"; + case "refreshing": + return "Refreshing models…"; + default: + return null; + } +} + +function updateButtonLabel(status: AgentUpdateJob["status"] | undefined) { + switch (status) { + case "queued": + return "Update queued"; + case "resolving": + return "Resolving update"; + case "updating": + return "Updating agent"; + case "refreshing": + return "Refreshing agent capabilities"; + case "failed": + return "Retry update"; + default: + return "Update agent"; + } +} + +function VersionSummary({ + runtimeUpdate, + job, +}: { + runtimeUpdate: RuntimeUpdate; + job?: AgentUpdateJob; +}) { + const current = job?.current_version || runtimeUpdate.current_version || "Unknown"; + if (job?.target_version) { + return

{`${current} → ${job.target_version}`}

; + } + if (job?.status === "resolving") { + return

{`${current} → Checking latest…`}

; + } + return

Current version: {current}

; +} + +function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) { + if (!job) return null; + if (job.status === "succeeded" && job.refresh_error) { + return ( +

+ Runtime updated, but capabilities could not be refreshed: {job.refresh_error} +

+ ); + } + if (job.status === "succeeded") { + return ( +

+ Runtime updated successfully. +

+ ); + } + if (job.status === "failed") { + return ( +

+ {job.error || "The runtime update failed."} +

+ ); + } + return null; +} + +export function AgentRuntimeUpdateControl({ + agentName, + runtimeUpdate, + job, + installJob, + onUpdate, +}: { + agentName: string; + runtimeUpdate: RuntimeUpdate; + job?: AgentUpdateJob; + installJob?: InstallJob; + onUpdate: (agentName: string) => void; +}) { + if (!runtimeUpdate.supported) return null; + + const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status)); + const installInFlight = installJob?.status === "queued" || installJob?.status === "running"; + const disabled = updateInFlight || installInFlight; + const phase = updatePhase(job?.status); + const buttonLabel = updateButtonLabel(job?.status); + + return ( +
+ + {phase && ( +

+ + {phase} +

+ )} + {installInFlight && ( +

+ Agent installation is in progress. +

+ )} + {job?.output && ( +
+          {job.output}
+        
+ )} + + +
+ ); +} diff --git a/apps/web/components/settings/installed-agent-card.tsx b/apps/web/components/settings/installed-agent-card.tsx index 3026174b1b..f9402d3f5f 100644 --- a/apps/web/components/settings/installed-agent-card.tsx +++ b/apps/web/components/settings/installed-agent-card.tsx @@ -9,8 +9,10 @@ import { Card, CardContent } from "@kandev/ui/card"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { AgentLogo } from "@/components/agent-logo"; import { AgentLoginDialog } from "@/components/settings/agent-login-dialog"; +import { AgentRuntimeUpdateControl } from "@/components/settings/agent-runtime-update-control"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; -import type { Agent, AgentDiscovery } from "@/lib/types/http"; +import type { AgentUpdateJob, InstallJob } from "@/lib/api"; +import type { Agent, AgentDiscovery, RuntimeUpdate } from "@/lib/types/http"; type Props = { agent: AgentDiscovery; @@ -18,6 +20,10 @@ type Props = { displayName: string; /** Capability status from the host utility probe ("ok", "auth_required", etc.). */ capabilityStatus?: string; + runtimeUpdate?: RuntimeUpdate; + updateJob?: AgentUpdateJob; + installJob?: InstallJob; + onUpdate?: (agentName: string) => void; /** * Called when the auth/shell dialog closes so the page can refresh * discovery + availability. Without this the yellow lock stays put even @@ -26,6 +32,75 @@ type Props = { onAuthComplete?: () => void; }; +function InstalledAgentIdentity({ + agent, + displayName, + configured, + probing, + authRequired, + loginAvailable, + onAuthClick, +}: { + agent: AgentDiscovery; + displayName: string; + configured: boolean; + probing: boolean; + authRequired: boolean; + loginAvailable: boolean; + onAuthClick: () => void; +}) { + return ( +
+
+ +

{displayName}

+ {agent.supports_mcp && MCP} + {configured && Configured} + {probing && ( + + + + + + + Checking agent capabilities and authentication... + + )} + {authRequired && ( + + + + + + {loginAvailable + ? "Authentication required - click to open login terminal" + : "Authentication required - click to open a shell and sign in"} + + + )} +
+

+ {agent.matched_path ? `Detected at ${agent.matched_path}` : ""} +

+
+ ); +} + /** * Card rendered under "Installed Agents" - links to the agent profile * editor and surfaces a yellow lock icon when the capability probe reports @@ -37,6 +112,10 @@ export function InstalledAgentCard({ savedAgent, displayName, capabilityStatus, + runtimeUpdate, + updateJob, + installJob, + onUpdate, onAuthComplete, }: Props) { const configured = Boolean(savedAgent && savedAgent.profiles.length > 0); @@ -56,57 +135,26 @@ export function InstalledAgentCard({ }; return ( - - -
-
- -

{displayName}

- {agent.supports_mcp && MCP} - {configured && Configured} - {probing && ( - - - - - - - Checking agent capabilities and authentication... - - )} - {authRequired && ( - - - - - - {loginAvailable - ? "Authentication required - click to open login terminal" - : "Authentication required - click to open a shell and sign in"} - - - )} -
- {/* Reserve two lines so cards align regardless of path length. */} -

- {agent.matched_path ? `Detected at ${agent.matched_path}` : ""} -

-
+ + + + {runtimeUpdate?.supported && onUpdate && ( + + )}