diff --git a/.github/scripts/update-agent-runtime-pins-workflow-contract_test.py b/.github/scripts/update-agent-runtime-pins-workflow-contract_test.py new file mode 100644 index 0000000000..deae41aab5 --- /dev/null +++ b/.github/scripts/update-agent-runtime-pins-workflow-contract_test.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Contract tests for the managed runtime pin update workflow.""" + +from pathlib import Path +import re +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "update-agent-runtime-pins.yml" +LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml" +SCRIPT = REPO_ROOT / "scripts" / "update-agent-runtime-pins.mjs" + + +class ManagedRuntimePinWorkflowContractTest(unittest.TestCase): + def setUp(self) -> None: + self.workflow = WORKFLOW.read_text(encoding="utf-8") + + def test_workflow_has_weekly_and_manual_triggers(self) -> None: + self.assertIn("schedule:", self.workflow) + self.assertRegex(self.workflow, r"cron:\s*[\"']?[0-9]+ [0-9]+ \* \* [0-6][\"']?") + self.assertIn("workflow_dispatch:", self.workflow) + + def test_workflow_uses_least_privilege_and_dedicated_app_token(self) -> None: + self.assertIn("permissions:\n contents: read", self.workflow) + self.assertIn("permission-contents: write", self.workflow) + self.assertIn("permission-pull-requests: write", self.workflow) + self.assertNotIn("permissions:\n contents: write", self.workflow) + self.assertNotIn("permissions:\n pull-requests: write", self.workflow) + self.assertIn("MANAGED_RUNTIME_PIN_APP_PRIVATE_KEY", self.workflow) + self.assertIn("MANAGED_RUNTIME_PIN_APP_CLIENT_ID", self.workflow) + self.assertNotIn("secrets.GITHUB_TOKEN", self.workflow) + self.assertNotIn("GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}", self.workflow) + + def test_workflow_groups_one_conventional_commit_pr_on_stable_branch(self) -> None: + self.assertIn("update-managed-runtime-pins", self.workflow) + self.assertIn("chore: update managed runtime pins", self.workflow) + self.assertIn("gh pr create", self.workflow) + self.assertNotIn("gh pr merge", self.workflow) + self.assertNotIn("--auto", self.workflow) + self.assertIn("at most one", self.workflow) + + def test_workflow_refreshes_existing_pr_by_number_and_authenticates_push(self) -> None: + self.assertIn('open_pr_number="$(gh pr list', self.workflow) + self.assertIn('gh pr edit "$open_pr_number"', self.workflow) + self.assertNotIn('gh pr edit \\\n --repo "$GITHUB_REPOSITORY" \\\n --head "$BOT_BRANCH"', self.workflow) + push_start = self.workflow.index("- name: Commit and push one grouped change") + pr_start = self.workflow.index("- name: Create or refresh the single grouped PR") + push_step = self.workflow[push_start:pr_start] + self.assertIn("GH_TOKEN: ${{ steps.app-token.outputs.token }}", push_step) + + def test_workflow_validates_before_any_commit_or_push(self) -> None: + update_pos = self.workflow.index("node scripts/update-agent-runtime-pins.mjs") + validation_pos = self.workflow.index("go test ./internal/agent/agents") + commit_pos = self.workflow.index("git commit") + push_pos = self.workflow.index("git push") + self.assertLess(update_pos, validation_pos) + self.assertLess(validation_pos, commit_pos) + self.assertLess(commit_pos, push_pos) + self.assertIn("node --test scripts/update-agent-runtime-pins.test.mjs", self.workflow) + self.assertIn("--github-output", self.workflow) + self.assertIn("working-directory: apps/backend", self.workflow) + self.assertIn( + "go test ./internal/agent/agents ./internal/agent/managedruntime " + "./internal/agent/runtime/lifecycle ./internal/agent/hostutility " + "./internal/agent/settings/controller ./internal/agent/settings/handlers", + self.workflow, + ) + self.assertIn("if: steps.update.outputs.changed == 'true'", self.workflow) + + def test_all_actions_are_commit_pinned(self) -> None: + for line in self.workflow.splitlines(): + if " uses: " not in line: + continue + self.assertRegex(line, r"uses:\s+[^@\s]+@[0-9a-f]{40}(?:\s+#.*)?$") + + def test_lint_workflow_runs_this_contract(self) -> None: + lint_workflow = LINT_WORKFLOW.read_text(encoding="utf-8") + self.assertIn( + "python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py", + lint_workflow, + ) + + def test_updater_script_exists_and_has_no_network_in_fixture_tests(self) -> None: + self.assertTrue(SCRIPT.is_file()) + script = SCRIPT.read_text(encoding="utf-8") + self.assertIn("registry.npmjs.org", script) + self.assertIn("fs.rename", script) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/lint-action-pinning.yml b/.github/workflows/lint-action-pinning.yml index a3f3c174eb..b2e4e5a552 100644 --- a/.github/workflows/lint-action-pinning.yml +++ b/.github/workflows/lint-action-pinning.yml @@ -71,6 +71,9 @@ jobs: - name: Test E2E workflow contract run: python3 .github/scripts/e2e-tests-workflow-contract_test.py + - name: Test managed runtime pin update workflow contract + run: python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py + - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: diff --git a/.github/workflows/update-agent-runtime-pins.yml b/.github/workflows/update-agent-runtime-pins.yml new file mode 100644 index 0000000000..be7bb8b084 --- /dev/null +++ b/.github/workflows/update-agent-runtime-pins.yml @@ -0,0 +1,115 @@ +name: Update managed runtime pins + +on: + schedule: + # Refresh the reviewed runtime pins every Monday at 06:17 UTC, then open + # or refresh the single grouped updater PR when a stable version changes. + - cron: "17 6 * * 1" + workflow_dispatch: + +# The job mints a narrower GitHub App token only after checkout. The default +# workflow token is never used to create the generated PR, so its events retain +# the repository's normal CI behavior. +permissions: + contents: read + +concurrency: + group: managed-runtime-pin-update + cancel-in-progress: false + +jobs: + update: + name: Open managed runtime pin PR + runs-on: ubuntu-latest + permissions: + contents: read + env: + BOT_BRANCH: automation/update-managed-runtime-pins + + steps: + - name: Checkout main + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + + - name: Test pin updater + run: node --test scripts/update-agent-runtime-pins.test.mjs + + - name: Create managed runtime pin App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.MANAGED_RUNTIME_PIN_APP_CLIENT_ID }} + private-key: ${{ secrets.MANAGED_RUNTIME_PIN_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Configure git authentication + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: gh auth setup-git + + - name: Prepare stable bot branch + run: | + set -euo pipefail + git fetch origin main + git fetch origin "refs/heads/${BOT_BRANCH}:refs/remotes/origin/${BOT_BRANCH}" || true + git checkout -B "${BOT_BRANCH}" origin/main + + - name: Update trusted catalogue + id: update + run: node scripts/update-agent-runtime-pins.mjs --github-output "$GITHUB_OUTPUT" + + - name: Test managed runtime catalogue and commands + working-directory: apps/backend + run: go test ./internal/agent/agents ./internal/agent/managedruntime ./internal/agent/runtime/lifecycle ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers + + - name: Commit and push one grouped change + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + git config user.name "kandev-runtime-updater[bot]" + git config user.email "kandev-runtime-updater[bot]@users.noreply.github.com" + git add apps/backend/internal/agent/agents/managed_npm_runtime_versions.json + git diff --cached --check + git commit -m "chore: update managed runtime pins" + git push --force-with-lease origin "${BOT_BRANCH}" + + # The stable branch and head filter keep this workflow to at most one + # open grouped PR, even when the scheduler and manual trigger overlap. + - name: Create or refresh the single grouped PR + if: steps.update.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + cat > /tmp/managed-runtime-pin-pr-body.md <<'EOF' + ## Summary + + Refresh the reviewed managed npm runtime defaults from stable `latest` metadata. + + The catalogue is validated before this PR is created. This workflow does not install or activate a runtime. + EOF + open_pr_number="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BOT_BRANCH" --base main --state open --json number --jq '.[0].number // ""')" + if [[ -z "$open_pr_number" ]]; then + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$BOT_BRANCH" \ + --title "chore: update managed runtime pins" \ + --body-file /tmp/managed-runtime-pin-pr-body.md + else + gh pr edit "$open_pr_number" \ + --repo "$GITHUB_REPOSITORY" \ + --title "chore: update managed runtime pins" \ + --body-file /tmp/managed-runtime-pin-pr-body.md + fi diff --git a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md index 9bb7657478..ba54527a7e 100644 --- a/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md +++ b/apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md @@ -1,30 +1,38 @@ # Managed npm ACP runtimes -Kandev invokes these managed npm-provided ACP runtimes by unversioned package -name: +Kandev invokes these managed npm-provided ACP runtimes with the exact effective +version. The default values below are the reviewed pins in +`managed_npm_runtime_versions.json` at this commit. An operator selection +replaces the default for that installation until **Use Kandev default** clears +it. -| Agent | Package | ACP arguments | -| --- | --- | --- | -| Claude | `@agentclientprotocol/claude-agent-acp` | none | -| Codex | `@agentclientprotocol/codex-acp` | none | -| OpenCode | `opencode-ai` | `acp --print-logs --log-level ERROR` | -| Copilot | `@github/copilot` | `--acp` | -| Gemini | `@google/gemini-cli` | `--acp` | +| Agent | Package | Default version | ACP arguments | +| --- | --- | --- | --- | +| Claude | `@agentclientprotocol/claude-agent-acp` | `0.70.0` | none | +| Codex | `@agentclientprotocol/codex-acp` | `1.6.0` | none | +| OpenCode | `opencode-ai` | `1.18.18` | `acp --print-logs --log-level ERROR` | +| Copilot | `@github/copilot` | `1.0.75` | `--acp` | +| Gemini | `@google/gemini-cli` | `0.52.0` | `--acp` | -Normal capability probes, sessions, container commands, and one-shot -inference use `npx --yes --prefer-offline` with the package name and arguments -above. OpenCode's error-only log flags are part of its managed command so -agentctl can observe terminal provider diagnostics without reading OpenCode's -private log files. 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. +Normal capability probes, sessions, container commands, and one-shot inference +use `npx --yes --prefer-offline package@effective-version` with the ACP +arguments above. For example, an unmodified Claude installation currently +launches `npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp@0.70.0`. +OpenCode's error-only log flags +are part of its managed command so agentctl can observe terminal provider +diagnostics without reading OpenCode's private log files. The exact top-level +package is pinned, but npm transitive ranges, its cache, and the registry still +affect reproducibility. Kandev records the version reported by the ACP +initialize response instead of inferring it from source. 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. +the Kandev host. Its candidate preparation resolves the requested trusted +`package@effective-version` with online preference, 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. The normal +launch path remains offline-preferred; the update path is online-preferred so +it can refresh stale npm metadata. ACP protocol negotiation and advertised capabilities are the compatibility boundary. Kandev does not maintain an exact package-version allowlist or @@ -33,6 +41,8 @@ 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. +native-only agents such as Cursor are outside this managed update path. The +install-wide effective version is included in commands built for remote +executors and new containers, but the Settings action does not prepare their +package cache. Each remote environment must resolve the exact package when it +launches. diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 13a4cc58ee..0cb64a9f70 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -215,7 +215,7 @@ type CommandOptions struct { // the native binary (e.g. "copilot --acp") instead of "npx -y ". PreferNativeBinary bool // ManagedRuntimeVersion is an internal exact version override for trusted - // managed npm ACP runtimes. Empty preserves the legacy unversioned command. + // managed npm ACP runtimes. Empty uses the built-in exact version pin. ManagedRuntimeVersion string } diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index 7833769b62..b1e84c7cd0 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -111,7 +111,7 @@ func (a *ClaudeACP) BuildCommand(opts CommandOptions) Command { } func (a *ClaudeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { - return ManagedNPMRuntimeSpec{Package: claudeACPPackage} + return newManagedNPMRuntimeSpec(claudeACPPackage) } func (a *ClaudeACP) Runtime() *RuntimeConfig { diff --git a/apps/backend/internal/agent/agents/claude_acp_test.go b/apps/backend/internal/agent/agents/claude_acp_test.go index 3bc228cfa5..e145441f49 100644 --- a/apps/backend/internal/agent/agents/claude_acp_test.go +++ b/apps/backend/internal/agent/agents/claude_acp_test.go @@ -8,7 +8,7 @@ import ( func TestClaudeACPUsesManagedRuntime(t *testing.T) { a := NewClaudeACP() - want := []string{"npx", "--yes", "--prefer-offline", "@agentclientprotocol/claude-agent-acp"} + want := a.ManagedNPMRuntime().CachedACPCommand().Args() if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) { t.Fatalf("BuildCommand = %#v, want %#v", got, want) diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index c3759db105..d1420d6a22 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -100,7 +100,7 @@ func (a *CodexACP) BuildCommand(opts CommandOptions) Command { } func (a *CodexACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { - return ManagedNPMRuntimeSpec{Package: codexACPPackage} + return newManagedNPMRuntimeSpec(codexACPPackage) } func (a *CodexACP) Runtime() *RuntimeConfig { diff --git a/apps/backend/internal/agent/agents/codex_acp_test.go b/apps/backend/internal/agent/agents/codex_acp_test.go index bcd8252385..8be30ea503 100644 --- a/apps/backend/internal/agent/agents/codex_acp_test.go +++ b/apps/backend/internal/agent/agents/codex_acp_test.go @@ -25,8 +25,9 @@ func TestCodexACP_PermissionSettings_NoBridgeCLIFlags(t *testing.T) { } func TestCodexACP_BuildCommand_NoCodexCLIFlags(t *testing.T) { - want := []string{"npx", "--yes", "--prefer-offline", codexACPPackage} - cmd := NewCodexACP().BuildCommand(CommandOptions{ + agent := NewCodexACP() + want := agent.ManagedNPMRuntime().CachedACPCommand().Args() + cmd := agent.BuildCommand(CommandOptions{ PermissionValues: map[string]bool{PermissionKeyAutoApprove: true}, }) if !slices.Equal(cmd.Args(), want) { @@ -36,7 +37,7 @@ func TestCodexACP_BuildCommand_NoCodexCLIFlags(t *testing.T) { func TestCodexACP_UsesAgentClientProtocolBridge(t *testing.T) { a := NewCodexACP() - want := []string{"npx", "--yes", "--prefer-offline", "@agentclientprotocol/codex-acp"} + want := a.ManagedNPMRuntime().CachedACPCommand().Args() if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) { t.Fatalf("BuildCommand = %#v, want %#v", got, want) diff --git a/apps/backend/internal/agent/agents/copilot_acp.go b/apps/backend/internal/agent/agents/copilot_acp.go index cd3c8c76e8..c02c1850d8 100644 --- a/apps/backend/internal/agent/agents/copilot_acp.go +++ b/apps/backend/internal/agent/agents/copilot_acp.go @@ -95,7 +95,7 @@ func (a *CopilotACP) BuildCommand(opts CommandOptions) Command { } func (a *CopilotACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { - return ManagedNPMRuntimeSpec{Package: copilotACPPackage, ACPArgs: []string{"--acp"}} + return newManagedNPMRuntimeSpec(copilotACPPackage, "--acp") } func (a *CopilotACP) Runtime() *RuntimeConfig { diff --git a/apps/backend/internal/agent/agents/copilot_acp_test.go b/apps/backend/internal/agent/agents/copilot_acp_test.go index 640ab4b852..f0af202899 100644 --- a/apps/backend/internal/agent/agents/copilot_acp_test.go +++ b/apps/backend/internal/agent/agents/copilot_acp_test.go @@ -50,7 +50,7 @@ func TestCopilotACP_BuildCommand_NoCLIFlagSpecialCasing(t *testing.T) { ag := NewCopilotACP() cmd := ag.BuildCommand(CommandOptions{}) got := cmd.Args() - want := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} + want := ag.ManagedNPMRuntime().CachedACPCommand().Args() if len(got) != len(want) { t.Fatalf("argv length mismatch\n got: %#v\n want: %#v", got, want) } @@ -63,7 +63,7 @@ func TestCopilotACP_BuildCommand_NoCLIFlagSpecialCasing(t *testing.T) { func TestCopilotACP_UsesManagedPackageOnEveryNPMCommandSurface(t *testing.T) { ag := NewCopilotACP() - wantACP := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} + wantACP := ag.ManagedNPMRuntime().CachedACPCommand().Args() wantPassthrough := []string{"npx", "--yes", "--prefer-offline", "@github/copilot"} if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, wantACP) { @@ -91,7 +91,7 @@ func TestCopilotACP_BuildCommand_KeepsManagedPackageWhenNativeBinaryIsPresent(t } native := ag.BuildCommand(CommandOptions{PreferNativeBinary: true}).Args() - wantNative := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} + wantNative := ag.ManagedNPMRuntime().CachedACPCommand().Args() 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 759f569b2c..99e8229218 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -82,7 +82,7 @@ func (a *Gemini) BuildCommand(opts CommandOptions) Command { } func (a *Gemini) ManagedNPMRuntime() ManagedNPMRuntimeSpec { - return ManagedNPMRuntimeSpec{Package: geminiPackage, ACPArgs: []string{"--acp"}} + return newManagedNPMRuntimeSpec(geminiPackage, "--acp") } func (a *Gemini) Runtime() *RuntimeConfig { diff --git a/apps/backend/internal/agent/agents/gemini_test.go b/apps/backend/internal/agent/agents/gemini_test.go index c29680a33d..3d21404553 100644 --- a/apps/backend/internal/agent/agents/gemini_test.go +++ b/apps/backend/internal/agent/agents/gemini_test.go @@ -7,7 +7,7 @@ import ( func TestGeminiUsesManagedPackageOnEveryNPMCommandSurface(t *testing.T) { ag := NewGemini() - wantACP := []string{"npx", "--yes", "--prefer-offline", "@google/gemini-cli", "--acp"} + wantACP := ag.ManagedNPMRuntime().CachedACPCommand().Args() wantPassthrough := []string{"npx", "--yes", "--prefer-offline", "@google/gemini-cli"} if got := ag.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, wantACP) { diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime.go b/apps/backend/internal/agent/agents/managed_npm_runtime.go index 8893a7c78b..5dfe1cce56 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime.go @@ -1,6 +1,9 @@ package agents import ( + "fmt" + "strings" + "github.com/kandev/kandev/internal/agent/managedruntime" ) @@ -8,12 +11,46 @@ import ( // 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 + Package string + DefaultVersion string + ACPArgs []string +} + +// DefaultVersionOrPinned returns the reviewed default version, including the +// catalogue fallback for specs that do not embed one. Custom specs without an +// explicit default remain empty rather than treating the bare package name as +// a version. +func (s ManagedNPMRuntimeSpec) DefaultVersionOrPinned() string { + if s.DefaultVersion != "" { + return s.DefaultVersion + } + packageSpec := s.PackageSpec("") + if strings.HasPrefix(packageSpec, s.Package+"@") { + return strings.TrimPrefix(packageSpec, s.Package+"@") + } + return "" +} + +func newManagedNPMRuntimeSpec(packageName string, acpArgs ...string) ManagedNPMRuntimeSpec { + return ManagedNPMRuntimeSpec{ + Package: packageName, + DefaultVersion: MustDefaultManagedNPMRuntimeVersion(packageName), + ACPArgs: acpArgs, + } } // PackageSpec returns the trusted package name or exact package@version spec. func (s ManagedNPMRuntimeSpec) PackageSpec(version string) string { + if version == "" { + version = s.DefaultVersion + if version == "" { + if reviewed, err := DefaultManagedNPMRuntimeVersion(s.Package); err == nil { + version = reviewed + } else if isBuiltInManagedNPMRuntimePackage(s.Package) { + panic(fmt.Sprintf("managed runtime default is unavailable for %q: %v", s.Package, err)) + } + } + } if version == "" { return s.Package } @@ -23,13 +60,13 @@ func (s ManagedNPMRuntimeSpec) PackageSpec(version string) string { // ExecutionCacheKey returns npm's deterministic _npx execution-tree key for // this trusted package spec. npm derives it from the full package string using // SHA-512 and the first 16 lowercase hexadecimal characters. The optional -// argument preserves the unversioned legacy key when omitted. +// argument uses the reviewed default when omitted. func (s ManagedNPMRuntimeSpec) ExecutionCacheKey(versions ...string) string { return managedruntime.NpxExecutionCacheKey(s.PackageSpec(firstVersion(versions))) } // ACPCommand returns the normal launch command for the exact version when one -// is supplied. An empty version keeps the legacy unversioned behavior. +// is supplied. An empty version uses the reviewed default. func (s ManagedNPMRuntimeSpec) ACPCommand(version string) Command { return s.ACPCommandWithNpmPreference(version, false) } @@ -47,7 +84,7 @@ func (s ManagedNPMRuntimeSpec) ACPCommandWithNpmPreference(version string, prefe return NewCommand(args...) } -// CachedACPCommand returns the legacy unversioned launch command. +// CachedACPCommand returns the default exact-version launch command. func (s ManagedNPMRuntimeSpec) CachedACPCommand() Command { return s.ACPCommand("") } @@ -75,3 +112,8 @@ func firstVersion(versions []string) string { } return versions[0] } + +func isBuiltInManagedNPMRuntimePackage(packageName string) bool { + _, err := DefaultManagedNPMRuntimeVersion(packageName) + return err == nil +} diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go index 8b6915dfc6..cdfd35deca 100644 --- a/apps/backend/internal/agent/agents/managed_npm_runtime_test.go +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_test.go @@ -24,24 +24,33 @@ func TestManagedNPMRuntimeContracts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spec := tt.agent.ManagedNPMRuntime() + wantDefault := spec.DefaultVersionOrPinned() + if wantDefault == "" { + t.Fatal("DefaultVersionOrPinned() is empty") + } if got := spec.Package; got != tt.wantPackage { t.Fatalf("Package = %q, want %q", got, tt.wantPackage) } + if got := spec.DefaultVersion; got != wantDefault { + t.Fatalf("DefaultVersion = %q, want %q", got, wantDefault) + } 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...) + wantCached := append([]string{"npx", "--yes", "--prefer-offline", tt.wantPackage + "@" + wantDefault}, tt.wantACPArgs...) if !slices.Equal(cached.Args(), wantCached) { t.Fatalf("CachedACPCommand = %#v, want %#v", cached.Args(), wantCached) } - assertUnversionedPackage(t, cached.Args(), tt.wantPackage) + if got := spec.PackageSpec(""); got != tt.wantPackage+"@"+wantDefault { + t.Fatalf("empty PackageSpec = %q, want exact default", got) + } update := spec.CacheUpdateCommand() wantUpdate := []string{ "npm", "exec", "--yes", "--prefer-online", - "--package=" + tt.wantPackage, "--", "node", "-e", "", + "--package=" + tt.wantPackage + "@" + wantDefault, "--", "node", "-e", "", } if !slices.Equal(update.Args(), wantUpdate) { t.Fatalf("CacheUpdateCommand = %#v, want %#v", update.Args(), wantUpdate) @@ -55,8 +64,27 @@ func TestManagedNPMRuntimeContracts(t *testing.T) { func TestManagedNPMRuntimeExecutionCacheKeyMatchesNPM(t *testing.T) { spec := ManagedNPMRuntimeSpec{Package: "opencode-ai"} - if got := spec.ExecutionCacheKey(); got != "e2094862b59aac7b" { - t.Fatalf("ExecutionCacheKey = %q, want npm key e2094862b59aac7b", got) + if got := spec.ExecutionCacheKey(); got != "305cdb391114ad88" { + t.Fatalf("ExecutionCacheKey = %q, want npm key 305cdb391114ad88", got) + } +} + +func TestManagedNPMRuntimeDefaultVersionOrPinned(t *testing.T) { + tests := []struct { + name string + spec ManagedNPMRuntimeSpec + want string + }{ + {name: "explicit default", spec: ManagedNPMRuntimeSpec{Package: "@scope/managed", DefaultVersion: "1.2.3"}, want: "1.2.3"}, + {name: "built-in catalogue fallback", spec: ManagedNPMRuntimeSpec{Package: "opencode-ai"}, want: MustDefaultManagedNPMRuntimeVersion("opencode-ai")}, + {name: "custom package without default", spec: ManagedNPMRuntimeSpec{Package: "@scope/managed"}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.spec.DefaultVersionOrPinned(); got != tt.want { + t.Fatalf("DefaultVersionOrPinned() = %q, want %q", got, tt.want) + } + }) } } @@ -134,14 +162,3 @@ func TestManagedAgentsHonorExactVersionCommandOption(t *testing.T) { }) } } - -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/managed_npm_runtime_versions.go b/apps/backend/internal/agent/agents/managed_npm_runtime_versions.go new file mode 100644 index 0000000000..f21747ea3e --- /dev/null +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_versions.go @@ -0,0 +1,73 @@ +package agents + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/kandev/kandev/internal/agent/managedruntime" +) + +//go:embed managed_npm_runtime_versions.json +var managedNPMRuntimeVersionsJSON []byte + +var ( + managedNPMRuntimeVersionsOnce sync.Once + managedNPMRuntimeVersions map[string]string + managedNPMRuntimeVersionsErr error +) + +// DefaultManagedNPMRuntimeVersion returns the reviewed exact default for a +// trusted managed npm package. +func DefaultManagedNPMRuntimeVersion(packageName string) (string, error) { + packageName = strings.TrimSpace(packageName) + if packageName == "" { + return "", fmt.Errorf("managed runtime package is empty") + } + managedNPMRuntimeVersionsOnce.Do(loadManagedNPMRuntimeVersions) + if managedNPMRuntimeVersionsErr != nil { + return "", managedNPMRuntimeVersionsErr + } + version, ok := managedNPMRuntimeVersions[packageName] + if !ok { + return "", fmt.Errorf("no reviewed default for managed runtime package %q", packageName) + } + return version, nil +} + +// MustDefaultManagedNPMRuntimeVersion returns a reviewed default or panics. +// Built-in agents call this while the registry is loaded, so a malformed or +// incomplete catalogue fails startup instead of creating an unversioned +// command. +func MustDefaultManagedNPMRuntimeVersion(packageName string) string { + version, err := DefaultManagedNPMRuntimeVersion(packageName) + if err != nil { + panic(fmt.Sprintf("managed runtime defaults are invalid: %v", err)) + } + return version +} + +func loadManagedNPMRuntimeVersions() { + var versions map[string]string + if err := json.Unmarshal(managedNPMRuntimeVersionsJSON, &versions); err != nil { + managedNPMRuntimeVersionsErr = fmt.Errorf("decode managed runtime defaults: %w", err) + return + } + if len(versions) == 0 { + managedNPMRuntimeVersionsErr = fmt.Errorf("managed runtime defaults are empty") + return + } + for packageName, version := range versions { + if strings.TrimSpace(packageName) == "" { + managedNPMRuntimeVersionsErr = fmt.Errorf("managed runtime defaults contain an empty package") + return + } + if _, err := managedruntime.ParseStableVersion(version); err != nil { + managedNPMRuntimeVersionsErr = fmt.Errorf("managed runtime default %q for %q: %w", version, packageName, err) + return + } + } + managedNPMRuntimeVersions = versions +} diff --git a/apps/backend/internal/agent/agents/managed_npm_runtime_versions.json b/apps/backend/internal/agent/agents/managed_npm_runtime_versions.json new file mode 100644 index 0000000000..98b07417bc --- /dev/null +++ b/apps/backend/internal/agent/agents/managed_npm_runtime_versions.json @@ -0,0 +1,7 @@ +{ + "@agentclientprotocol/claude-agent-acp": "0.70.0", + "@agentclientprotocol/codex-acp": "1.6.0", + "@github/copilot": "1.0.75", + "@google/gemini-cli": "0.52.0", + "opencode-ai": "1.18.18" +} diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index b91c4faa08..4d11bffed1 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -89,10 +89,10 @@ func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command { } func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec { - return ManagedNPMRuntimeSpec{ - Package: opencodeACPPackage, - ACPArgs: []string{"acp", "--print-logs", "--log-level", "ERROR"}, - } + return newManagedNPMRuntimeSpec( + opencodeACPPackage, + "acp", "--print-logs", "--log-level", "ERROR", + ) } func (a *OpenCodeACP) Runtime() *RuntimeConfig { diff --git a/apps/backend/internal/agent/agents/opencode_acp_test.go b/apps/backend/internal/agent/agents/opencode_acp_test.go index cdc1265e72..5ab5debdd3 100644 --- a/apps/backend/internal/agent/agents/opencode_acp_test.go +++ b/apps/backend/internal/agent/agents/opencode_acp_test.go @@ -10,7 +10,7 @@ import ( func TestOpenCodeACPUsesManagedRuntime(t *testing.T) { a := NewOpenCodeACP() - want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai", "acp", "--print-logs", "--log-level", "ERROR"} + want := a.ManagedNPMRuntime().CachedACPCommand().Args() if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) { t.Fatalf("BuildCommand = %#v, want %#v", got, want) diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index 4eca56472d..05d3c2c1fe 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -718,7 +718,7 @@ func (m *Manager) resolveInferenceCommand( return agents.Command{}, fmt.Errorf("resolve active managed runtime version for %s: %w", agentType, err) } if !found || selection.Package != spec.Package { - return command, nil + return spec.ACPCommand(spec.DefaultVersion), nil } return spec.ACPCommand(selection.Version), nil } diff --git a/apps/backend/internal/agent/managedruntime/selection.go b/apps/backend/internal/agent/managedruntime/selection.go index 6ad494c873..1234bd39bb 100644 --- a/apps/backend/internal/agent/managedruntime/selection.go +++ b/apps/backend/internal/agent/managedruntime/selection.go @@ -20,6 +20,7 @@ const selectionKeyPrefix = "managed_runtime.active." type SettingsStore interface { Get(context.Context, string) ([]byte, bool, error) Save(context.Context, string, []byte) error + Delete(context.Context, string) error } // SelectionStore is the runtime-facing seam for reading and persisting an @@ -27,6 +28,7 @@ type SettingsStore interface { type SelectionStore interface { Get(context.Context, string, string) (Selection, bool, error) Save(context.Context, string, string, string) error + Delete(context.Context, string, string) error } // SelectionReader is the read-only seam used by runtime command builders. @@ -97,6 +99,19 @@ func (s *Store) Save(ctx context.Context, agentID, packageName, version string) return s.settings.Save(ctx, selectionKey(agentID), raw) } +// Delete removes the operator selection for one trusted package. The built-in +// default remains outside the settings store and becomes effective after this +// operation succeeds. +func (s *Store) Delete(ctx context.Context, agentID, packageName string) error { + if s == nil || s.settings == nil { + return errSettingsMissing + } + if agentID == "" || packageName == "" { + return fmt.Errorf("%w: agent and package are required", ErrInvalidSelection) + } + return s.settings.Delete(ctx, selectionKey(agentID)) +} + func selectionKey(agentID string) string { return selectionKeyPrefix + strings.TrimSpace(agentID) } diff --git a/apps/backend/internal/agent/managedruntime/selection_test.go b/apps/backend/internal/agent/managedruntime/selection_test.go index e3d9fbe68d..14cc79a32d 100644 --- a/apps/backend/internal/agent/managedruntime/selection_test.go +++ b/apps/backend/internal/agent/managedruntime/selection_test.go @@ -30,6 +30,14 @@ func (s *memorySettings) Save(_ context.Context, key string, value []byte) error return nil } +func (s *memorySettings) Delete(_ context.Context, key string) error { + if s.err != nil { + return s.err + } + delete(s.values, key) + return nil +} + func TestStoreRoundTripAndPerAgentIsolation(t *testing.T) { settings := &memorySettings{} store := NewStore(settings) @@ -80,3 +88,18 @@ func TestStorePropagatesSettingsErrors(t *testing.T) { t.Fatalf("save error = %v, want %v", err, wantErr) } } + +func TestStoreDeleteRemovesSelection(t *testing.T) { + settings := &memorySettings{} + store := NewStore(settings) + ctx := context.Background() + if err := store.Save(ctx, "opencode-acp", "opencode-ai", "1.18.5"); err != nil { + t.Fatalf("save selection: %v", err) + } + if err := store.Delete(ctx, "opencode-acp", "opencode-ai"); err != nil { + t.Fatalf("delete selection: %v", err) + } + if _, found, err := store.Get(ctx, "opencode-acp", "opencode-ai"); err != nil || found { + t.Fatalf("selection after delete = found %v, err %v; want absent", found, err) + } +} diff --git a/apps/backend/internal/agent/managedruntime/versions.go b/apps/backend/internal/agent/managedruntime/versions.go index 0a1357729d..c97b6fbbe0 100644 --- a/apps/backend/internal/agent/managedruntime/versions.go +++ b/apps/backend/internal/agent/managedruntime/versions.go @@ -17,10 +17,11 @@ var ErrNoStableVersions = errors.New("no stable runtime versions available") type Operation string const ( - OperationUpdate Operation = "update" - OperationRollback Operation = "rollback" - OperationRepair Operation = "repair" - OperationUpToDate Operation = "up_to_date" + OperationUpdate Operation = "update" + OperationRollback Operation = "rollback" + OperationRepair Operation = "repair" + OperationUpToDate Operation = "up_to_date" + OperationUseDefault Operation = "use_default" ) // VersionOption is one selectable version in the bounded catalogue. @@ -211,6 +212,48 @@ func ClassifyOperation(active, current, target string) (Operation, error) { return OperationRepair, nil } +// ClassifyEffectiveOperation derives the operator action from the effective +// version used by future commands. An active selection is distinct from the +// effective version because choosing the shipped default removes that +// selection only after the candidate has been validated. +func ClassifyEffectiveOperation( + useDefault bool, + active string, + effective string, + current string, + target string, + defaultVersion string, +) (Operation, error) { + targetParsed, err := ParseStableVersion(target) + if err != nil { + return "", err + } + if useDefault && active != "" && defaultVersion != "" { + if _, err := ParseStableVersion(defaultVersion); err != nil { + return "", err + } + if target == defaultVersion { + return OperationUseDefault, nil + } + } + effectiveParsed, effectiveOK := parseKnownVersion(effective) + _, currentOK := parseKnownVersion(current) + if !effectiveOK || !currentOK { + return OperationRepair, nil + } + if effective == target && current == target { + return OperationUpToDate, nil + } + compare := targetParsed.Compare(effectiveParsed) + if compare > 0 { + return OperationUpdate, nil + } + if compare < 0 { + return OperationRollback, nil + } + return OperationRepair, nil +} + func parseKnownVersion(value string) (*semver.Version, bool) { if value == "" { return nil, false diff --git a/apps/backend/internal/agent/managedruntime/versions_test.go b/apps/backend/internal/agent/managedruntime/versions_test.go index 853af82929..b5c18321fe 100644 --- a/apps/backend/internal/agent/managedruntime/versions_test.go +++ b/apps/backend/internal/agent/managedruntime/versions_test.go @@ -91,3 +91,64 @@ func TestClassifyOperation(t *testing.T) { }) } } + +func TestClassifyEffectiveOperation(t *testing.T) { + tests := []struct { + name string + useDefault bool + active string + effective string + current string + target string + defaultVer string + want Operation + }{ + { + name: "return to default", + useDefault: true, + active: "1.18.5", + effective: "1.18.5", + current: "1.18.5", + target: "1.18.18", + defaultVer: "1.18.18", + want: OperationUseDefault, + }, + { + name: "default is up to date without active selection", + effective: "1.18.18", + current: "1.18.18", + target: "1.18.18", + defaultVer: "1.18.18", + want: OperationUpToDate, + }, + { + name: "selected version updates", + active: "1.18.5", + effective: "1.18.5", + current: "1.18.5", + target: "1.18.10", + defaultVer: "1.18.18", + want: OperationUpdate, + }, + { + name: "numeric default remains an active selection", + active: "1.18.5", + effective: "1.18.5", + current: "1.18.5", + target: "1.18.18", + defaultVer: "1.18.18", + want: OperationUpdate, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ClassifyEffectiveOperation(tt.useDefault, tt.active, tt.effective, tt.current, tt.target, tt.defaultVer) + if err != nil { + t.Fatalf("ClassifyEffectiveOperation: %v", err) + } + if got != tt.want { + t.Fatalf("operation = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agent/registry/provider.go b/apps/backend/internal/agent/registry/provider.go index 63006e880c..fcafa667fe 100644 --- a/apps/backend/internal/agent/registry/provider.go +++ b/apps/backend/internal/agent/registry/provider.go @@ -162,12 +162,15 @@ func (r *Registry) resolveManagedProviderVersion( } spec := managed.ManagedNPMRuntime() selection, found, err := selectionStore.Get(ctx, providerID, spec.Package) - if err != nil || !found { - return "", err == nil - } - if selection.Package != spec.Package || selection.Version == "" { + if err != nil { return "", false } + if !found || selection.Package != spec.Package { + return spec.DefaultVersion, true + } + if selection.Version == "" { + return spec.DefaultVersion, true + } return selection.Version, true } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 75dc4a055a..834d2d0238 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -444,13 +444,17 @@ type ExecutorCreateRequest struct { ComparisonTargets map[string]models.ComparisonTarget McpServers []McpServerConfig AgentConfig agents.Agent // Agent type info needed by runtimes - PreviousExecutionID string // Non-empty when reconnecting to a previous execution - McpMode string // MCP tool mode: "task" (default), "config", or "office" - McpProviders []string // Normalized provider capabilities attached to the task - McpProfile *mcpprofile.Context - AuthToken string // Previously handshaken agentctl token for reconnects - BootstrapNonce string // Stored nonce for re-handshake after container restart - AgentctlStartupConfig commonconfig.AgentctlStartupConfig + // ManagedRuntimeVersion is the effective exact version resolved for this + // launch. Remote executors use it during preflight before agentctl receives + // the final command. + ManagedRuntimeVersion string + PreviousExecutionID string // Non-empty when reconnecting to a previous execution + McpMode string // MCP tool mode: "task" (default), "config", or "office" + McpProviders []string // Normalized provider capabilities attached to the task + McpProfile *mcpprofile.Context + AuthToken string // Previously handshaken agentctl token for reconnects + BootstrapNonce string // Stored nonce for re-handshake after container restart + AgentctlStartupConfig commonconfig.AgentctlStartupConfig // OnProgress is an optional callback for streaming preparation progress. // Executors that perform multi-step setup (e.g. Sprites, remote Docker) can diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go index a6dafe12fa..d54cb7306a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go @@ -925,7 +925,7 @@ func (r *SSHExecutor) preflightAgentBinary( return nil } - cmd := req.AgentConfig.BuildCommand(agents.CommandOptions{Runtime: agentruntime.RuntimeSSH}) + cmd := buildRemotePreflightAgentCommand(req) args := cmd.Args() if len(args) == 0 { return nil @@ -945,6 +945,16 @@ func (r *SSHExecutor) preflightAgentBinary( return nil } +func buildRemotePreflightAgentCommand(req *ExecutorCreateRequest) agents.Command { + if req == nil || req.AgentConfig == nil { + return agents.Command{} + } + return req.AgentConfig.BuildCommand(agents.CommandOptions{ + Runtime: agentruntime.RuntimeSSH, + ManagedRuntimeVersion: req.ManagedRuntimeVersion, + }) +} + // probeNativeBinary probes the remote for the agent's standalone CLI (if it // declares one via NativeBinaryAgent). On a hit it records the binary name in // req.Metadata under MetadataKeyNativeBinary and returns true. It returns false diff --git a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go index e9dbf670c2..d851eec599 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/managed_runtime_command_test.go @@ -30,7 +30,11 @@ func (s managedRuntimeSelectionStore) Save(context.Context, string, string, stri return nil } -func TestBuildAgentCommandUsesExactVersionOnlyOnStandalone(t *testing.T) { +func (s managedRuntimeSelectionStore) Delete(context.Context, string, string) error { + return nil +} + +func TestBuildAgentCommandUsesEffectiveVersionAcrossExecutors(t *testing.T) { log := newTestLogger() manager := &Manager{commandBuilder: NewCommandBuilder(), logger: log} manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{ @@ -46,8 +50,8 @@ func TestBuildAgentCommandUsesExactVersionOnlyOnStandalone(t *testing.T) { }{ {name: "standalone", executorType: models.ExecutorTypeLocal, wantVersion: true}, {name: "worktree", executorType: models.ExecutorTypeWorktree, wantVersion: true}, - {name: "docker", executorType: models.ExecutorTypeLocalDocker}, - {name: "ssh", executorType: models.ExecutorTypeSSH}, + {name: "docker", executorType: models.ExecutorTypeLocalDocker, wantVersion: true}, + {name: "ssh", executorType: models.ExecutorTypeSSH, wantVersion: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -121,7 +125,7 @@ func TestBuildFreshAgentCommandFailsWhenStandaloneSelectionCannotBeRead(t *testi } } -func TestBuildFreshAgentCommandLeavesRemoteCommandUnversioned(t *testing.T) { +func TestBuildFreshAgentCommandUsesEffectiveVersionForRemoteRestart(t *testing.T) { manager := &Manager{commandBuilder: NewCommandBuilder(), logger: newTestLogger()} manager.SetManagedRuntimeSelectionStore(managedRuntimeSelectionStore{ selection: managedruntime.Selection{Package: "opencode-ai", Version: "1.18.5"}, @@ -136,10 +140,19 @@ func TestBuildFreshAgentCommandLeavesRemoteCommandUnversioned(t *testing.T) { if err != nil { t.Fatalf("buildFreshAgentCommand: %v", err) } - if strings.Contains(commands.initial, "opencode-ai@1.18.5") { - t.Fatalf("remote restart command = %q, must not use host selection", commands.initial) - } - if !strings.Contains(commands.initial, "opencode-ai") { - t.Fatalf("remote restart command = %q, want managed runtime command", commands.initial) + if !strings.Contains(commands.initial, "opencode-ai@1.18.5") { + t.Fatalf("remote restart command = %q, want exact selected version", commands.initial) + } +} + +func TestRemotePreflightUsesResolvedManagedRuntimeVersion(t *testing.T) { + req := &ExecutorCreateRequest{ + AgentConfig: agents.NewOpenCodeACP(), + ManagedRuntimeVersion: "1.18.5", + } + got := buildRemotePreflightAgentCommand(req).Args() + want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai@1.18.5", "acp", "--print-logs", "--log-level", "ERROR"} + if !strings.EqualFold(strings.Join(got, " "), strings.Join(want, " ")) { + t.Fatalf("remote preflight command = %#v, want %#v", got, want) } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 942b431b20..e684364811 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -682,6 +682,14 @@ func (m *Manager) prepareExecutionCreateRequest( if !ok { return nil, fmt.Errorf("agent type %q not found in registry", info.AgentID) } + managedRuntimeVersion, err := m.resolveManagedRuntimeVersion( + ctx, + models.ExecutorType(info.ExecutorType).Runtime(), + agentConfig, + ) + if err != nil { + return nil, err + } executionProfileID := workspaceExecutionProfileID(info) profileInfo := m.resolveWorkspaceExecutionProfile(ctx, executionProfileID) @@ -747,6 +755,7 @@ func (m *Manager) prepareExecutionCreateRequest( AgentctlStartupConfig: m.agentctlStartupConfig, RemoteContributions: remoteContributions, ContributionDestinations: contributionDestinations, + ManagedRuntimeVersion: managedRuntimeVersion, ComparisonTargets: comparisonTargets, }, profileInfo: profileInfo, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 5fa0f8ca0d..2c674d84aa 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -489,23 +489,32 @@ func (m *Manager) buildAgentCommandWithContext( func (m *Manager) resolveManagedRuntimeVersion( ctx context.Context, - runtime agentruntime.Runtime, + _ agentruntime.Runtime, agentConfig agents.Agent, ) (string, error) { - if runtime != agentruntime.RuntimeStandalone || m.managedRuntimeSelections == nil { - return "", nil - } managed, ok := agentConfig.(agents.ManagedNPMRuntimeAgent) if !ok { return "", nil } spec := managed.ManagedNPMRuntime() + effectiveVersion := spec.DefaultVersion + if effectiveVersion == "" { + // Test and embedded agents may construct a spec literal. PackageSpec + // still resolves a known built-in package's reviewed default. + packageSpec := spec.PackageSpec("") + if packageSpec != spec.Package { + effectiveVersion = strings.TrimPrefix(packageSpec, spec.Package+"@") + } + } + if m.managedRuntimeSelections == nil { + return effectiveVersion, nil + } selection, found, err := m.managedRuntimeSelections.Get(ctx, agentConfig.ID(), spec.Package) if err != nil { return "", fmt.Errorf("resolve active managed runtime version for %s: %w", agentConfig.ID(), err) } if !found || selection.Package != spec.Package { - return "", nil + return effectiveVersion, nil } return selection.Version, nil } 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 f93860bfed..b6479935d3 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go @@ -136,40 +136,35 @@ func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) { tests := []struct { name string agent agents.Agent - want string }{ { name: "claude", agent: agents.NewClaudeACP(), - want: "npx --yes --prefer-offline @agentclientprotocol/claude-agent-acp", }, { name: "codex", agent: agents.NewCodexACP(), - want: "npx --yes --prefer-offline @agentclientprotocol/codex-acp", }, { name: "opencode", agent: agents.NewOpenCodeACP(), - want: "npx --yes --prefer-offline opencode-ai acp --print-logs --log-level ERROR", }, { 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) { + want := strings.Join(tt.agent.(agents.ManagedNPMRuntimeAgent).ManagedNPMRuntime().CachedACPCommand().Args(), " ") cmds, err := mgr.buildAgentCommandWithContext(context.Background(), &LaunchRequest{}, nil, tt.agent, true) require.NoError(t, err) - require.Equal(t, tt.want, cmds.initial) + require.Equal(t, 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 c126c9ab19..52cb89a83d 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -166,10 +166,18 @@ func (c *Controller) buildRuntimeUpdateDTO(ctx context.Context, ag agents.Agent, if spec.Package == "" { return nil } - item := &dto.RuntimeUpdateDTO{Supported: true, Package: spec.Package} + defaultVersion := spec.DefaultVersionOrPinned() + item := &dto.RuntimeUpdateDTO{ + Supported: true, + Package: spec.Package, + DefaultVersion: defaultVersion, + EffectiveVersion: defaultVersion, + } if c.managedRuntimeSelections != nil { - if selection, found, err := c.managedRuntimeSelections.Get(ctx, ag.ID(), spec.Package); err == nil && found { + if selection, found, err := c.managedRuntimeSelections.Get(ctx, ag.ID(), spec.Package); err == nil && found && + selection.Package == spec.Package { item.ActiveVersion = selection.Version + item.EffectiveVersion = selection.Version } } if c.runtimeUpdater != nil { 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 a2de19580e..c5b19a78b1 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery_test.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/hostutility" + "github.com/kandev/kandev/internal/agent/managedruntime" ) type managedTestAgent struct { @@ -84,6 +85,39 @@ func TestUnavailableManagedAgentOmitsRuntimeMetadata(t *testing.T) { } } +func TestAvailableAgentIgnoresMismatchedManagedRuntimeSelection(t *testing.T) { + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: agents.ManagedNPMRuntimeSpec{ + Package: "@example/managed-acp", + DefaultVersion: "1.2.3", + }, + } + selectionStore := newRecoverySelectionStore() + selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{ + Package: "@other/managed-acp", + Version: "9.9.9", + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + ctrl.SetManagedRuntimeSelectionStore(selectionStore) + + item := ctrl.buildAvailableAgentDTO( + context.Background(), + ag, + discovery.Availability{Name: ag.ID(), Available: true}, + time.Now(), + ) + if item.RuntimeUpdate == nil { + t.Fatal("runtime_update missing") + } + if item.RuntimeUpdate.ActiveVersion != "" { + t.Fatalf("active_version = %q, want empty for mismatched selection", item.RuntimeUpdate.ActiveVersion) + } + if item.RuntimeUpdate.EffectiveVersion != "1.2.3" { + t.Fatalf("effective_version = %q, want default 1.2.3", item.RuntimeUpdate.EffectiveVersion) + } +} + func TestAvailableACPInferenceAgentIsDynamicBeforeProbeSnapshot(t *testing.T) { ag := agents.NewMockAgent() ag.SetEnabled(true) diff --git a/apps/backend/internal/agent/settings/controller/agent_update.go b/apps/backend/internal/agent/settings/controller/agent_update.go index edbc36b01b..2f592b4d7d 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update.go +++ b/apps/backend/internal/agent/settings/controller/agent_update.go @@ -33,6 +33,28 @@ func (c *Controller) PreviewAgentUpdate( ctx context.Context, name string, targetVersions ...string, +) (*dto.AgentUpdatePreviewDTO, error) { + target := "" + if len(targetVersions) > 0 { + target = strings.TrimSpace(targetVersions[0]) + } + return c.previewAgentUpdate(ctx, name, target, false) +} + +// PreviewAgentUpdateUseDefault previews returning to the reviewed Kandev +// default without claiming maintenance state or running a candidate. +func (c *Controller) PreviewAgentUpdateUseDefault( + ctx context.Context, + name string, +) (*dto.AgentUpdatePreviewDTO, error) { + return c.previewAgentUpdate(ctx, name, "", true) +} + +func (c *Controller) previewAgentUpdate( + ctx context.Context, + name string, + targetVersion string, + useDefault bool, ) (*dto.AgentUpdatePreviewDTO, error) { if c.runtimeUpdater == nil { return nil, ErrRuntimeUpdaterUnavailable @@ -54,30 +76,34 @@ func (c *Controller) PreviewAgentUpdate( if caps, found := c.runtimeUpdater.CurrentCapabilities(name); found { current = caps.AgentVersion } - active, err := c.activeRuntimeVersion(ctx, name, spec.Package) + active, effective, defaultVersion, err := c.runtimeVersions(ctx, name, spec) if err != nil { return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err) } - catalogue, exactCatalogue, err := c.resolveRuntimeCatalogue(ctx, spec.Package, active, current) + catalogue, exactCatalogue, err := c.resolveRuntimeCatalogue( + ctx, spec.Package, active, current, effective, defaultVersion, + ) if err != nil { return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdatePreviewFailed, err) } - target := catalogue.Latest - if len(targetVersions) > 0 && strings.TrimSpace(targetVersions[0]) != "" { - target = strings.TrimSpace(targetVersions[0]) - if _, err := managedruntime.ParseStableVersion(target); err != nil { - return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) - } - if exactCatalogue && !catalogue.Has(target) { - return nil, fmt.Errorf("%w: %s", ErrRuntimeUpdateTargetMissing, target) - } + target, err := resolvePreviewTarget( + catalogue, + exactCatalogue, + targetVersion, + defaultVersion, + useDefault, + ) + if err != nil { + return nil, err } - operation, err := managedruntime.ClassifyOperation(active, current, target) + operation, err := managedruntime.ClassifyEffectiveOperation( + useDefault, active, effective, current, target, defaultVersion, + ) if err != nil { return nil, fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) } command := spec.CacheUpdateCommand(target).Args() - if !exactCatalogue && (len(targetVersions) == 0 || strings.TrimSpace(targetVersions[0]) == "") { + if !exactCatalogue && targetVersion == "" && !useDefault { // Keep the compatibility preview for embedders that provide only the // legacy latest-version seam. Production uses the catalogue resolver and // always previews an exact package@version command. @@ -87,7 +113,9 @@ func (c *Controller) PreviewAgentUpdate( AgentName: name, Package: spec.Package, CurrentVersion: current, + DefaultVersion: defaultVersion, ActiveVersion: active, + EffectiveVersion: effective, TargetVersion: target, Operation: string(operation), AvailableVersions: runtimeVersionDTOs(catalogue), @@ -96,6 +124,31 @@ func (c *Controller) PreviewAgentUpdate( }, nil } +func resolvePreviewTarget( + catalogue managedruntime.Catalogue, + exactCatalogue bool, + targetVersion string, + defaultVersion string, + useDefault bool, +) (string, error) { + target := catalogue.Latest + switch { + case useDefault: + target = defaultVersion + case targetVersion != "": + target = targetVersion + default: + return target, nil + } + if _, err := managedruntime.ParseStableVersion(target); err != nil { + return "", fmt.Errorf("%w: %v", ErrRuntimeUpdateTargetInvalid, err) + } + if exactCatalogue && !catalogue.Has(target) { + return "", fmt.Errorf("%w: %s", ErrRuntimeUpdateTargetMissing, target) + } + return target, nil +} + func (c *Controller) resolveRuntimeCatalogue( ctx context.Context, packageName string, @@ -146,6 +199,23 @@ func (c *Controller) activeRuntimeVersion( return selection.Version, nil } +func (c *Controller) runtimeVersions( + ctx context.Context, + agentName string, + spec agents.ManagedNPMRuntimeSpec, +) (active, effective, defaultVersion string, err error) { + defaultVersion = spec.DefaultVersionOrPinned() + active, err = c.activeRuntimeVersion(ctx, agentName, spec.Package) + if err != nil { + return "", "", defaultVersion, err + } + effective = defaultVersion + if active != "" { + effective = active + } + return active, effective, defaultVersion, nil +} + // RuntimeUpdater is the external-process and host-probe boundary used by // managed runtime jobs. Implementations must execute commands as direct argv. type RuntimeUpdater interface { @@ -167,6 +237,11 @@ type RuntimeVersionMetadata struct { Latest string } +type npmRuntimeMetadata struct { + Versions []string `json:"versions"` + DistTags map[string]string `json:"dist-tags"` +} + // RuntimeVersionResolver obtains package metadata without executing a package. type RuntimeVersionResolver interface { ResolveVersions(context.Context, string) (RuntimeVersionMetadata, error) @@ -267,12 +342,9 @@ func (u *hostRuntimeUpdater) ResolveVersions( if err != nil { return RuntimeVersionMetadata{}, err } - var metadata struct { - Versions []string `json:"versions"` - DistTags map[string]string `json:"dist-tags"` - } - if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &metadata); err != nil { - return RuntimeVersionMetadata{}, fmt.Errorf("parse npm runtime metadata: %w", err) + metadata, err := parseNPMRuntimeMetadata(output) + if err != nil { + return RuntimeVersionMetadata{}, err } latest := "" if metadata.DistTags != nil { @@ -284,6 +356,26 @@ func (u *hostRuntimeUpdater) ResolveVersions( return RuntimeVersionMetadata{Versions: metadata.Versions, Latest: latest}, nil } +func parseNPMRuntimeMetadata(output string) (npmRuntimeMetadata, error) { + payload := []byte(strings.TrimSpace(output)) + if len(payload) > 0 && payload[0] == '[' { + var records []npmRuntimeMetadata + if err := json.Unmarshal(payload, &records); err != nil { + return npmRuntimeMetadata{}, fmt.Errorf("parse npm runtime metadata: %w", err) + } + if len(records) != 1 { + return npmRuntimeMetadata{}, errors.New("parse npm runtime metadata: expected one record") + } + return records[0], nil + } + + var metadata npmRuntimeMetadata + if err := json.Unmarshal(payload, &metadata); err != nil { + return npmRuntimeMetadata{}, fmt.Errorf("parse npm runtime metadata: %w", err) + } + return metadata, nil +} + func runDirectCommandOutput(ctx context.Context, command agents.Command) (string, error) { argv := command.Args() if len(argv) == 0 { @@ -323,7 +415,11 @@ func (u *hostRuntimeUpdater) InvalidateExecutionCache(ctx context.Context, packa if err != nil { return err } - return managedruntime.RemoveNpxExecutionTree(cacheRoot, packageName) + // Cache repair must target the same exact package spec used by the managed + // launch command. Known built-ins therefore invalidate their pinned tree, + // while unknown packages retain the unversioned compatibility path. + spec := agents.ManagedNPMRuntimeSpec{Package: packageName} + return managedruntime.RemoveNpxExecutionTree(cacheRoot, spec.PackageSpec("")) } func (u *hostRuntimeUpdater) InvalidateExecutionCacheVersion( @@ -429,6 +525,25 @@ func (c *Controller) EnqueueAgentUpdate( ctx context.Context, name string, targetVersion string, +) (*dto.AgentUpdateJobDTO, error) { + return c.enqueueAgentUpdate(ctx, name, strings.TrimSpace(targetVersion), false) +} + +// EnqueueAgentUpdateUseDefault validates and activates the reviewed Kandev +// default, deleting an operator selection only after the candidate probe +// succeeds. +func (c *Controller) EnqueueAgentUpdateUseDefault( + ctx context.Context, + name string, +) (*dto.AgentUpdateJobDTO, error) { + return c.enqueueAgentUpdate(ctx, name, "", true) +} + +func (c *Controller) enqueueAgentUpdate( + ctx context.Context, + name string, + targetVersion string, + useDefault bool, ) (*dto.AgentUpdateJobDTO, error) { if c.updateJobStore == nil || c.runtimeUpdater == nil { return nil, ErrRuntimeUpdaterUnavailable @@ -448,6 +563,9 @@ func (c *Controller) EnqueueAgentUpdate( if strings.TrimSpace(spec.Package) == "" { return nil, ErrRuntimeUpdateUnsupported } + if useDefault { + targetVersion = spec.DefaultVersionOrPinned() + } targetVersion = strings.TrimSpace(targetVersion) if targetVersion == "" { return nil, ErrRuntimeUpdateTargetRequired @@ -455,10 +573,18 @@ func (c *Controller) EnqueueAgentUpdate( if err := c.validateAgentUpdateTarget(ctx, spec, targetVersion); err != nil { return nil, err } - if noOp := c.alreadyActiveHealthyUpdate(ctx, name, spec, targetVersion); noOp != nil { - return noOp, nil + if !useDefault { + if noOp := c.alreadyActiveHealthyUpdate(ctx, name, spec, targetVersion); noOp != nil { + return noOp, nil + } + } + var job *AgentUpdateJob + var err error + if useDefault { + job, err = c.updateJobStore.EnqueueDefault(name, spec) + } else { + job, err = c.updateJobStore.Enqueue(name, spec, targetVersion) } - job, err := c.updateJobStore.Enqueue(name, spec, targetVersion) if err != nil { return nil, err } @@ -515,16 +641,19 @@ func (c *Controller) alreadyActiveHealthyUpdate( return nil } now := time.Now().UTC() + defaultVersion := spec.DefaultVersionOrPinned() return &dto.AgentUpdateJobDTO{ - AgentName: agentName, - Status: dto.AgentUpdateJobStatusSucceeded, - Operation: string(managedruntime.OperationUpToDate), - CurrentVersion: caps.AgentVersion, - ActiveVersion: selection.Version, - TargetVersion: targetVersion, - Output: "Runtime already up to date.\n", - StartedAt: now, - FinishedAt: &now, + AgentName: agentName, + Status: dto.AgentUpdateJobStatusSucceeded, + Operation: string(managedruntime.OperationUpToDate), + CurrentVersion: caps.AgentVersion, + DefaultVersion: defaultVersion, + ActiveVersion: selection.Version, + EffectiveVersion: selection.Version, + TargetVersion: targetVersion, + Output: "Runtime already up to date.\n", + StartedAt: now, + FinishedAt: &now, } } diff --git a/apps/backend/internal/agent/settings/controller/agent_update_job.go b/apps/backend/internal/agent/settings/controller/agent_update_job.go index 7d7299d932..0447881381 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_job.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_job.go @@ -19,31 +19,36 @@ import ( ) type AgentUpdateJob struct { - ID string - AgentName string - Status dto.AgentUpdateJobStatus - Operation managedruntime.Operation - CurrentVersion string - ActiveVersion string - TargetVersion string - Output *ringBuffer - StartedAt time.Time - FinishedAt *time.Time - Error string - RefreshError string + ID string + AgentName string + Package string + Status dto.AgentUpdateJobStatus + Operation managedruntime.Operation + CurrentVersion string + DefaultVersion string + ActiveVersion string + EffectiveVersion string + TargetVersion string + UseDefault bool + 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() - selectionStore managedruntime.SelectionStore + mu sync.Mutex + jobs map[string]*AgentUpdateJob + activeByAgt map[string]*AgentUpdateJob + semaphore chan struct{} + hub JobBroadcaster + log *zap.Logger + updater RuntimeUpdater + maintenance *maintenanceCoordinator + onRefresh func() + selectionStore managedruntime.SelectionStore + onStatusInvalidated func(string) } func NewAgentUpdateJobStore( @@ -78,6 +83,22 @@ func (s *AgentUpdateJobStore) Enqueue( agentName string, spec agents.ManagedNPMRuntimeSpec, targetVersions ...string, +) (*AgentUpdateJob, error) { + return s.enqueue(agentName, spec, false, targetVersions...) +} + +func (s *AgentUpdateJobStore) EnqueueDefault( + agentName string, + spec agents.ManagedNPMRuntimeSpec, +) (*AgentUpdateJob, error) { + return s.enqueue(agentName, spec, true) +} + +func (s *AgentUpdateJobStore) enqueue( + agentName string, + spec agents.ManagedNPMRuntimeSpec, + useDefault bool, + targetVersions ...string, ) (*AgentUpdateJob, error) { s.mu.Lock() if existing, ok := s.activeByAgt[agentName]; ok { @@ -85,11 +106,13 @@ func (s *AgentUpdateJobStore) Enqueue( return existing, nil } job := &AgentUpdateJob{ - ID: uuid.NewString(), - AgentName: agentName, - Status: dto.AgentUpdateJobStatusQueued, - Output: newRingBuffer(jobOutputRingSize), - StartedAt: time.Now().UTC(), + ID: uuid.NewString(), + AgentName: agentName, + Package: spec.Package, + Status: dto.AgentUpdateJobStatusQueued, + Output: newRingBuffer(jobOutputRingSize), + StartedAt: time.Now().UTC(), + UseDefault: useDefault, } requestedTarget := "" if len(targetVersions) > 0 { @@ -115,6 +138,14 @@ func (s *AgentUpdateJobStore) Enqueue( return job, nil } +// SetStatusInvalidator wires the process-local status cache invalidation used +// after a successful runtime activation. It is optional for isolated stores. +func (s *AgentUpdateJobStore) SetStatusInvalidator(invalidator func(string)) { + s.mu.Lock() + defer s.mu.Unlock() + s.onStatusInvalidated = invalidator +} + func (s *AgentUpdateJobStore) Get(jobID string) (*dto.AgentUpdateJobDTO, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -161,7 +192,20 @@ func (s *AgentUpdateJobStore) run( defer cancel() s.setStatus(job, dto.AgentUpdateJobStatusResolving) - target, exactTarget, err := s.resolveTarget(ctx, spec.Package, requestedTarget) + var ( + target string + exactTarget bool + err error + ) + if job.UseDefault { + target = spec.DefaultVersionOrPinned() + if _, parseErr := managedruntime.ParseStableVersion(target); parseErr != nil { + err = fmt.Errorf("resolve default version: %w", parseErr) + } + exactTarget = true + } else { + target, exactTarget, err = s.resolveTarget(ctx, spec.Package, requestedTarget) + } if err != nil { s.finishFailed(job, ctx, fmt.Errorf("resolve target version: %w", err), ref) return @@ -184,7 +228,14 @@ func (s *AgentUpdateJobStore) run( activeVersion = selection.Version } } - operation, err := managedruntime.ClassifyOperation(activeVersion, currentVersion, target) + defaultVersion := spec.DefaultVersionOrPinned() + effectiveVersion := defaultVersion + if activeVersion != "" { + effectiveVersion = activeVersion + } + operation, err := managedruntime.ClassifyEffectiveOperation( + job.UseDefault, activeVersion, effectiveVersion, currentVersion, target, defaultVersion, + ) if err != nil { s.finishFailed(job, ctx, fmt.Errorf("classify runtime operation: %w", err), ref) return @@ -192,7 +243,9 @@ func (s *AgentUpdateJobStore) run( s.mu.Lock() job.TargetVersion = target job.Operation = operation + job.DefaultVersion = defaultVersion job.ActiveVersion = activeVersion + job.EffectiveVersion = effectiveVersion s.mu.Unlock() if operation == managedruntime.OperationUpToDate || (s.selectionStore == nil && currentVersion != "" && currentVersion == target) { @@ -329,10 +382,27 @@ func (s *AgentUpdateJobStore) runExactCandidate( s.finishFailed(job, ctx, errors.New(capabilityRefreshError(caps)), ref) return } - if err := s.selectionStore.Save(ctx, job.AgentName, spec.Package, target); err != nil { + s.mu.Lock() + job.CurrentVersion = caps.AgentVersion + s.mu.Unlock() + if job.UseDefault { + if err := s.selectionStore.Delete(ctx, job.AgentName, spec.Package); err != nil { + s.finishFailed(job, ctx, fmt.Errorf("clear active runtime version: %w", err), ref) + return + } + } else if err := s.selectionStore.Save(ctx, job.AgentName, spec.Package, target); err != nil { s.finishFailed(job, ctx, fmt.Errorf("persist active runtime version: %w", err), ref) return } + s.mu.Lock() + if job.UseDefault { + job.ActiveVersion = "" + job.EffectiveVersion = job.DefaultVersion + } else { + job.ActiveVersion = target + job.EffectiveVersion = target + } + s.mu.Unlock() candidate.PublishCapabilities(job.AgentName, caps) s.finishActivated(job, target, ref) } @@ -386,11 +456,22 @@ func (s *AgentUpdateJobStore) finishActivated( ) { s.mu.Lock() job.Status = dto.AgentUpdateJobStatusSucceeded - job.ActiveVersion = target + if !job.UseDefault { + job.ActiveVersion = target + job.EffectiveVersion = target + } else { + job.ActiveVersion = "" + job.EffectiveVersion = job.DefaultVersion + } s.maintenance.release(job.AgentName, ref) s.finishLocked(job) snapshot := job.snapshot() + statusInvalidator := s.onStatusInvalidated + packageName := job.Package s.mu.Unlock() + if statusInvalidator != nil && packageName != "" { + statusInvalidator(packageName) + } if s.onRefresh != nil { s.onRefresh() } @@ -413,6 +494,7 @@ func (s *AgentUpdateJobStore) finishRefresh( job.Error = formatUpdateJobError(ctx, fmt.Errorf("refresh capabilities: %w", err)) case caps.Status == hostutility.StatusOK: job.Status = dto.AgentUpdateJobStatusSucceeded + job.CurrentVersion = caps.AgentVersion refreshed = true case caps.Status == hostutility.StatusAuthRequired: job.Status = dto.AgentUpdateJobStatusSucceeded @@ -429,7 +511,12 @@ func (s *AgentUpdateJobStore) finishRefresh( s.maintenance.release(job.AgentName, ref) s.finishLocked(job) snapshot := job.snapshot() + statusInvalidator := s.onStatusInvalidated + packageName := job.Package s.mu.Unlock() + if refreshed && statusInvalidator != nil && packageName != "" { + statusInvalidator(packageName) + } if refreshed && s.onRefresh != nil { s.onRefresh() } @@ -476,17 +563,19 @@ func (s *AgentUpdateJobStore) broadcast(action string, payload dto.AgentUpdateJo func (j *AgentUpdateJob) snapshot() dto.AgentUpdateJobDTO { snapshot := dto.AgentUpdateJobDTO{ - JobID: j.ID, - AgentName: j.AgentName, - Status: j.Status, - Operation: string(j.Operation), - CurrentVersion: j.CurrentVersion, - ActiveVersion: j.ActiveVersion, - TargetVersion: j.TargetVersion, - Output: j.Output.String(), - Error: j.Error, - RefreshError: j.RefreshError, - StartedAt: j.StartedAt, + JobID: j.ID, + AgentName: j.AgentName, + Status: j.Status, + Operation: string(j.Operation), + CurrentVersion: j.CurrentVersion, + DefaultVersion: j.DefaultVersion, + ActiveVersion: j.ActiveVersion, + EffectiveVersion: j.EffectiveVersion, + TargetVersion: j.TargetVersion, + Output: j.Output.String(), + Error: j.Error, + RefreshError: j.RefreshError, + StartedAt: j.StartedAt, } if j.FinishedAt != nil { finished := *j.FinishedAt diff --git a/apps/backend/internal/agent/settings/controller/agent_update_metadata_test.go b/apps/backend/internal/agent/settings/controller/agent_update_metadata_test.go new file mode 100644 index 0000000000..cf343f4722 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_update_metadata_test.go @@ -0,0 +1,38 @@ +package controller + +import ( + "context" + "testing" +) + +func TestHostRuntimeUpdaterResolvesNPM12ArrayVersionCatalogue(t *testing.T) { + executor := &recordingCommandExecutor{ + output: `[{"versions":["1.0.1","1.0.2"],"dist-tags":{"latest":"1.0.2"}}]`, + } + updater := &hostRuntimeUpdater{executor: executor} + + metadata, err := updater.ResolveVersions(context.Background(), "@example/managed-acp") + if err != nil { + t.Fatalf("ResolveVersions: %v", err) + } + if metadata.Latest != "1.0.2" || len(metadata.Versions) != 2 { + t.Fatalf("metadata = %#v", metadata) + } +} + +func TestHostRuntimeUpdaterRejectsAmbiguousNPMRuntimeMetadata(t *testing.T) { + tests := map[string]string{ + "empty array": `[]`, + "multiple records": `[{"dist-tags":{"latest":"1.0.1"}},{"dist-tags":{"latest":"1.0.2"}}]`, + "malformed payload": `{not-json`, + } + + for name, output := range tests { + t.Run(name, func(t *testing.T) { + updater := &hostRuntimeUpdater{executor: &recordingCommandExecutor{output: output}} + if _, err := updater.ResolveVersions(context.Background(), "@example/managed-acp"); err == nil { + t.Fatal("ResolveVersions succeeded, want metadata error") + } + }) + } +} diff --git a/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go b/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go index ac9cf59f9d..17940608ac 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_recovery_test.go @@ -61,10 +61,28 @@ func (s *recoverySelectionStore) Save( return nil } +func (s *recoverySelectionStore) Delete( + _ context.Context, + agentName string, + packageName string, +) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.saveErr != nil { + return s.saveErr + } + delete(s.values, agentName+"\x00"+packageName) + if s.events != nil { + *s.events = append(*s.events, "delete") + } + return nil +} + type recoveryRuntimeUpdater struct { mu sync.Mutex metadata RuntimeVersionMetadata metadataCalls int + resolveErr error current hostutility.AgentCapabilities currentFound bool probeCaps hostutility.AgentCapabilities @@ -84,7 +102,7 @@ func (u *recoveryRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentC } func (u *recoveryRuntimeUpdater) ResolveTarget(context.Context, string) (string, error) { - return u.metadata.Latest, nil + return u.metadata.Latest, u.resolveErr } func (u *recoveryRuntimeUpdater) ResolveVersions(context.Context, string) (RuntimeVersionMetadata, error) { @@ -192,6 +210,7 @@ func TestAgentUpdateExactCandidatePersistsBeforePublishing(t *testing.T) { current: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.3"}, currentFound: true, probeCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.2"}, + resolveErr: errors.New("registry unavailable"), } updater.events = &events store, completed := newRecoveryStore(updater, selectionStore) @@ -207,6 +226,9 @@ func TestAgentUpdateExactCandidatePersistsBeforePublishing(t *testing.T) { if final.ActiveVersion != "1.0.2" { t.Fatalf("active version = %q, want 1.0.2", final.ActiveVersion) } + if final.CurrentVersion != "1.0.2" { + t.Fatalf("current version = %q, want probed 1.0.2", final.CurrentVersion) + } if len(updater.prepare) != 1 || !strings.Contains(updater.prepare[0], "--package=@example/managed-acp@1.0.2") { t.Fatalf("prepare commands = %#v", updater.prepare) } @@ -221,6 +243,50 @@ func TestAgentUpdateExactCandidatePersistsBeforePublishing(t *testing.T) { } } +func TestAgentUpdateUseDefaultDeletesSelectionAfterProbe(t *testing.T) { + selectionStore := newRecoverySelectionStore() + selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{ + Package: "@example/managed-acp", + Version: "1.0.1", + } + events := make([]string, 0, 4) + selectionStore.events = &events + updater := &recoveryRuntimeUpdater{ + metadata: RuntimeVersionMetadata{ + Versions: []string{"1.0.1", "1.0.2"}, + Latest: "1.0.2", + }, + current: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.1"}, + currentFound: true, + probeCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.2"}, + } + updater.events = &events + store, completed := newRecoveryStore(updater, selectionStore) + spec := managedRuntimeSpec() + spec.DefaultVersion = "1.0.2" + + job, err := store.EnqueueDefault("managed-acp", spec) + if err != nil { + t.Fatalf("EnqueueDefault: %v", err) + } + final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded) + if final.Operation != string(managedruntime.OperationUseDefault) { + t.Fatalf("operation = %q, want use_default", final.Operation) + } + if final.ActiveVersion != "" || final.EffectiveVersion != "1.0.2" { + t.Fatalf("final versions = active %q, effective %q", final.ActiveVersion, final.EffectiveVersion) + } + if final.CurrentVersion != "1.0.2" { + t.Fatalf("current version = %q, want probed default 1.0.2", final.CurrentVersion) + } + if _, found, err := selectionStore.Get(context.Background(), "managed-acp", "@example/managed-acp"); err != nil || found { + t.Fatalf("selection after reset = found %v, err %v; want absent", found, err) + } + if len(events) != 3 || events[0] != "probe" || events[1] != "delete" || events[2] != "publish" { + t.Fatalf("reset activation order = %#v", events) + } +} + func TestAgentUpdateExactTargetSkipsMetadataRefetch(t *testing.T) { selectionStore := newRecoverySelectionStore() updater := &recoveryRuntimeUpdater{ diff --git a/apps/backend/internal/agent/settings/controller/agent_update_status.go b/apps/backend/internal/agent/settings/controller/agent_update_status.go new file mode 100644 index 0000000000..1c09de3c4f --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_update_status.go @@ -0,0 +1,288 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/managedruntime" + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +const ( + runtimeUpdateStatusSuccessTTL = 6 * time.Hour + runtimeUpdateStatusFailureTTL = 15 * time.Minute + runtimeUpdateStatusMaxConcurrent = 5 + runtimeUpdateStatusLookupTimeout = 10 * time.Second +) + +// RuntimeUpdateStatusResolver is the latest-version lookup seam used by the +// cached status endpoint. Production resolves through the configured runtime +// updater; tests and embedders can provide a deterministic implementation. +type RuntimeUpdateStatusResolver func(context.Context, string) (string, error) + +type runtimeUpdateStatusCacheEntry struct { + latest string + checkedAt time.Time + expiresAt time.Time + ok bool +} + +type runtimeUpdateStatusTarget struct { + agentName string + packageName string + defaultVersion string + activeVersion string + effectiveVersion string + selectionErr error +} + +// SetRuntimeUpdateStatusClock injects the clock used for status cache TTLs. +// Passing nil restores the wall clock. +func (c *Controller) SetRuntimeUpdateStatusClock(now func() time.Time) { + c.runtimeUpdateStatusMu.Lock() + defer c.runtimeUpdateStatusMu.Unlock() + if now == nil { + c.runtimeUpdateStatusNow = time.Now + return + } + c.runtimeUpdateStatusNow = now +} + +// SetRuntimeUpdateStatusResolver injects the read-only latest-version lookup +// used by the status endpoint and clears prior results from another resolver. +func (c *Controller) SetRuntimeUpdateStatusResolver(resolver RuntimeUpdateStatusResolver) { + c.runtimeUpdateStatusMu.Lock() + defer c.runtimeUpdateStatusMu.Unlock() + c.runtimeUpdateStatusResolver = resolver + c.runtimeUpdateStatusCache = make(map[string]runtimeUpdateStatusCacheEntry) +} + +// InvalidateRuntimeUpdateStatus expires only the affected package. Successful +// activation and return-to-default jobs call this after persistence/probing. +func (c *Controller) InvalidateRuntimeUpdateStatus(packageName string) { + packageName = strings.TrimSpace(packageName) + if packageName == "" { + return + } + c.runtimeUpdateStatusMu.Lock() + defer c.runtimeUpdateStatusMu.Unlock() + delete(c.runtimeUpdateStatusCache, packageName) +} + +// ListAgentUpdateStatuses returns one non-mutating status item for each +// available built-in managed runtime. Registry failures are represented as +// unknown entries rather than failing the whole batch. +func (c *Controller) ListAgentUpdateStatuses(ctx context.Context) (*dto.ListAgentUpdateStatusResponse, error) { + targets, err := c.runtimeUpdateStatusTargets(ctx) + if err != nil { + return nil, err + } + + now := c.runtimeUpdateStatusTime() + entries := make(map[string]runtimeUpdateStatusCacheEntry, len(targets)) + uniquePackages := make(map[string]struct{}, len(targets)) + for _, target := range targets { + if _, exists := uniquePackages[target.packageName]; exists { + continue + } + uniquePackages[target.packageName] = struct{}{} + } + type result struct { + packageName string + entry runtimeUpdateStatusCacheEntry + } + results := make(chan result, len(uniquePackages)) + for packageName := range uniquePackages { + go func(packageName string) { + entry := c.runtimeUpdateStatusEntry(ctx, packageName, now) + results <- result{packageName: packageName, entry: entry} + }(packageName) + } + for range uniquePackages { + item := <-results + entries[item.packageName] = item.entry + } + + statuses := make([]dto.AgentUpdateStatusDTO, 0, len(targets)) + for _, target := range targets { + entry := entries[target.packageName] + status := dto.AgentUpdateStatusDTO{ + AgentName: target.agentName, + Package: target.packageName, + DefaultVersion: target.defaultVersion, + ActiveVersion: target.activeVersion, + EffectiveVersion: target.effectiveVersion, + CheckState: dto.AgentUpdateCheckStateUnknown, + } + if !entry.checkedAt.IsZero() { + checkedAt := entry.checkedAt + status.CheckedAt = &checkedAt + } + if entry.ok { + status.LatestVersion = entry.latest + } + if target.selectionErr == nil && entry.ok { + status.CheckState = compareRuntimeUpdateStatus(entry.latest, target.effectiveVersion) + } + statuses = append(statuses, status) + } + return &dto.ListAgentUpdateStatusResponse{Statuses: statuses}, nil +} + +func (c *Controller) runtimeUpdateStatusTargets(ctx context.Context) ([]runtimeUpdateStatusTarget, error) { + if c.agentRegistry == nil { + return nil, nil + } + available := map[string]bool{} + if c.discovery != nil { + results, err := c.detectAgents(ctx) + if err != nil { + return nil, fmt.Errorf("detect available agents: %w", err) + } + for _, result := range results { + available[result.Name] = result.Available + } + } + + targets := make([]runtimeUpdateStatusTarget, 0) + for _, ag := range c.agentRegistry.ListEnabled() { + if c.discovery != nil && !available[ag.ID()] { + continue + } + managed, ok := ag.(agents.ManagedNPMRuntimeAgent) + if !ok { + continue + } + spec := managed.ManagedNPMRuntime() + packageName := strings.TrimSpace(spec.Package) + if packageName == "" { + continue + } + defaultVersion := strings.TrimSpace(spec.DefaultVersionOrPinned()) + if _, err := managedruntime.ParseStableVersion(defaultVersion); err != nil { + // Custom test/extension agents are not part of the built-in managed + // catalogue and must not create an unverifiable status item. + continue + } + activeVersion, effectiveVersion, _, selectionErr := c.runtimeVersions(ctx, ag.ID(), spec) + if selectionErr != nil { + effectiveVersion = defaultVersion + } + targets = append(targets, runtimeUpdateStatusTarget{ + agentName: ag.ID(), + packageName: packageName, + defaultVersion: defaultVersion, + activeVersion: activeVersion, + effectiveVersion: effectiveVersion, + selectionErr: selectionErr, + }) + } + return targets, nil +} + +func (c *Controller) runtimeUpdateStatusEntry( + ctx context.Context, + packageName string, + now time.Time, +) runtimeUpdateStatusCacheEntry { + c.runtimeUpdateStatusMu.Lock() + if entry, ok := c.runtimeUpdateStatusCache[packageName]; ok && now.Before(entry.expiresAt) { + c.runtimeUpdateStatusMu.Unlock() + return entry + } + lookup := c.runtimeUpdateStatusLookup + if lookup == nil { + lookup = make(chan struct{}, runtimeUpdateStatusMaxConcurrent) + c.runtimeUpdateStatusLookup = lookup + } + c.runtimeUpdateStatusMu.Unlock() + + lookup <- struct{}{} + defer func() { <-lookup }() + + // Recheck after waiting for the bounded slot so concurrent requests do not + // repeat a lookup that another waiter already completed. + c.runtimeUpdateStatusMu.Lock() + if entry, ok := c.runtimeUpdateStatusCache[packageName]; ok && now.Before(entry.expiresAt) { + c.runtimeUpdateStatusMu.Unlock() + return entry + } + c.runtimeUpdateStatusMu.Unlock() + + lookupCtx, cancel := context.WithTimeout(ctx, runtimeUpdateStatusLookupTimeout) + defer cancel() + latest, err := c.resolveRuntimeUpdateLatest(lookupCtx, packageName) + entry := runtimeUpdateStatusCacheEntry{} + if err == nil { + entry.latest = latest + entry.ok = true + entry.checkedAt = now + entry.expiresAt = now.Add(runtimeUpdateStatusSuccessTTL) + } else { + entry.expiresAt = now.Add(runtimeUpdateStatusFailureTTL) + } + c.runtimeUpdateStatusMu.Lock() + if c.runtimeUpdateStatusCache == nil { + c.runtimeUpdateStatusCache = make(map[string]runtimeUpdateStatusCacheEntry) + } + c.runtimeUpdateStatusCache[packageName] = entry + c.runtimeUpdateStatusMu.Unlock() + return entry +} + +func (c *Controller) resolveRuntimeUpdateLatest(ctx context.Context, packageName string) (string, error) { + c.runtimeUpdateStatusMu.Lock() + resolver := c.runtimeUpdateStatusResolver + c.runtimeUpdateStatusMu.Unlock() + if resolver != nil { + return validateRuntimeUpdateLatest(resolver(ctx, packageName)) + } + if c.runtimeUpdater == nil { + return "", errors.New("runtime updater unavailable") + } + if metadataResolver, ok := c.runtimeUpdater.(RuntimeVersionResolver); ok { + metadata, err := metadataResolver.ResolveVersions(ctx, packageName) + if err != nil { + return "", err + } + return validateRuntimeUpdateLatest(metadata.Latest, nil) + } + return validateRuntimeUpdateLatest(c.runtimeUpdater.ResolveTarget(ctx, packageName)) +} + +func validateRuntimeUpdateLatest(latest string, err error) (string, error) { + if err != nil { + return "", err + } + latest = strings.TrimSpace(latest) + if _, parseErr := managedruntime.ParseStableVersion(latest); parseErr != nil { + return "", parseErr + } + return latest, nil +} + +func compareRuntimeUpdateStatus(latest, effective string) dto.AgentUpdateCheckState { + latestVersion, latestErr := managedruntime.ParseStableVersion(latest) + effectiveVersion, effectiveErr := managedruntime.ParseStableVersion(effective) + if latestErr != nil || effectiveErr != nil { + return dto.AgentUpdateCheckStateUnknown + } + if latestVersion.GreaterThan(effectiveVersion) { + return dto.AgentUpdateCheckStateUpdateAvailable + } + return dto.AgentUpdateCheckStateUpToDate +} + +func (c *Controller) runtimeUpdateStatusTime() time.Time { + c.runtimeUpdateStatusMu.Lock() + now := c.runtimeUpdateStatusNow + c.runtimeUpdateStatusMu.Unlock() + if now == nil { + return time.Now().UTC() + } + return now().UTC() +} diff --git a/apps/backend/internal/agent/settings/controller/agent_update_status_test.go b/apps/backend/internal/agent/settings/controller/agent_update_status_test.go new file mode 100644 index 0000000000..6fcf1473a3 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/agent_update_status_test.go @@ -0,0 +1,226 @@ +package controller + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/managedruntime" + "github.com/kandev/kandev/internal/agent/settings/dto" +) + +type statusSelectionStore struct { + selection map[string]managedruntime.Selection +} + +func (s *statusSelectionStore) Get(_ context.Context, agentName, packageName string) (managedruntime.Selection, bool, error) { + selection, ok := s.selection[agentName+"\x00"+packageName] + return selection, ok, nil +} + +func (s *statusSelectionStore) Save(context.Context, string, string, string) error { + return nil +} + +func (s *statusSelectionStore) Delete(context.Context, string, string) error { + return nil +} + +func TestListAgentUpdateStatusesComparesLatestAndCachesFailures(t *testing.T) { + controller := newTestController(map[string]agents.Agent{ + "claude-acp": agents.NewClaudeACP(), + "codex-acp": agents.NewCodexACP(), + "copilot-acp": agents.NewCopilotACP(), + "gemini": agents.NewGemini(), + }) + controller.SetManagedRuntimeSelectionStore(&statusSelectionStore{selection: map[string]managedruntime.Selection{ + "claude-acp\x00@agentclientprotocol/claude-agent-acp": { + Package: "@agentclientprotocol/claude-agent-acp", + Version: "0.70.1", + }, + }}) + + now := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC) + controller.SetRuntimeUpdateStatusClock(func() time.Time { return now }) + var mu sync.Mutex + calls := make(map[string]int) + controller.SetRuntimeUpdateStatusResolver(func(_ context.Context, packageName string) (string, error) { + mu.Lock() + calls[packageName]++ + mu.Unlock() + switch packageName { + case "@agentclientprotocol/claude-agent-acp": + return "0.71.0", nil + case "@agentclientprotocol/codex-acp": + return agents.NewCodexACP().ManagedNPMRuntime().DefaultVersionOrPinned(), nil + case "@github/copilot": + return "1.0.74", nil + case "@google/gemini-cli": + return "", errors.New("registry unavailable") + default: + return "", errors.New("unexpected package") + } + }) + + statuses, err := controller.ListAgentUpdateStatuses(context.Background()) + if err != nil { + t.Fatalf("ListAgentUpdateStatuses: %v", err) + } + if len(statuses.Statuses) != 4 { + t.Fatalf("status count = %d, want 4", len(statuses.Statuses)) + } + byPackage := make(map[string]dto.AgentUpdateStatusDTO, len(statuses.Statuses)) + for _, status := range statuses.Statuses { + byPackage[status.Package] = status + if status.Package != "@google/gemini-cli" && status.CheckedAt == nil { + t.Fatalf("%s has no checked_at", status.Package) + } + } + if got := byPackage["@agentclientprotocol/claude-agent-acp"].CheckState; got != dto.AgentUpdateCheckStateUpdateAvailable { + t.Fatalf("claude state = %q, want update_available", got) + } + if got := byPackage["@agentclientprotocol/claude-agent-acp"].EffectiveVersion; got != "0.70.1" { + t.Fatalf("claude effective = %q, want selected version", got) + } + if got := byPackage["@agentclientprotocol/codex-acp"].CheckState; got != dto.AgentUpdateCheckStateUpToDate { + t.Fatalf("codex state = %q, want up_to_date", got) + } + if got := byPackage["@github/copilot"].CheckState; got != dto.AgentUpdateCheckStateUpToDate { + t.Fatalf("copilot state = %q, want up_to_date for older latest", got) + } + if got := byPackage["@google/gemini-cli"].CheckState; got != dto.AgentUpdateCheckStateUnknown { + t.Fatalf("gemini state = %q, want unknown", got) + } + if status := byPackage["@google/gemini-cli"]; status.CheckedAt != nil || status.LatestVersion != "" { + t.Fatalf("gemini failure metadata = latest %q, checked_at %v; want both omitted", status.LatestVersion, status.CheckedAt) + } + + if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil { + t.Fatalf("cached ListAgentUpdateStatuses: %v", err) + } + mu.Lock() + for packageName, count := range calls { + if count != 1 { + t.Errorf("resolver calls for %s = %d, want 1 within TTL", packageName, count) + } + } + mu.Unlock() + + now = now.Add(15*time.Minute + time.Second) + if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil { + t.Fatalf("failed-cache refresh: %v", err) + } + mu.Lock() + if calls["@google/gemini-cli"] != 2 { + t.Errorf("failed resolver calls = %d, want 2 after failure TTL", calls["@google/gemini-cli"]) + } + if calls["@agentclientprotocol/claude-agent-acp"] != 1 { + t.Errorf("successful resolver calls = %d, want 1 before success TTL", calls["@agentclientprotocol/claude-agent-acp"]) + } + mu.Unlock() + + now = now.Add(6*time.Hour + time.Second) + if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil { + t.Fatalf("successful-cache refresh: %v", err) + } + mu.Lock() + if calls["@agentclientprotocol/claude-agent-acp"] != 2 { + t.Errorf("successful resolver calls = %d, want 2 after success TTL", calls["@agentclientprotocol/claude-agent-acp"]) + } + mu.Unlock() + + if got := controller.ListAgentUpdateJobs(); len(got) != 0 { + t.Fatalf("status request created %d update jobs", len(got)) + } +} + +func TestListAgentUpdateStatusesInvalidatesOnePackage(t *testing.T) { + controller := newTestController(map[string]agents.Agent{ + "claude-acp": agents.NewClaudeACP(), + }) + var calls int + controller.SetRuntimeUpdateStatusResolver(func(context.Context, string) (string, error) { + calls++ + return "0.71.0", nil + }) + + if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil { + t.Fatalf("initial status: %v", err) + } + controller.InvalidateRuntimeUpdateStatus("@agentclientprotocol/claude-agent-acp") + if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil { + t.Fatalf("status after invalidation: %v", err) + } + if calls != 2 { + t.Fatalf("resolver calls = %d, want 2 after invalidation", calls) + } +} + +func TestListAgentUpdateStatusesInitializesCacheForBareController(t *testing.T) { + controller := newTestController(map[string]agents.Agent{ + "claude-acp": agents.NewClaudeACP(), + }) + + statuses, err := controller.ListAgentUpdateStatuses(context.Background()) + if err != nil { + t.Fatalf("ListAgentUpdateStatuses: %v", err) + } + if len(statuses.Statuses) != 1 || statuses.Statuses[0].CheckState != dto.AgentUpdateCheckStateUnknown { + t.Fatalf("statuses = %#v, want one unknown status", statuses.Statuses) + } +} + +func TestListAgentUpdateStatusesBoundsRegistryLookupByContext(t *testing.T) { + controller := newTestController(map[string]agents.Agent{ + "claude-acp": agents.NewClaudeACP(), + }) + controller.SetRuntimeUpdateStatusResolver(func(ctx context.Context, _ string) (string, error) { + <-ctx.Done() + return "", ctx.Err() + }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + statuses, err := controller.ListAgentUpdateStatuses(ctx) + if err != nil { + t.Fatalf("ListAgentUpdateStatuses: %v", err) + } + if len(statuses.Statuses) != 1 || statuses.Statuses[0].CheckState != dto.AgentUpdateCheckStateUnknown { + t.Fatalf("statuses = %#v, want one unknown status", statuses.Statuses) + } +} + +func TestListAgentUpdateStatusesBoundsConcurrentLookups(t *testing.T) { + controller := newTestController(map[string]agents.Agent{ + "claude-acp": agents.NewClaudeACP(), + "codex-acp": agents.NewCodexACP(), + "copilot-acp": agents.NewCopilotACP(), + "gemini": agents.NewGemini(), + "opencode-acp": agents.NewOpenCodeACP(), + }) + var mu sync.Mutex + current, maximum := 0, 0 + controller.SetRuntimeUpdateStatusResolver(func(context.Context, string) (string, error) { + mu.Lock() + current++ + if current > maximum { + maximum = current + } + mu.Unlock() + time.Sleep(5 * time.Millisecond) + mu.Lock() + current-- + mu.Unlock() + return "9.9.9", nil + }) + + if _, err := controller.ListAgentUpdateStatuses(context.Background()); err != nil { + t.Fatalf("ListAgentUpdateStatuses: %v", err) + } + if maximum > 5 { + t.Fatalf("maximum concurrent lookups = %d, want at most 5", maximum) + } +} diff --git a/apps/backend/internal/agent/settings/controller/agent_update_test.go b/apps/backend/internal/agent/settings/controller/agent_update_test.go index 6fa3dfba99..ef37f14945 100644 --- a/apps/backend/internal/agent/settings/controller/agent_update_test.go +++ b/apps/backend/internal/agent/settings/controller/agent_update_test.go @@ -316,6 +316,43 @@ func TestAgentUpdatePreviewResolvesTrustedCommandWithoutStartingAJob(t *testing. } } +func TestAgentUpdatePreviewUseDefaultClassifiesResetAndShowsEffectiveVersions(t *testing.T) { + updater := &recoveryRuntimeUpdater{ + metadata: RuntimeVersionMetadata{Versions: []string{"1.0.0", "1.1.0"}, Latest: "1.1.0"}, + current: hostutility.AgentCapabilities{ + Status: hostutility.StatusOK, + AgentVersion: "1.0.0", + }, + currentFound: true, + } + selectionStore := newRecoverySelectionStore() + selectionStore.values["managed-acp\x00@example/managed-acp"] = managedruntime.Selection{ + Package: "@example/managed-acp", + Version: "1.0.0", + } + ag := &managedTestAgent{ + testAgent: testAgent{id: "managed-acp", name: "Managed", enabled: true}, + spec: agents.ManagedNPMRuntimeSpec{ + Package: "@example/managed-acp", + DefaultVersion: "1.1.0", + }, + } + ctrl := newTestController(map[string]agents.Agent{ag.ID(): ag}) + ctrl.SetRuntimeUpdater(updater) + ctrl.SetManagedRuntimeSelectionStore(selectionStore) + + preview, err := ctrl.PreviewAgentUpdateUseDefault(context.Background(), ag.ID()) + if err != nil { + t.Fatalf("PreviewAgentUpdateUseDefault: %v", err) + } + if preview.Operation != string(managedruntime.OperationUseDefault) { + t.Fatalf("operation = %q, want use_default", preview.Operation) + } + if preview.DefaultVersion != "1.1.0" || preview.EffectiveVersion != "1.0.0" || preview.TargetVersion != "1.1.0" { + t.Fatalf("versions = default %q, effective %q, target %q", preview.DefaultVersion, preview.EffectiveVersion, preview.TargetVersion) + } +} + func TestAgentUpdatePreviewRejectsUnsupportedAndResolutionFailure(t *testing.T) { tests := []struct { name string @@ -495,8 +532,8 @@ func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) { 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.CurrentVersion != "1.1.0" || final.TargetVersion != "1.1.0" { + t.Fatalf("versions = %q -> %q, want 1.1.0 -> 1.1.0", final.CurrentVersion, final.TargetVersion) } if final.Output != "npm prepared runtime\n" { t.Fatalf("Output = %q", final.Output) diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 69182878cd..b88fcf48b7 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/kandev/kandev/internal/agent/discovery" @@ -61,26 +62,31 @@ var ( ) type Controller struct { - repo store.Repository - discovery *discovery.Registry - agentRegistry *registry.Registry - sessionChecker SessionChecker - watcherDeps WatcherDependencyChecker - routingTierDeps RoutingTierDependencyChecker - automationDeps AutomationDependencyChecker - utilityDeps UtilityDependencyChecker - mcpService *mcpconfig.Service - modelCache *modelfetcher.Cache - hostUtility hostUtilityProvider - jobStore *JobStore - updateJobStore *AgentUpdateJobStore - runtimeUpdater RuntimeUpdater - managedRuntimeSelections managedruntime.SelectionStore - maintenance *maintenanceCoordinator - hub JobBroadcaster - logger *logger.Logger - secretStore secrets.SecretStore - dynamicAgentRoutingEnabled bool + repo store.Repository + discovery *discovery.Registry + agentRegistry *registry.Registry + sessionChecker SessionChecker + watcherDeps WatcherDependencyChecker + routingTierDeps RoutingTierDependencyChecker + automationDeps AutomationDependencyChecker + utilityDeps UtilityDependencyChecker + mcpService *mcpconfig.Service + modelCache *modelfetcher.Cache + hostUtility hostUtilityProvider + jobStore *JobStore + updateJobStore *AgentUpdateJobStore + runtimeUpdater RuntimeUpdater + managedRuntimeSelections managedruntime.SelectionStore + maintenance *maintenanceCoordinator + hub JobBroadcaster + logger *logger.Logger + secretStore secrets.SecretStore + runtimeUpdateStatusMu sync.Mutex + runtimeUpdateStatusCache map[string]runtimeUpdateStatusCacheEntry + runtimeUpdateStatusNow func() time.Time + runtimeUpdateStatusResolver RuntimeUpdateStatusResolver + runtimeUpdateStatusLookup chan struct{} + dynamicAgentRoutingEnabled bool } // SetDynamicAgentRoutingEnabled applies the authoritative runtime flag to the @@ -245,13 +251,16 @@ type hostUtilityProvider interface { func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry *registry.Registry, sessionChecker SessionChecker, log *logger.Logger, ) *Controller { return &Controller{ - repo: repo, - discovery: discoveryRegistry, - agentRegistry: agentRegistry, - sessionChecker: sessionChecker, - mcpService: mcpconfig.NewService(repo), - modelCache: modelfetcher.NewCache(), - logger: log.WithFields(zap.String("component", "agent-settings-controller")), + repo: repo, + discovery: discoveryRegistry, + agentRegistry: agentRegistry, + sessionChecker: sessionChecker, + mcpService: mcpconfig.NewService(repo), + modelCache: modelfetcher.NewCache(), + logger: log.WithFields(zap.String("component", "agent-settings-controller")), + runtimeUpdateStatusCache: make(map[string]runtimeUpdateStatusCacheEntry), + runtimeUpdateStatusNow: time.Now, + runtimeUpdateStatusLookup: make(chan struct{}, runtimeUpdateStatusMaxConcurrent), } } @@ -339,6 +348,7 @@ func (c *Controller) initializeUpdateJobStore() { c.BroadcastAvailableAgents, c.managedRuntimeSelections, ) + c.updateJobStore.SetStatusInvalidator(c.InvalidateRuntimeUpdateStatus) } // 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 123797ec96..ae4d965d9a 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -681,7 +681,8 @@ func TestController_PreviewAgentCommand_CopilotKeepsManagedPackage(t *testing.T) if err != nil { t.Fatalf("PreviewAgentCommand() error = %v", err) } - want := []string{"npx", "--yes", "--prefer-offline", "@github/copilot", "--acp"} + managed := agents.NewCopilotACP() + want := []string{"npx", "--yes", "--prefer-offline", managed.ManagedNPMRuntime().PackageSpec(""), "--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/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index f923a83a20..032a106cea 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -283,10 +283,41 @@ type AvailableAgentDTO struct { // 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"` - ActiveVersion string `json:"active_version,omitempty"` + Supported bool `json:"supported"` + Package string `json:"package"` + CurrentVersion string `json:"current_version,omitempty"` + DefaultVersion string `json:"default_version"` + ActiveVersion string `json:"active_version,omitempty"` + EffectiveVersion string `json:"effective_version"` +} + +// AgentUpdateCheckState describes the result of the cached npm latest-version +// lookup. Unknown is intentionally distinct from up_to_date so the UI can +// avoid suggesting that an unavailable registry check is current. +type AgentUpdateCheckState string + +const ( + AgentUpdateCheckStateUpdateAvailable AgentUpdateCheckState = "update_available" + AgentUpdateCheckStateUpToDate AgentUpdateCheckState = "up_to_date" + AgentUpdateCheckStateUnknown AgentUpdateCheckState = "unknown" +) + +// AgentUpdateStatusDTO is the read-only update hint for one available managed +// runtime. ActiveVersion is the optional persisted operator selection; the +// default is never persisted and remains the fallback effective version. +type AgentUpdateStatusDTO struct { + AgentName string `json:"agent_name"` + Package string `json:"package"` + DefaultVersion string `json:"default_version"` + ActiveVersion string `json:"active_version,omitempty"` + EffectiveVersion string `json:"effective_version"` + LatestVersion string `json:"latest_version,omitempty"` + CheckedAt *time.Time `json:"checked_at,omitempty"` + CheckState AgentUpdateCheckState `json:"check_state"` +} + +type ListAgentUpdateStatusResponse struct { + Statuses []AgentUpdateStatusDTO `json:"statuses"` } type ListAvailableAgentsResponse struct { @@ -344,18 +375,20 @@ const ( // 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"` - Operation string `json:"operation"` - CurrentVersion string `json:"current_version,omitempty"` - ActiveVersion string `json:"active_version,omitempty"` - TargetVersion string `json:"target_version,omitempty"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` - RefreshError string `json:"refresh_error,omitempty"` - StartedAt time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty"` + JobID string `json:"job_id"` + AgentName string `json:"agent_name"` + Status AgentUpdateJobStatus `json:"status"` + Operation string `json:"operation"` + CurrentVersion string `json:"current_version,omitempty"` + DefaultVersion string `json:"default_version"` + ActiveVersion string `json:"active_version,omitempty"` + EffectiveVersion string `json:"effective_version"` + 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"` } // AgentUpdatePreviewDTO is a read-only representation of the next managed @@ -364,7 +397,9 @@ type AgentUpdatePreviewDTO struct { AgentName string `json:"agent_name"` Package string `json:"package"` CurrentVersion string `json:"current_version,omitempty"` + DefaultVersion string `json:"default_version"` ActiveVersion string `json:"active_version,omitempty"` + EffectiveVersion string `json:"effective_version"` TargetVersion string `json:"target_version"` Operation string `json:"operation"` AvailableVersions []AgentUpdateVersionDTO `json:"available_versions"` @@ -383,6 +418,7 @@ type AgentUpdateVersionDTO struct { // always resolved from trusted built-in agent metadata. type AgentUpdateRequest struct { TargetVersion string `json:"target_version"` + UseDefault bool `json:"use_default"` } type ListAgentUpdateJobsResponse struct { diff --git a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go index 4e54051d91..efa850e684 100644 --- a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go @@ -45,6 +45,10 @@ func (handlerSelectionStore) Save(context.Context, string, string, string) error return nil } +func (handlerSelectionStore) Delete(context.Context, string, string) error { + return nil +} + func (u *handlerRuntimeUpdater) CurrentCapabilities(string) (hostutility.AgentCapabilities, bool) { return hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.0.0"}, true } @@ -181,7 +185,9 @@ func TestAgentUpdatePreviewEndpointIsReadOnly(t *testing.T) { if preview["current_version"] != "1.0.0" || preview["target_version"] != "1.1.0" { t.Fatalf("versions = %#v", preview) } - if preview["command_string"] != `npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e ""` { + commandArgs := agents.NewClaudeACP().ManagedNPMRuntime().CacheUpdateCommand().Args() + wantCommand := strings.Join(commandArgs[:len(commandArgs)-1], " ") + ` ""` + if preview["command_string"] != wantCommand { t.Fatalf("command_string = %#v", preview["command_string"]) } @@ -192,6 +198,28 @@ func TestAgentUpdatePreviewEndpointIsReadOnly(t *testing.T) { } } +func TestAgentUpdateStatusEndpointIsReadOnly(t *testing.T) { + t.Setenv("KANDEV_E2E_MOCK", "true") + router, ctrl, _ := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) + var statusResponse struct { + Statuses []dto.AgentUpdateStatusDTO `json:"statuses"` + } + response := httptest.NewRecorder() + router.ServeHTTP(response, updateRequest(http.MethodGet, "/api/v1/agent-update/status")) + if response.Code != http.StatusOK { + t.Fatalf("GET status = %d, body = %s", response.Code, response.Body.String()) + } + if err := json.Unmarshal(response.Body.Bytes(), &statusResponse); err != nil { + t.Fatalf("decode status response: %v", err) + } + if len(statusResponse.Statuses) == 0 { + t.Fatal("status response is empty") + } + if jobs := ctrl.ListAgentUpdateJobs(); len(jobs) != 0 { + t.Fatalf("status request created %d update jobs", len(jobs)) + } +} + func TestAgentUpdateEndpointsAcceptAndRetainJobs(t *testing.T) { router, _, completed := newAgentUpdateRouter(t, &handlerRuntimeUpdater{}) response := httptest.NewRecorder() diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index bd0e1d2929..193eff4cc1 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strconv" "strings" "time" @@ -69,6 +70,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.POST("/agent-models/:agentName/resolve", h.httpResolveAgentModelConfig) api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) api.POST("/agent-install/:agentName", h.interlock, h.httpInstallAgent) + api.GET("/agent-update/status", h.httpListAgentUpdateStatuses) api.GET("/agent-update/:agentName/preview", h.httpPreviewAgentUpdate) api.GET("/agent-install/jobs", h.httpListInstallJobs) api.GET("/agent-install/jobs/:id", h.httpGetInstallJob) @@ -131,11 +133,18 @@ func (h *Handlers) httpUpdateAgentRuntime(c *gin.Context) { return } request.TargetVersion = strings.TrimSpace(request.TargetVersion) - if request.TargetVersion == "" { + if request.UseDefault && request.TargetVersion != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target version and use_default cannot be combined"}) + return + } + if !request.UseDefault && request.TargetVersion == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "target version is required"}) return } h.enqueueMaintenance(c, name, "update", func() (any, error) { + if request.UseDefault { + return h.controller.EnqueueAgentUpdateUseDefault(c.Request.Context(), name) + } return h.controller.EnqueueAgentUpdate(c.Request.Context(), name, request.TargetVersion) }, classifyUpdateError) } @@ -145,11 +154,27 @@ func (h *Handlers) httpPreviewAgentUpdate(c *gin.Context) { if !ok { return } - preview, err := h.controller.PreviewAgentUpdate( - c.Request.Context(), - name, - strings.TrimSpace(c.Query("target_version")), - ) + targetVersion := strings.TrimSpace(c.Query("target_version")) + useDefault := false + if raw := strings.TrimSpace(c.Query("use_default")); raw != "" { + parsed, err := strconv.ParseBool(raw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "use_default must be a boolean"}) + return + } + useDefault = parsed + } + if useDefault && targetVersion != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target version and use_default cannot be combined"}) + return + } + var preview *dto.AgentUpdatePreviewDTO + var err error + if useDefault { + preview, err = h.controller.PreviewAgentUpdateUseDefault(c.Request.Context(), name) + } else { + preview, err = h.controller.PreviewAgentUpdate(c.Request.Context(), name, targetVersion) + } if err == nil { c.JSON(http.StatusOK, preview) return @@ -162,6 +187,16 @@ func (h *Handlers) httpPreviewAgentUpdate(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to preview agent update"}) } +func (h *Handlers) httpListAgentUpdateStatuses(c *gin.Context) { + resp, err := h.controller.ListAgentUpdateStatuses(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list agent runtime update statuses", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list agent update statuses"}) + return + } + c.JSON(http.StatusOK, resp) +} + func requireAgentName(c *gin.Context) (string, bool) { name := strings.TrimSpace(c.Param("agentName")) if name == "" { diff --git a/apps/backend/internal/system/settings/store.go b/apps/backend/internal/system/settings/store.go index c074979896..ac04b3f15c 100644 --- a/apps/backend/internal/system/settings/store.go +++ b/apps/backend/internal/system/settings/store.go @@ -80,3 +80,8 @@ func (s *Store) Save(ctx context.Context, key string, value []byte) error { `), key, string(value), time.Now().UTC()) return err } + +func (s *Store) Delete(ctx context.Context, key string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM settings WHERE key = ?`), key) + return err +} diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index be51bab8e8..3993e2f356 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -22,9 +22,10 @@ import { listAgents, listAvailableAgents, } from "@/lib/api"; -import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; +import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates"; +import { useAgentRuntimeUpdateStatuses } from "@/hooks/domains/settings/use-agent-runtime-update-statuses"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog"; import { AgentProfilesSubList } from "@/components/settings/agents/agent-profiles-section"; @@ -55,10 +56,19 @@ type InstalledAgentsSectionProps = { resolveDisplayName: (name: string) => string; resolveCapabilityStatus: (name: string) => string | undefined; resolveRuntimeUpdate: (name: string) => RuntimeUpdate | undefined; + resolveRuntimeUpdateStatus: (name: string) => AgentUpdateStatus | undefined; installJobs: Record; updateJobs: Record; - previewUpdate: (name: string, targetVersion?: string) => Promise; - startUpdate: (name: string, targetVersion: string) => Promise; + previewUpdate: ( + name: string, + targetVersion?: string, + useDefault?: boolean, + ) => Promise; + startUpdate: ( + name: string, + targetVersion: string, + useDefault?: boolean, + ) => Promise; setTuiDialogOpen: (open: boolean) => void; handleRescan: () => Promise; }; @@ -172,6 +182,7 @@ function InstalledAgentsSection({ resolveDisplayName, resolveCapabilityStatus, resolveRuntimeUpdate, + resolveRuntimeUpdateStatus, installJobs, updateJobs, previewUpdate, @@ -239,6 +250,7 @@ function InstalledAgentsSection({ ? { capabilityStatus: resolveCapabilityStatus(agent.name), runtimeUpdate: resolveRuntimeUpdate(agent.name), + runtimeUpdateStatus: resolveRuntimeUpdateStatus(agent.name), installJob: installJobs[agent.name], updateJob: updateJobs[agent.name], onPreview: previewUpdate, @@ -270,6 +282,9 @@ function useAgentPageState() { const { items: availableAgents } = useAvailableAgents(); const [rescanning, setRescanning] = useState(false); const [tuiDialogOpen, setTuiDialogOpen] = useState(false); + const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates(); + const { refresh: refreshRuntimeUpdateStatuses, statusByAgent } = + useAgentRuntimeUpdateStatuses(updateJobs); const installedAgents = useMemo(() => detectedAgents(discoveryAgents), [discoveryAgents]); const savedAgentsByName = useMemo( @@ -282,6 +297,7 @@ function useAgentPageState() { availableAgents.find((item: AvailableAgent) => item.name === name)?.model_config?.status; const resolveRuntimeUpdate = (name: string) => availableAgents.find((item: AvailableAgent) => item.name === name)?.runtime_update; + const resolveRuntimeUpdateStatus = (name: string) => statusByAgent[name]; const handleRescan = async () => { if (rescanning) { @@ -295,13 +311,12 @@ function useAgentPageState() { ]); setAgentDiscovery(discoveryResp.agents); setAvailableAgents(availableResp.agents, availableResp.tools ?? []); + await refreshRuntimeUpdateStatuses(); } finally { setRescanning(false); } }; - const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates(); - const handleCreateCustomTUI = async (data: { display_name: string; model?: string; @@ -336,6 +351,7 @@ function useAgentPageState() { resolveDisplayName, resolveCapabilityStatus, resolveRuntimeUpdate, + resolveRuntimeUpdateStatus, handleRescan, handleCreateCustomTUI, installJobs, @@ -358,6 +374,7 @@ export default function AgentsSettingsPage() { resolveDisplayName, resolveCapabilityStatus, resolveRuntimeUpdate, + resolveRuntimeUpdateStatus, handleRescan, handleCreateCustomTUI, installJobs, @@ -403,6 +420,7 @@ export default function AgentsSettingsPage() { resolveDisplayName={resolveDisplayName} resolveCapabilityStatus={resolveCapabilityStatus} resolveRuntimeUpdate={resolveRuntimeUpdate} + resolveRuntimeUpdateStatus={resolveRuntimeUpdateStatus} installJobs={installJobs} updateJobs={updateJobs} previewUpdate={previewUpdate} 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..7c2cf4bf2f --- /dev/null +++ b/apps/web/components/settings/agent-runtime-update-control.test.tsx @@ -0,0 +1,319 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState, type ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api"; +import { AgentRuntimeUpdateControl } from "./agent-runtime-update-control"; + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: false }), +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +vi.mock("@kandev/ui/dialog", () => ({ + Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => + open ? <>{children} : null, + DialogContent: ({ children, ...props }: { children?: ReactNode } & Record) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})); + +vi.mock("@kandev/ui/drawer", () => ({ + Drawer: ({ children, open }: { children?: ReactNode; open?: boolean }) => + open ? <>{children} : null, + DrawerContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DrawerDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DrawerFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DrawerHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DrawerTitle: ({ children }: { children?: ReactNode }) =>

{children}

, +})); + +const AGENT_NAME = "claude-acp"; +const PACKAGE_NAME = "@agentclientprotocol/claude-agent-acp"; +const ACTIVE_VERSION = "0.70.0"; + +function preview(overrides: Partial = {}): AgentUpdatePreview { + return { + agent_name: AGENT_NAME, + package: PACKAGE_NAME, + current_version: ACTIVE_VERSION, + default_version: ACTIVE_VERSION, + effective_version: ACTIVE_VERSION, + target_version: "0.71.0", + operation: "update", + active_version: ACTIVE_VERSION, + available_versions: [ + { version: "0.71.0", latest: true }, + { version: ACTIVE_VERSION, latest: false }, + ], + command: ["npm", "exec"], + command_string: "npm exec", + ...overrides, + }; +} + +function queuedJob(): AgentUpdateJob { + return { + job_id: "job-1", + agent_name: AGENT_NAME, + status: "queued", + started_at: "2026-01-01T00:00:00.000Z", + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("AgentRuntimeUpdateControl", () => { + it("keeps a long version history behind the browse action", async () => { + const onPreview = vi.fn().mockResolvedValue( + preview({ + available_versions: [ + { version: "0.71.0", latest: true }, + { version: ACTIVE_VERSION, latest: false }, + { version: "0.69.0", latest: false }, + { version: "0.50.0", latest: false }, + ], + }), + ); + render( + , + ); + + fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`)); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Browse all versions" })).toBeTruthy(); + }); + expect(screen.queryByText("0.50.0")).toBeNull(); + }); + + it("filters the full version history and previews the selected version", async () => { + const onPreview = vi.fn().mockResolvedValue( + preview({ + available_versions: [ + { version: "0.71.0", latest: true }, + { version: ACTIVE_VERSION, latest: false }, + { version: "0.69.0", latest: false }, + { version: "0.50.0", latest: false }, + ], + }), + ); + render( + , + ); + + fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`)); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Browse all versions" })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: "Browse all versions" })); + const search = screen.getByPlaceholderText("Search versions"); + expect(screen.getByTestId(`agent-update-version-option-${AGENT_NAME}-0.50.0`)).toBeTruthy(); + fireEvent.change(search, { target: { value: "0.50" } }); + expect(screen.getByTestId(`agent-update-version-option-${AGENT_NAME}-0.50.0`)).toBeTruthy(); + expect(screen.queryByTestId(`agent-update-version-option-${AGENT_NAME}-0.69.0`)).toBeNull(); + + fireEvent.click(screen.getByTestId(`agent-update-version-option-${AGENT_NAME}-0.50.0`)); + await waitFor(() => expect(onPreview).toHaveBeenLastCalledWith(AGENT_NAME, "0.50.0")); + }); +}); + +describe("AgentRuntimeUpdateControl", () => { + it("shows the update dot and exposes effective and latest versions", () => { + render( + , + ); + + expect(screen.getByTestId(`agent-update-available-dot-${AGENT_NAME}`)).toBeTruthy(); + expect( + screen.getByRole("button", { name: `Update Claude Code from ${ACTIVE_VERSION} to 0.71.0` }), + ).toBeTruthy(); + }); + + it("keeps the trigger usable when status is unknown and offers the default action", async () => { + const onPreview = vi.fn().mockResolvedValue(preview()); + const onUpdate = vi.fn().mockResolvedValue(queuedJob()); + render( + , + ); + + expect(screen.queryByTestId(`agent-update-available-dot-${AGENT_NAME}`)).toBeNull(); + expect( + screen.getByRole("button", { + name: "Latest version for Claude Code is unavailable. The update control remains usable.", + }), + ).toBeTruthy(); + fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`)); + await waitFor(() => expect(onPreview).toHaveBeenCalledWith(AGENT_NAME, undefined)); + expect( + screen.getByRole("button", { name: `Use Kandev default (${ACTIVE_VERSION})` }), + ).toBeTruthy(); + }); + + it("offers the default action when the active version is unknown", async () => { + const onPreview = vi.fn().mockResolvedValue(preview({ active_version: undefined })); + render( + , + ); + + fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`)); + await waitFor(() => expect(onPreview).toHaveBeenCalledWith(AGENT_NAME, undefined)); + expect( + screen.getByRole("button", { name: `Use Kandev default (${ACTIVE_VERSION})` }), + ).toBeTruthy(); + }); +}); + +describe("AgentRuntimeUpdateControl reset state", () => { + it("shows the cleared active version and job effective version after a reset", async () => { + const defaultVersion = "0.71.0"; + const onPreview = vi.fn().mockResolvedValue( + preview({ + default_version: defaultVersion, + target_version: defaultVersion, + operation: "use_default", + }), + ); + const resetJob: AgentUpdateJob = { + job_id: "job-reset", + agent_name: AGENT_NAME, + status: "succeeded", + operation: "use_default", + current_version: defaultVersion, + default_version: defaultVersion, + effective_version: defaultVersion, + target_version: defaultVersion, + started_at: "2026-01-01T00:00:00.000Z", + finished_at: "2026-01-01T00:01:00.000Z", + }; + function StatefulControl() { + const [job, setJob] = useState(); + const update = vi.fn().mockImplementation(async () => { + setJob(resetJob); + return resetJob; + }); + return ( + + ); + } + + render(); + fireEvent.click(screen.getByTestId(`agent-update-trigger-${AGENT_NAME}`)); + await waitFor(() => + expect( + screen.getByRole("button", { name: `Use Kandev default (${defaultVersion})` }), + ).toBeTruthy(), + ); + fireEvent.click(screen.getByTestId(`agent-update-quick-default-${AGENT_NAME}`)); + await waitFor(() => expect(onPreview).toHaveBeenLastCalledWith(AGENT_NAME, undefined, true)); + fireEvent.click(screen.getByTestId(`agent-update-confirm-${AGENT_NAME}`)); + + await waitFor(() => { + const dialog = screen.getByTestId(`agent-update-dialog-${AGENT_NAME}`); + const text = dialog.textContent ?? ""; + expect(text).toContain(`Effective version: ${defaultVersion}`); + expect(text).not.toContain(`Active version: ${ACTIVE_VERSION}`); + }); + }); +}); diff --git a/apps/web/components/settings/agent-runtime-update-control.tsx b/apps/web/components/settings/agent-runtime-update-control.tsx index e0c09dbf54..9774dca18c 100644 --- a/apps/web/components/settings/agent-runtime-update-control.tsx +++ b/apps/web/components/settings/agent-runtime-update-control.tsx @@ -2,36 +2,24 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { IconAlertTriangle, IconChevronDown, IconLoader2, IconRefresh } from "@tabler/icons-react"; +import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@kandev/ui/dialog"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@kandev/ui/drawer"; +import { DialogFooter } from "@kandev/ui/dialog"; +import { DrawerFooter } from "@kandev/ui/drawer"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { canApproveAgentRuntimeUpdate, resolveRuntimeActiveVersion, + resolveRuntimeEffectiveVersion, resolveRuntimeOperation, resolveRuntimeVersionPair, - latestRuntimeVersions, runtimeOperationLabelKey, } from "@/lib/agent-runtime-update"; -import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; +import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api"; import type { RuntimeUpdate } from "@/lib/types/http"; +import { AgentRuntimeUpdateSurface } from "./agent-runtime-update-surface"; +import { RuntimeVersionPicker } from "./runtime-version-picker"; import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state"; const UPDATE_AGENT_KEY = "agents:updateAgent"; @@ -107,6 +95,8 @@ type UpdateBodyProps = { onRetryPreview: () => void; selectedTarget: string; onSelectTarget: (targetVersion: string) => void; + selectedUseDefault: boolean; + onSelectDefault: () => void; starting: boolean; }; @@ -124,95 +114,74 @@ function RuntimeVersionSummary({ const activeVersion = resolveRuntimeActiveVersion(preview, job); const operation = resolveRuntimeOperation(preview, job); const isUpToDate = operation === "up_to_date"; + const effectiveVersion = resolveRuntimeEffectiveVersion(preview, job); return ( -
-

{t(runtimeOperationLabelKey(operation))}

+
+

+ {t(runtimeOperationLabelKey(operation))} +

{isUpToDate ? currentVersion : `${currentVersion} → ${targetVersion}`}

- {activeVersion && ( -

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

- )} - {isUpToDate && ( -

- {t("agents:upToDate")} -

- )} +
+ {activeVersion &&

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

} +

{t("agents:effectiveRuntimeVersion", { version: effectiveVersion })}

+ {preview.default_version && ( +

{t("agents:kandevDefaultVersion", { version: preview.default_version })}

+ )} +
); } -function RuntimeVersionSelector({ +function RuntimeUpdatePreviewDetails({ agentName, preview, selectedTarget, + selectedUseDefault, loading, starting, job, onSelectTarget, + onSelectDefault, }: { agentName: string; preview: AgentUpdatePreview; selectedTarget: string; + selectedUseDefault: boolean; loading: boolean; starting: boolean; job?: AgentUpdateJob; onSelectTarget: (targetVersion: string) => void; + onSelectDefault: () => void; }) { const { t } = useTranslation(); - const versions = latestRuntimeVersions( - preview.available_versions ?? [{ version: preview.target_version, latest: false }], - ); - const activeVersion = resolveRuntimeActiveVersion(preview, job); return ( -
- -
- - - {loading ? ( - - ) : ( - + <> + + +
+

{t("agents:runtimeUpdateExplainer")}

+

{t("agents:runtimeUpdateSessionsNote")}

-
+
+

{t("agents:commandThatWillRun")}

+
+          {preview.command_string}
+        
+
+ ); } @@ -226,13 +195,15 @@ function UpdateBody({ onRetryPreview, selectedTarget, onSelectTarget, + selectedUseDefault, + onSelectDefault, starting, }: UpdateBodyProps) { const { t } = useTranslation(); const phase = updatePhase(t, job?.status); return (
{loading && !preview && ( @@ -243,7 +214,7 @@ function UpdateBody({ )} {previewError && (

@@ -257,7 +228,7 @@ function UpdateBody({ )} {approveError && (

@@ -268,28 +239,17 @@ function UpdateBody({
)} {preview && ( - <> - - -
-

{t("agents:runtimeUpdateExplainer")}

-

{t("agents:runtimeUpdateSessionsNote")}

-
-
-

{t("agents:commandThatWillRun")}

-
-              {preview.command_string}
-            
-
- + )} {phase && (

{job.output} @@ -374,57 +334,84 @@ function UpdateFooter({ ); if (mobile) { return ( - + {content} ); } - return {content}; + return {content}; } function UpdateTrigger({ agentName, displayName, + runtimeUpdateStatus, installInFlight, onOpen, }: { agentName: string; displayName: string; + runtimeUpdateStatus?: AgentUpdateStatus; installInFlight: boolean; onOpen: () => void; }) { const { t } = useTranslation(); + const statusLabel = runtimeUpdateStatusLabel(t, displayName, runtimeUpdateStatus); return ( - + + {runtimeUpdateStatus?.check_state === "update_available" && ( + - {installInFlight - ? t("agents:agentInstallationInProgress") - : t(UPDATE_AGENT_KEY, { name: displayName })} + {installInFlight ? t("agents:agentInstallationInProgress") : statusLabel} ); } +function runtimeUpdateStatusLabel( + t: TFunction, + displayName: string, + status?: AgentUpdateStatus, +): string { + if (status?.check_state === "update_available") { + return t("agents:updateAvailableWithVersions", { + name: displayName, + current: status.effective_version, + latest: status.latest_version, + }); + } + if (status?.check_state === "unknown") { + return t("agents:runtimeUpdateStatusUnknown", { name: displayName }); + } + return t(UPDATE_AGENT_KEY, { name: displayName }); +} + export function AgentRuntimeUpdateControl({ agentName, displayName, runtimeUpdate, + runtimeUpdateStatus, job, installJob, onPreview, @@ -433,12 +420,20 @@ export function AgentRuntimeUpdateControl({ agentName: string; displayName: string; runtimeUpdate: RuntimeUpdate; + runtimeUpdateStatus?: AgentUpdateStatus; job?: AgentUpdateJob; installJob?: InstallJob; - onPreview: (agentName: string, targetVersion?: string) => Promise; - onUpdate: (agentName: string, targetVersion: string) => Promise; + onPreview: ( + agentName: string, + targetVersion?: string, + useDefault?: boolean, + ) => Promise; + onUpdate: ( + agentName: string, + targetVersion: string, + useDefault?: boolean, + ) => Promise; }) { - const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); const { activeJob, @@ -451,7 +446,9 @@ export function AgentRuntimeUpdateControl({ preview, previewError, selectTarget, + selectDefault, selectedTarget, + selectedUseDefault, starting, } = useAgentUpdateDialogState({ agentName, job, onPreview, onUpdate }); const installInFlight = installJob?.status === "queued" || installJob?.status === "running"; @@ -466,9 +463,13 @@ export function AgentRuntimeUpdateControl({ previewError={previewError} approveError={approveError} job={activeJob} - onRetryPreview={() => void loadPreview(selectedTarget || undefined)} + onRetryPreview={() => + void loadPreview(selectedUseDefault ? undefined : selectedTarget, selectedUseDefault) + } selectedTarget={selectedTarget} onSelectTarget={selectTarget} + selectedUseDefault={selectedUseDefault} + onSelectDefault={selectDefault} starting={starting} /> ); @@ -493,35 +494,19 @@ export function AgentRuntimeUpdateControl({ handleOpenChange(true)} /> - {isMobile ? ( - - - - {t(UPDATE_AGENT_KEY, { name: displayName })} - {t("agents:reviewUpdateBeforeApplying")} - - {body} - {footer(true)} - - - ) : ( -

- - - {t(UPDATE_AGENT_KEY, { name: displayName })} - {t("agents:reviewUpdateBeforeApplying")} - - {body} - {footer()} - - - )} + ); } diff --git a/apps/web/components/settings/agent-runtime-update-surface.tsx b/apps/web/components/settings/agent-runtime-update-surface.tsx new file mode 100644 index 0000000000..f9b082a09d --- /dev/null +++ b/apps/web/components/settings/agent-runtime-update-surface.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; + +const UPDATE_AGENT_KEY = "agents:updateAgent"; + +export function AgentRuntimeUpdateSurface({ + agentName, + displayName, + isMobile, + open, + onOpenChange, + body, + footer, +}: { + agentName: string; + displayName: string; + isMobile: boolean; + open: boolean; + onOpenChange: (nextOpen: boolean) => void; + body: ReactNode; + footer: (mobile?: boolean) => ReactNode; +}) { + const { t } = useTranslation(); + if (isMobile) { + return ( + + + + {t(UPDATE_AGENT_KEY, { name: displayName })} + {t("agents:reviewUpdateBeforeApplying")} + + {body} + {footer(true)} + + + ); + } + return ( + + + + {t(UPDATE_AGENT_KEY, { name: displayName })} + {t("agents:reviewUpdateBeforeApplying")} + + {body} + {footer()} + + + ); +} diff --git a/apps/web/components/settings/installed-agent-card.tsx b/apps/web/components/settings/installed-agent-card.tsx index 68373bc03e..f53a1e9a1d 100644 --- a/apps/web/components/settings/installed-agent-card.tsx +++ b/apps/web/components/settings/installed-agent-card.tsx @@ -22,7 +22,7 @@ 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 { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api"; +import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api"; import type { Agent, AgentDiscovery, RuntimeUpdate } from "@/lib/types/http"; type Props = { @@ -32,10 +32,19 @@ type Props = { /** Capability status from the host utility probe ("ok", "auth_required", etc.). */ capabilityStatus?: string; runtimeUpdate?: RuntimeUpdate; + runtimeUpdateStatus?: AgentUpdateStatus; updateJob?: AgentUpdateJob; installJob?: InstallJob; - onPreview?: (agentName: string) => Promise; - onUpdate?: (agentName: string, targetVersion: string) => Promise; + onPreview?: ( + agentName: string, + targetVersion?: string, + useDefault?: boolean, + ) => Promise; + onUpdate?: ( + agentName: string, + targetVersion: string, + useDefault?: boolean, + ) => Promise; /** * Called when the auth/shell dialog closes so the page can refresh * discovery + availability. Without this the yellow lock stays put even @@ -240,6 +249,7 @@ export function InstalledAgentCard({ displayName, capabilityStatus, runtimeUpdate, + runtimeUpdateStatus, updateJob, installJob, onPreview, @@ -304,6 +314,7 @@ export function InstalledAgentCard({ agentName={agent.name} displayName={displayName} runtimeUpdate={runtimeUpdate} + runtimeUpdateStatus={runtimeUpdateStatus} job={updateJob} installJob={installJob} onPreview={onPreview} diff --git a/apps/web/components/settings/runtime-version-picker.tsx b/apps/web/components/settings/runtime-version-picker.tsx new file mode 100644 index 0000000000..7bb0c9dbc2 --- /dev/null +++ b/apps/web/components/settings/runtime-version-picker.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useState } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { IconChevronDown, IconLoader2 } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kandev/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; +import { latestRuntimeVersions, resolveRuntimeActiveVersion } from "@/lib/agent-runtime-update"; +import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateVersion } from "@/lib/api"; + +const ACTIVE_UPDATE_STATUSES = new Set([ + "queued", + "resolving", + "updating", + "refreshing", +]); + +function versionLabel( + t: TFunction, + version: AgentUpdateVersion, + activeVersion?: string, + defaultVersion?: string, +): string { + const markers = [ + version.latest ? t("agents:latestRuntimeVersion") : "", + version.version === activeVersion ? t("agents:activeRuntimeVersionMarker") : "", + version.version === defaultVersion ? t("agents:kandevDefaultRuntimeVersionMarker") : "", + ] + .filter(Boolean) + .join(", "); + if (!markers) return version.version; + return t("agents:runtimeVersionOption", { + version: version.version, + markers: t("agents:runtimeVersionMarkerGroup", { markers }), + }); +} + +function RuntimeVersionBrowser({ + agentName, + browserId, + versions, + activeVersion, + defaultVersion, + selectedVersion, + selectedUseDefault, + onSelectTarget, +}: { + agentName: string; + browserId: string; + versions: AgentUpdateVersion[]; + activeVersion?: string; + defaultVersion?: string; + selectedVersion?: string; + selectedUseDefault: boolean; + onSelectTarget: (targetVersion: string) => void; +}) { + const { t } = useTranslation(); + return ( + event.stopPropagation()} + > + + + + {t("agents:noMatchingRuntimeVersions")} + + {versions.map((version) => { + const isSelected = !selectedUseDefault && selectedVersion === version.version; + return ( + onSelectTarget(version.version)} + > + {version.version} + + {versionLabel(t, version, activeVersion, defaultVersion).slice( + version.version.length, + )} + + + ); + })} + + + + + ); +} + +function RuntimeVersionQuickChoices({ + agentName, + latestVersion, + activeVersion, + defaultVersion, + selectedVersion, + selectedUseDefault, + disabled, + onSelectTarget, + onSelectDefault, +}: { + agentName: string; + latestVersion: string; + activeVersion?: string; + defaultVersion?: string; + selectedVersion?: string; + selectedUseDefault: boolean; + disabled: boolean; + onSelectTarget: (targetVersion: string) => void; + onSelectDefault: () => void; +}) { + const { t } = useTranslation(); + return ( +
+

{t("agents:runtimeVersionQuickChoices")}

+
+ + {defaultVersion && ( + + )} +
+
+ ); +} + +export function RuntimeVersionPicker({ + agentName, + preview, + selectedTarget, + selectedUseDefault, + loading, + starting, + job, + onSelectTarget, + onSelectDefault, +}: { + agentName: string; + preview: AgentUpdatePreview; + selectedTarget: string; + selectedUseDefault: boolean; + loading: boolean; + starting: boolean; + job?: AgentUpdateJob; + onSelectTarget: (targetVersion: string) => void; + onSelectDefault: () => void; +}) { + const { t } = useTranslation(); + const [browseOpen, setBrowseOpen] = useState(false); + const versions = latestRuntimeVersions( + preview.available_versions ?? [{ version: preview.target_version, latest: false }], + ); + const latestVersion = + versions.find((version) => version.latest)?.version ?? preview.target_version; + const activeVersion = resolveRuntimeActiveVersion(preview, job); + const selectedVersion = selectedUseDefault ? undefined : selectedTarget || preview.target_version; + const disabled = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status)) || starting || loading; + const browserId = `agent-update-version-browser-${agentName}`; + + const selectTarget = (targetVersion: string) => { + setBrowseOpen(false); + onSelectTarget(targetVersion); + }; + + const selectDefault = () => { + setBrowseOpen(false); + onSelectDefault(); + }; + + return ( +
+ + +
+

{t("agents:selectRuntimeVersion")}

+ + + + + + +
+
+ ); +} diff --git a/apps/web/components/settings/use-agent-update-dialog-state.test.ts b/apps/web/components/settings/use-agent-update-dialog-state.test.ts index e23a6ea1fa..42359370a9 100644 --- a/apps/web/components/settings/use-agent-update-dialog-state.test.ts +++ b/apps/web/components/settings/use-agent-update-dialog-state.test.ts @@ -193,7 +193,9 @@ describe("useAgentUpdateDialogState target selection", () => { expect(result.current.selectedTarget).toBe("0.60.0"); expect(result.current.preview?.target_version).toBe("0.60.0"); }); +}); +describe("useAgentUpdateDialogState approval", () => { it("approves the selected exact target", async () => { const onUpdate = vi.fn().mockResolvedValue({ job_id: "job-1", @@ -223,6 +225,42 @@ describe("useAgentUpdateDialogState target selection", () => { }); expect(onUpdate).toHaveBeenLastCalledWith(AGENT_NAME, "0.61.0"); }); + + it("previews and approves the Kandev default through structural arguments", async () => { + const defaultPreview: AgentUpdatePreview = { + ...FIRST_PREVIEW, + default_version: "0.61.0", + active_version: "0.62.0", + effective_version: "0.62.0", + target_version: "0.61.0", + operation: "use_default", + }; + const onPreview = vi.fn().mockResolvedValue(defaultPreview); + const onUpdate = vi.fn().mockResolvedValue({ + job_id: "job-2", + agent_name: AGENT_NAME, + status: "queued", + started_at: "2026-01-01T00:00:00.000Z", + } satisfies AgentUpdateJob); + const { result } = renderHook(() => + useAgentUpdateDialogState({ + agentName: AGENT_NAME, + onPreview, + onUpdate, + }), + ); + + await act(async () => { + await result.current.loadPreview(); + }); + act(() => result.current.selectDefault()); + await waitFor(() => expect(onPreview).toHaveBeenLastCalledWith(AGENT_NAME, undefined, true)); + expect(result.current.selectedUseDefault).toBe(true); + await act(async () => { + await result.current.approve(); + }); + expect(onUpdate).toHaveBeenLastCalledWith(AGENT_NAME, "0.61.0", true); + }); }); describe("useAgentUpdateDialogState failed target selection", () => { diff --git a/apps/web/components/settings/use-agent-update-dialog-state.ts b/apps/web/components/settings/use-agent-update-dialog-state.ts index 29286f3588..65399b04a3 100644 --- a/apps/web/components/settings/use-agent-update-dialog-state.ts +++ b/apps/web/components/settings/use-agent-update-dialog-state.ts @@ -10,10 +10,129 @@ function errorMessage(error: unknown) { type UseAgentUpdateDialogStateOptions = { agentName: string; job?: AgentUpdateJob; - onPreview: (agentName: string, targetVersion?: string) => Promise; - onUpdate: (agentName: string, targetVersion: string) => Promise; + onPreview: ( + agentName: string, + targetVersion?: string, + useDefault?: boolean, + ) => Promise; + onUpdate: ( + agentName: string, + targetVersion: string, + useDefault?: boolean, + ) => Promise; }; +// i18n-exempt: internal selector token, never rendered as user-facing copy. +export const DEFAULT_RUNTIME_TARGET = "__kandev_default__"; + +function resetDialogState({ + previewRequestID, + setPreview, + setPreviewError, + setApproveError, + setLoading, + setStarting, + setSelectedTarget, + setSelectedUseDefault, + setActiveJobID, +}: { + previewRequestID: { current: number }; + setPreview: (value: AgentUpdatePreview | null) => void; + setPreviewError: (value: string | null) => void; + setApproveError: (value: string | null) => void; + setLoading: (value: boolean) => void; + setStarting: (value: boolean) => void; + setSelectedTarget: (value: string) => void; + setSelectedUseDefault: (value: boolean) => void; + setActiveJobID: (value: string | null) => void; +}) { + previewRequestID.current += 1; + setPreview(null); + setPreviewError(null); + setApproveError(null); + setLoading(false); + setStarting(false); + setSelectedTarget(""); + setSelectedUseDefault(false); + setActiveJobID(null); +} + +async function approveRuntimeUpdate({ + requestID, + previewRequestID, + agentName, + preview, + selectedTarget, + selectedUseDefault, + onUpdate, + setStarting, + setApproveError, + setActiveJobID, +}: { + requestID: number; + previewRequestID: { current: number }; + agentName: string; + preview: AgentUpdatePreview | null; + selectedTarget: string; + selectedUseDefault: boolean; + onUpdate: UseAgentUpdateDialogStateOptions["onUpdate"]; + setStarting: (value: boolean) => void; + setApproveError: (value: string | null) => void; + setActiveJobID: (value: string | null) => void; +}) { + const targetVersion = selectedUseDefault + ? preview?.default_version || preview?.target_version || "" + : selectedTarget || preview?.target_version || ""; + if (!targetVersion) return; + setStarting(true); + setApproveError(null); + try { + const nextJob = selectedUseDefault + ? await onUpdate(agentName, targetVersion, true) + : await onUpdate(agentName, targetVersion); + if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id); + } catch (error) { + if (requestID === previewRequestID.current) setApproveError(errorMessage(error)); + } finally { + if (requestID === previewRequestID.current) setStarting(false); + } +} + +function handleDialogOpenChange( + nextOpen: boolean, + setOpen: (value: boolean) => void, + reset: () => void, +) { + setOpen(nextOpen); + if (!nextOpen) reset(); +} + +type PreviewLoader = (targetVersion?: string, useDefault?: boolean) => Promise; + +function useRuntimeTargetSelectors( + loadPreview: PreviewLoader, + setActiveJobID: (value: string | null) => void, + setSelectedTarget: (value: string) => void, + setSelectedUseDefault: (value: boolean) => void, +) { + const selectTarget = useCallback( + (targetVersion: string) => { + setActiveJobID(null); + setSelectedTarget(targetVersion); + setSelectedUseDefault(false); + void loadPreview(targetVersion); + }, + [loadPreview], + ); + const selectDefault = useCallback(() => { + setActiveJobID(null); + setSelectedTarget(DEFAULT_RUNTIME_TARGET); + setSelectedUseDefault(true); + void loadPreview(undefined, true); + }, [loadPreview]); + return { selectTarget, selectDefault }; +} + export function useAgentUpdateDialogState({ agentName, job, @@ -27,32 +146,39 @@ export function useAgentUpdateDialogState({ const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); const [selectedTarget, setSelectedTarget] = useState(""); + const [selectedUseDefault, setSelectedUseDefault] = useState(false); const [activeJobID, setActiveJobID] = useState(null); const previewRequestID = useRef(0); const activeJob = activeJobID === job?.job_id ? job : undefined; const reset = useCallback(() => { - previewRequestID.current += 1; - setPreview(null); - setPreviewError(null); - setApproveError(null); - setLoading(false); - setStarting(false); - setSelectedTarget(""); - setActiveJobID(null); + resetDialogState({ + previewRequestID, + setPreview, + setPreviewError, + setApproveError, + setLoading, + setStarting, + setSelectedTarget, + setSelectedUseDefault, + setActiveJobID, + }); }, []); const loadPreview = useCallback( - async (targetVersion?: string) => { + async (targetVersion?: string, useDefault = false) => { const requestID = ++previewRequestID.current; setLoading(true); setPreviewError(null); setApproveError(null); try { - const nextPreview = await onPreview(agentName, targetVersion); + const nextPreview = useDefault + ? await onPreview(agentName, undefined, true) + : await onPreview(agentName, targetVersion); if (requestID === previewRequestID.current) { setPreview(nextPreview); - setSelectedTarget(nextPreview.target_version); + setSelectedTarget(useDefault ? DEFAULT_RUNTIME_TARGET : nextPreview.target_version); + setSelectedUseDefault(useDefault); } } catch (error) { if (requestID === previewRequestID.current) setPreviewError(errorMessage(error)); @@ -63,39 +189,35 @@ export function useAgentUpdateDialogState({ [agentName, onPreview], ); - const selectTarget = useCallback( - (targetVersion: string) => { - setActiveJobID(null); - setSelectedTarget(targetVersion); - void loadPreview(targetVersion); - }, - [loadPreview], + const { selectTarget, selectDefault } = useRuntimeTargetSelectors( + loadPreview, + setActiveJobID, + setSelectedTarget, + setSelectedUseDefault, ); useEffect(() => { if (open) void loadPreview(); }, [loadPreview, open]); - const handleOpenChange = (nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) reset(); - }; + const handleOpenChange = (nextOpen: boolean) => handleDialogOpenChange(nextOpen, setOpen, reset); - const approve = async () => { - const requestID = previewRequestID.current; - const targetVersion = selectedTarget || preview?.target_version || ""; - if (!targetVersion) return; - setStarting(true); - setApproveError(null); - try { - const nextJob = await onUpdate(agentName, targetVersion); - if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id); - } catch (error) { - if (requestID === previewRequestID.current) setApproveError(errorMessage(error)); - } finally { - if (requestID === previewRequestID.current) setStarting(false); - } - }; + const approve = useCallback( + () => + approveRuntimeUpdate({ + requestID: previewRequestID.current, + previewRequestID, + agentName, + preview, + selectedTarget, + selectedUseDefault, + onUpdate, + setStarting, + setApproveError, + setActiveJobID, + }), + [agentName, onUpdate, preview, selectedTarget, selectedUseDefault], + ); return { activeJob, @@ -108,7 +230,9 @@ export function useAgentUpdateDialogState({ preview, previewError, selectTarget, + selectDefault, selectedTarget, + selectedUseDefault, starting, }; } diff --git a/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts b/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts index 03d2755d3a..ce459ad878 100644 --- a/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts +++ b/apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts @@ -1,11 +1,11 @@ -import { expect, type Page, type WebSocketRoute } from "@playwright/test"; +import { expect, type Page, type Route, type WebSocketRoute } from "@playwright/test"; import { injectLatency } from "../../helpers/causal-waits"; const AGENT_NAME = "claude-acp"; const NOW = "2026-07-26T12:00:00.000Z"; type UpdateStatus = "queued" | "resolving" | "updating" | "refreshing" | "succeeded" | "failed"; -type UpdateOperation = "update" | "rollback" | "repair" | "up_to_date"; +type UpdateOperation = "update" | "rollback" | "repair" | "up_to_date" | "use_default"; type UpdateJob = { job_id: string; @@ -14,6 +14,8 @@ type UpdateJob = { operation?: UpdateOperation; active_version?: string; current_version?: string; + default_version?: string; + effective_version?: string; target_version?: string; output?: string; error?: string; @@ -26,7 +28,9 @@ type UpdatePreview = { agent_name: string; package: string; current_version: string; + default_version?: string; active_version?: string; + effective_version?: string; target_version: string; operation?: UpdateOperation; available_versions?: Array<{ version: string; latest: boolean }>; @@ -34,7 +38,22 @@ type UpdatePreview = { command_string: string; }; -function catalogue(models: Array<{ id: string; name: string }>, displayName = "Claude") { +export type RuntimeUpdateStatus = { + agent_name: string; + package: string; + default_version: string; + active_version?: string; + effective_version: string; + latest_version?: string; + checked_at?: string; + check_state: "update_available" | "up_to_date" | "unknown"; +}; + +function catalogue( + models: Array<{ id: string; name: string }>, + displayName = "Claude", + runtimeVersion = "0.62.0", +) { return { agents: [ { @@ -65,8 +84,10 @@ function catalogue(models: Array<{ id: string; name: string }>, displayName = "C runtime_update: { supported: true, package: "@agentclientprotocol/claude-agent-acp", - current_version: "0.62.0", - active_version: "0.62.0", + current_version: runtimeVersion, + default_version: "0.64.0", + active_version: runtimeVersion, + effective_version: runtimeVersion, }, updated_at: NOW, }, @@ -123,12 +144,72 @@ function operationForTarget(currentVersion: string, targetVersion: string): Upda return comparison > 0 ? "update" : "rollback"; } +function previewForRequest( + previewResponse: UpdatePreview, + requestedTarget: string | null, + useDefault: boolean, +): UpdatePreview { + if (useDefault) { + const defaultVersion = previewResponse.default_version ?? "0.64.0"; + return { + ...previewResponse, + target_version: defaultVersion, + operation: "use_default", + }; + } + if (!requestedTarget) return previewResponse; + return { + ...previewResponse, + target_version: requestedTarget, + operation: operationForTarget(previewResponse.current_version, requestedTarget), + }; +} + +async function handlePreviewRoute({ + route, + url, + previewResponse, + previewFailures, + previewDelayMs, +}: { + route: Route; + url: URL; + previewResponse: UpdatePreview; + previewFailures: string[]; + previewDelayMs: number; +}): Promise { + const requestedTarget = url.searchParams.get("target_version"); + const useDefault = url.searchParams.get("use_default") === "true"; + if (requestedTarget && previewFailures.includes(requestedTarget)) { + previewFailures.splice(previewFailures.indexOf(requestedTarget), 1); + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "preview temporarily unavailable" }), + }); + return; + } + if (requestedTarget && previewDelayMs > 0) { + await injectLatency( + previewDelayMs, + "simulates a slow agent-update preview so the in-flight preview state stays observable", + ); + } + const response = previewForRequest(previewResponse, requestedTarget, useDefault); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(response), + }); +} + export type RuntimeUpdateFixtureOptions = { retainedJobs?: UpdateJob[]; postResponse?: UpdateJob; previewResponse?: UpdatePreview; previewFailures?: string[]; previewDelayMs?: number; + statusResponse?: RuntimeUpdateStatus[]; }; export async function installRuntimeUpdateFixture( @@ -136,6 +217,7 @@ export async function installRuntimeUpdateFixture( options: RuntimeUpdateFixtureOptions = {}, ) { let currentModels = [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }]; + let persistedRuntimeVersion = "0.62.0"; let retainedJobs = options.retainedJobs ?? []; let postResponse: UpdateJob = options.postResponse ?? @@ -148,10 +230,24 @@ export async function installRuntimeUpdateFixture( } satisfies UpdateJob); let postCount = 0; let previewCount = 0; + let previewDefaultCount = 0; + let statusRequestCount = 0; const previewTargets: string[] = []; const postTargets: string[] = []; const previewFailures = [...(options.previewFailures ?? [])]; const previewDelayMs = options.previewDelayMs ?? 0; + let statusResponse = options.statusResponse ?? [ + { + agent_name: AGENT_NAME, + package: "@agentclientprotocol/claude-agent-acp", + default_version: "0.64.0", + active_version: "0.62.0", + effective_version: "0.62.0", + latest_version: "0.64.0", + checked_at: NOW, + check_state: "update_available", + }, + ]; let previewResponse: UpdatePreview = options.previewResponse ?? ({ @@ -160,7 +256,9 @@ export async function installRuntimeUpdateFixture( current_version: "0.62.0", target_version: "0.63.0", operation: "update", + default_version: "0.64.0", active_version: "0.62.0", + effective_version: "0.62.0", available_versions: [ { version: "0.64.0", latest: true }, { version: "0.63.0", latest: false }, @@ -195,7 +293,7 @@ export async function installRuntimeUpdateFixture( route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(catalogue(currentModels)), + body: JSON.stringify(catalogue(currentModels, "Claude", persistedRuntimeVersion)), }), ); await page.route("**/api/v1/agents", (route) => @@ -213,33 +311,23 @@ export async function installRuntimeUpdateFixture( url.pathname.endsWith(`/agent-update/${AGENT_NAME}/preview`) ) { previewCount += 1; - previewTargets.push(url.searchParams.get("target_version") ?? ""); const requestedTarget = url.searchParams.get("target_version"); - if (requestedTarget && previewFailures.includes(requestedTarget)) { - previewFailures.splice(previewFailures.indexOf(requestedTarget), 1); - return route.fulfill({ - status: 503, - contentType: "application/json", - body: JSON.stringify({ error: "preview temporarily unavailable" }), - }); - } - if (requestedTarget && previewDelayMs > 0) { - await injectLatency( - previewDelayMs, - "simulates a slow agent-update preview so the in-flight preview state stays observable", - ); - } - const response = requestedTarget - ? { - ...previewResponse, - target_version: requestedTarget, - operation: operationForTarget(previewResponse.current_version, requestedTarget), - } - : previewResponse; + previewTargets.push(requestedTarget ?? ""); + if (url.searchParams.get("use_default") === "true") previewDefaultCount += 1; + return handlePreviewRoute({ + route, + url, + previewResponse, + previewFailures, + previewDelayMs, + }); + } + if (request.method() === "GET" && url.pathname.endsWith("/agent-update/status")) { + statusRequestCount += 1; return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(response), + body: JSON.stringify({ statuses: statusResponse }), }); } if (request.method() === "GET" && url.pathname.endsWith("/agent-update/jobs")) { @@ -251,8 +339,11 @@ export async function installRuntimeUpdateFixture( } if (request.method() === "POST" && url.pathname.endsWith(`/agent-update/${AGENT_NAME}`)) { postCount += 1; - const body = request.postDataJSON() as { target_version?: string } | null; - postTargets.push(body?.target_version ?? ""); + const body = request.postDataJSON() as { + target_version?: string; + use_default?: boolean; + } | null; + postTargets.push(body?.use_default ? "__kandev_default__" : (body?.target_version ?? "")); return route.fulfill({ status: 202, contentType: "application/json", @@ -302,8 +393,34 @@ export async function installRuntimeUpdateFixture( setPreviewResponse(preview: UpdatePreview) { previewResponse = preview; }, + setPersistedRuntimeVersion(version: string) { + persistedRuntimeVersion = version; + previewResponse = { + ...previewResponse, + current_version: version, + active_version: version, + effective_version: version, + target_version: version, + operation: "up_to_date", + }; + statusResponse = statusResponse.map((status) => + status.agent_name === AGENT_NAME + ? { + ...status, + active_version: version, + effective_version: version, + check_state: "up_to_date" as const, + } + : status, + ); + }, + setStatusResponse(statuses: RuntimeUpdateStatus[]) { + statusResponse = statuses; + }, postCount: () => postCount, previewCount: () => previewCount, + previewDefaultCount: () => previewDefaultCount, + statusRequestCount: () => statusRequestCount, previewTargets: () => [...previewTargets], postTargets: () => [...postTargets], async emit(action: string, payload: unknown) { @@ -327,7 +444,10 @@ export async function installRuntimeUpdateFixture( }, async emitCatalogue(models: Array<{ id: string; name: string }>) { currentModels = models; - await this.emit("agent.available.updated", catalogue(models, "Claude refreshed")); + await this.emit( + "agent.available.updated", + catalogue(models, "Claude refreshed", persistedRuntimeVersion), + ); }, }; } diff --git a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts index 7037f0b921..d8b6f46a25 100644 --- a/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts +++ b/apps/web/e2e/tests/settings/agent-runtime-update.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { settledBoundingBox } from "../../helpers/settled-box"; import { installRuntimeUpdateFixture, updateJob } from "./agent-runtime-update-helpers"; test.describe("managed agent runtime updates", () => { @@ -16,6 +17,10 @@ test.describe("managed agent runtime updates", () => { const navigationsAfterLoad = documentNavigations; const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`); await expect(trigger).toBeVisible(); + await expect( + testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`), + ).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-label", /0\.62\.0.*0\.64\.0/); await expect(testPage.getByTestId(`agent-update-control-${runtime.agentName}`)).toHaveCount(0); const profileAction = testPage.getByRole("link", { name: "Setup Profile" }); const [profileActionBox, triggerBox] = await Promise.all([ @@ -40,17 +45,21 @@ test.describe("managed agent runtime updates", () => { .getByTestId(`agent-update-version-${runtime.agentName}`) .boundingBox(); expect(selectorBox).not.toBeNull(); - expect(selectorBox!.height).toBeLessThanOrEqual(40); + expect(selectorBox!.height).toBeGreaterThanOrEqual(44); + const body = dialog.getByTestId(`agent-update-dialog-body-${runtime.agentName}`); + await expect + .poll(() => body.evaluate((element) => element.scrollHeight <= element.clientHeight)) + .toBe(true); + const quickLatestBox = await dialog + .getByTestId(`agent-update-quick-latest-${runtime.agentName}`) + .boundingBox(); + expect(quickLatestBox).not.toBeNull(); + expect(quickLatestBox!.height).toBeLessThan(44); await expect(dialog).toContainText("0.62.0 → 0.63.0"); await expect(dialog).toContainText( 'npm exec --yes --prefer-online --package=@agentclientprotocol/claude-agent-acp -- node -e ""', ); await expect(dialog).toContainText("Active sessions keep running"); - const viewport = testPage.viewportSize(); - expect(viewport).not.toBeNull(); - await expect - .poll(() => dialog.evaluate((element) => element.getBoundingClientRect().height)) - .toBeLessThan(viewport!.height * 0.6); expect(runtime.previewCount()).toBe(1); expect(runtime.postCount()).toBe(0); await prCapture.screenshot("desktop-update-preview", { @@ -77,12 +86,28 @@ test.describe("managed agent runtime updates", () => { finished_at: "2026-07-26T12:01:00.000Z", }), ); + runtime.setStatusResponse([ + { + agent_name: runtime.agentName, + package: "@agentclientprotocol/claude-agent-acp", + default_version: "0.64.0", + active_version: "0.63.0", + effective_version: "0.63.0", + latest_version: "0.63.0", + checked_at: "2026-07-26T12:01:00.000Z", + check_state: "up_to_date", + }, + ]); + runtime.setPersistedRuntimeVersion("0.63.0"); await runtime.emitCatalogue([ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { id: "claude-opus-5", name: "Claude Opus 5" }, ]); await expect(testPage.getByText("Claude refreshed", { exact: true })).toBeVisible(); + await expect( + testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`), + ).toHaveCount(0); await expect(dialog.getByTestId(`agent-update-result-${runtime.agentName}`)).toContainText( "Runtime updated successfully", ); @@ -91,6 +116,10 @@ test.describe("managed agent runtime updates", () => { await expect(trigger).toBeVisible(); await expect(testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`)).toHaveCount(0); await expect(testPage.getByTestId(`agent-update-result-${runtime.agentName}`)).toHaveCount(0); + await trigger.click(); + await expect(testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`)).toContainText( + "Active version: 0.63.0", + ); }); test("requires a current runtime version before approval", async ({ testPage }) => { @@ -114,6 +143,52 @@ test.describe("managed agent runtime updates", () => { expect(runtime.postCount()).toBe(0); }); + test("keeps the control usable when the latest-version check is unknown", async ({ + testPage, + }) => { + const runtime = await installRuntimeUpdateFixture(testPage, { + statusResponse: [ + { + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + default_version: "0.64.0", + active_version: "0.62.0", + effective_version: "0.62.0", + check_state: "unknown", + }, + ], + }); + + await testPage.goto("/settings/agents"); + const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`); + await expect(trigger).toBeVisible(); + await expect( + testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`), + ).toHaveCount(0); + await expect(trigger).toHaveAttribute("aria-label", /latest version.*unavailable/i); + await trigger.click(); + await expect(testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`)).toBeVisible(); + expect(runtime.postCount()).toBe(0); + }); + + test("uses a structural request to return to the Kandev default", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await dialog.getByTestId(`agent-update-quick-default-${runtime.agentName}`).click(); + + await expect(dialog).toContainText("0.62.0 → 0.64.0"); + await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText( + "Use Kandev default", + ); + expect(runtime.previewTargets()).toEqual(["", ""]); + expect(runtime.previewDefaultCount()).toBe(1); + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click(); + expect(runtime.postTargets()).toEqual(["__kandev_default__"]); + }); + test("shows an up-to-date preview and disables approval when versions match", async ({ testPage, prCapture, @@ -192,7 +267,11 @@ test.describe("managed agent runtime updates", () => { await testPage.goto("/settings/agents"); await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); - await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0"); + await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).click(); + await testPage + .getByTestId(`agent-update-version-browser-${runtime.agentName}`) + .getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`) + .click(); await expect(dialog).toContainText("0.62.0 → 0.61.0"); await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText( @@ -211,7 +290,11 @@ test.describe("managed agent runtime updates", () => { const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); const body = dialog.getByTestId(`agent-update-dialog-body-${runtime.agentName}`); - await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0"); + await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).click(); + await testPage + .getByTestId(`agent-update-version-browser-${runtime.agentName}`) + .getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`) + .click(); await expect( dialog.getByTestId(`agent-update-version-loading-${runtime.agentName}`), @@ -224,7 +307,7 @@ test.describe("managed agent runtime updates", () => { ).toBeHidden(); }); - test("limits the version selector to the ten newest options", async ({ testPage }) => { + test("shows the complete backend version projection", async ({ testPage }) => { const runtime = await installRuntimeUpdateFixture(testPage); runtime.setPreviewResponse({ agent_name: "claude-acp", @@ -243,14 +326,39 @@ test.describe("managed agent runtime updates", () => { await testPage.goto("/settings/agents"); await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); - const options = testPage - .getByTestId(`agent-update-version-${runtime.agentName}`) - .locator("option"); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + const selector = testPage.getByTestId(`agent-update-version-${runtime.agentName}`); + await expect( + testPage.getByTestId(`agent-update-version-option-${runtime.agentName}-0.74.0`), + ).toHaveCount(0); + const dialogBefore = await settledBoundingBox(dialog); + await selector.click(); + const browser = testPage.getByTestId(`agent-update-version-browser-${runtime.agentName}`); + await expect(browser).toBeVisible(); + await expect(browser).toHaveAttribute("data-slot", "popover-content"); + const browserBox = await settledBoundingBox(browser); + const viewport = testPage.viewportSize(); + expect(viewport).not.toBeNull(); + expect(browserBox.x).toBeGreaterThanOrEqual(0); + expect(browserBox.y).toBeGreaterThanOrEqual(0); + expect(browserBox.x + browserBox.width).toBeLessThanOrEqual(viewport!.width); + expect(browserBox.y + browserBox.height).toBeLessThanOrEqual(viewport!.height); + const dialogAfter = await settledBoundingBox(dialog); + expect(Math.abs(dialogAfter.height - dialogBefore.height)).toBeLessThan(2); + expect( + await browser.evaluate( + (element) => + element.closest('[data-slot="dialog-content"], [data-slot="drawer-content"]') === null, + ), + ).toBe(true); + const options = browser.getByRole("option"); - await expect(options).toHaveCount(10); + await expect(options).toHaveCount(12); expect( await options.evaluateAll((elements) => - elements.map((element) => (element as HTMLOptionElement).value), + elements.map( + (element) => element.getAttribute("data-value") ?? element.textContent?.trim(), + ), ), ).toEqual([ "0.74.0", @@ -263,6 +371,8 @@ test.describe("managed agent runtime updates", () => { "0.67.0", "0.66.0", "0.65.0", + "0.64.0", + "0.63.0", ]); }); @@ -274,7 +384,11 @@ test.describe("managed agent runtime updates", () => { await testPage.goto("/settings/agents"); await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); - await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).selectOption("0.61.0"); + await testPage.getByTestId(`agent-update-version-${runtime.agentName}`).click(); + await testPage + .getByTestId(`agent-update-version-browser-${runtime.agentName}`) + .getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`) + .click(); await expect(dialog.getByRole("alert")).toContainText("preview temporarily unavailable"); await dialog.getByRole("button", { name: "Retry version check" }).click(); @@ -353,15 +467,39 @@ test.describe("managed agent runtime updates", () => { ); await expect(dialog).toContainText("Active version: 0.63.0"); - const successOptionText = await dialog - .getByTestId(`agent-update-version-${runtime.agentName}`) - .locator("option") + await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).click(); + const successOptionText = await testPage + .getByTestId(`agent-update-version-browser-${runtime.agentName}`) + .getByRole("option") .allTextContents(); expect( successOptionText.some((text) => text.includes("0.63.0") && text.includes("active")), ).toBe(true); }); + test("clears the active version after a successful default reset", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage, { + postResponse: updateJob({ + status: "succeeded", + operation: "use_default", + current_version: "0.64.0", + default_version: "0.64.0", + effective_version: "0.64.0", + target_version: "0.64.0", + finished_at: "2026-07-26T12:01:00.000Z", + }), + }); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click(); + const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`); + await dialog.getByTestId(`agent-update-quick-default-${runtime.agentName}`).click(); + await testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`).click(); + + await expect(dialog).toContainText("Effective version: 0.64.0"); + await expect(dialog).not.toContainText("Active version: 0.62.0"); + }); + test("keeps the previous active version after a failed activation", async ({ testPage }) => { const runtime = await installRuntimeUpdateFixture(testPage); @@ -379,9 +517,10 @@ test.describe("managed agent runtime updates", () => { ); await expect(dialog).toContainText("Active version: 0.62.0"); - const failedOptionText = await dialog - .getByTestId(`agent-update-version-${runtime.agentName}`) - .locator("option") + await dialog.getByTestId(`agent-update-version-${runtime.agentName}`).click(); + const failedOptionText = await testPage + .getByTestId(`agent-update-version-browser-${runtime.agentName}`) + .getByRole("option") .allTextContents(); expect( failedOptionText.some((text) => text.includes("0.62.0") && text.includes("active")), diff --git a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts index 6fea1b4ece..805b0a14ef 100644 --- a/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import { settledBoundingBox } from "../../helpers/settled-box"; import { installRuntimeUpdateFixture, updateJob } from "./agent-runtime-update-helpers"; test.describe("managed agent runtime updates on mobile", () => { @@ -10,6 +11,9 @@ test.describe("managed agent runtime updates on mobile", () => { await testPage.goto("/settings/agents"); const trigger = testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`); + await expect( + testPage.getByTestId(`agent-update-available-dot-${runtime.agentName}`), + ).toBeVisible(); await trigger.scrollIntoViewIfNeeded(); const triggerBox = await trigger.boundingBox(); expect(triggerBox).not.toBeNull(); @@ -20,6 +24,10 @@ test.describe("managed agent runtime updates on mobile", () => { const drawer = testPage.getByTestId(`agent-update-drawer-${runtime.agentName}`); await expect(drawer).toBeVisible(); await expect(drawer).toContainText("0.62.0 → 0.63.0"); + const body = drawer.getByTestId(`agent-update-dialog-body-${runtime.agentName}`); + await expect + .poll(() => body.evaluate((element) => element.scrollHeight <= element.clientHeight)) + .toBe(true); expect(runtime.postCount()).toBe(0); await prCapture.screenshot("mobile-update-preview", { caption: "Mobile update preview before approval", @@ -40,7 +48,6 @@ test.describe("managed agent runtime updates on mobile", () => { await expect(drawer.getByTestId(`agent-update-phase-${runtime.agentName}`)).toContainText( "Updating runtime", ); - const body = drawer.getByTestId(`agent-update-dialog-body-${runtime.agentName}`); await body.scrollIntoViewIfNeeded(); await expect( body.evaluate((element) => element.scrollHeight > element.clientHeight), @@ -95,7 +102,31 @@ test.describe("managed agent runtime updates on mobile", () => { const selectorBox = await selector.boundingBox(); expect(selectorBox).not.toBeNull(); expect(selectorBox!.height).toBeGreaterThanOrEqual(44); - await selector.selectOption("0.61.0"); + const drawerBefore = await settledBoundingBox(drawer); + await selector.tap(); + const browser = testPage.getByTestId(`agent-update-version-browser-${runtime.agentName}`); + await expect(browser).toBeVisible(); + await expect(browser).toHaveAttribute("data-slot", "popover-content"); + const browserBox = await settledBoundingBox(browser); + const viewport = testPage.viewportSize(); + expect(viewport).not.toBeNull(); + expect(browserBox.x).toBeGreaterThanOrEqual(0); + expect(browserBox.y).toBeGreaterThanOrEqual(0); + expect(browserBox.x + browserBox.width).toBeLessThanOrEqual(viewport!.width); + expect(browserBox.y + browserBox.height).toBeLessThanOrEqual(viewport!.height); + const drawerAfter = await settledBoundingBox(drawer); + expect(Math.abs(drawerAfter.height - drawerBefore.height)).toBeLessThan(2); + expect( + await browser.evaluate( + (element) => + element.closest('[data-slot="dialog-content"], [data-slot="drawer-content"]') === null, + ), + ).toBe(true); + const option = browser.getByTestId(`agent-update-version-option-${runtime.agentName}-0.61.0`); + const optionBox = await option.boundingBox(); + expect(optionBox).not.toBeNull(); + expect(optionBox!.height).toBeGreaterThanOrEqual(44); + await option.tap(); await expect(drawer).toContainText("0.62.0 → 0.61.0"); await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toHaveText( @@ -106,6 +137,46 @@ test.describe("managed agent runtime updates on mobile", () => { expect(runtime.previewTargets()).toEqual(["", "0.61.0"]); }); + test("searches the full version history in the drawer", async ({ testPage }) => { + const runtime = await installRuntimeUpdateFixture(testPage, { + previewResponse: { + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + current_version: "0.62.0", + default_version: "0.64.0", + active_version: "0.62.0", + effective_version: "0.62.0", + target_version: "0.63.0", + available_versions: Array.from({ length: 12 }, (_, index) => ({ + version: `0.${74 - index}.0`, + latest: index === 0, + })), + command: ["npm", "exec"], + command_string: "npm exec", + }, + }); + + await testPage.goto("/settings/agents"); + await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).tap(); + const drawer = testPage.getByTestId(`agent-update-drawer-${runtime.agentName}`); + await drawer.getByTestId(`agent-update-version-${runtime.agentName}`).tap(); + const browser = testPage.getByTestId(`agent-update-version-browser-${runtime.agentName}`); + await expect(browser).toBeVisible(); + const search = browser.getByPlaceholder("Search versions"); + await expect(search).toBeVisible(); + await search.fill("0.70"); + const option = browser.getByTestId(`agent-update-version-option-${runtime.agentName}-0.70.0`); + await expect(option).toBeVisible(); + // The popover entrance animation scales the option row briefly. Poll until + // the touch target reaches its settled 44px height before tapping it. + await expect + .poll(async () => Math.round((await option.boundingBox())?.height ?? 0)) + .toBeGreaterThanOrEqual(44); + await option.tap(); + await expect(drawer).toContainText("0.62.0 → 0.70.0"); + expect(runtime.previewTargets()).toEqual(["", "0.70.0"]); + }); + test("keeps retry available after an update job fails", async ({ testPage }) => { const runtime = await installRuntimeUpdateFixture(testPage); diff --git a/apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.test.tsx b/apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.test.tsx new file mode 100644 index 0000000000..44e427e317 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.test.tsx @@ -0,0 +1,99 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AgentUpdateJob } from "@/lib/api"; + +const listAgentUpdateStatusesMock = vi.fn(); + +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + listAgentUpdateStatuses: (...args: unknown[]) => listAgentUpdateStatusesMock(...args), +})); + +import { useAgentRuntimeUpdateStatuses } from "./use-agent-runtime-update-statuses"; + +function job(overrides: Partial = {}): AgentUpdateJob { + return { + job_id: "job-1", + agent_name: "claude-acp", + status: "succeeded", + started_at: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +afterEach(() => vi.clearAllMocks()); + +describe("useAgentRuntimeUpdateStatuses", () => { + it("loads structural statuses into a page-local agent map", async () => { + listAgentUpdateStatusesMock.mockResolvedValueOnce({ + statuses: [ + { + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + default_version: "0.70.0", + effective_version: "0.70.0", + latest_version: "0.71.0", + check_state: "update_available", + }, + { + agent_name: "codex-acp", + package: "@agentclientprotocol/codex-acp", + default_version: "1.6.0", + effective_version: "1.6.0", + check_state: "unknown", + }, + ], + }); + + const { result } = renderHook(() => useAgentRuntimeUpdateStatuses({})); + + await waitFor(() => expect(result.current.statusByAgent["claude-acp"]).toBeDefined()); + expect(result.current.statusByAgent["claude-acp"]?.check_state).toBe("update_available"); + expect(result.current.statusByAgent["codex-acp"]?.check_state).toBe("unknown"); + expect(listAgentUpdateStatusesMock).toHaveBeenCalledWith({ cache: "no-store" }); + }); + + it("refreshes once after a successful update job", async () => { + listAgentUpdateStatusesMock + .mockResolvedValueOnce({ statuses: [] }) + .mockResolvedValueOnce({ statuses: [] }); + const { result, rerender } = renderHook(({ jobs }) => useAgentRuntimeUpdateStatuses(jobs), { + initialProps: { jobs: {} as Record }, + }); + + await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(1)); + rerender({ jobs: { "claude-acp": job() } }); + await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(2)); + expect(result.current.statusByAgent).toEqual({}); + }); + + it("does not erase a last good map when a refresh fails", async () => { + listAgentUpdateStatusesMock + .mockResolvedValueOnce({ + statuses: [ + { + agent_name: "claude-acp", + package: "@agentclientprotocol/claude-agent-acp", + default_version: "0.70.0", + effective_version: "0.70.0", + latest_version: "0.71.0", + check_state: "update_available", + }, + ], + }) + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce({ statuses: [] }); + const { result, rerender } = renderHook(({ jobs }) => useAgentRuntimeUpdateStatuses(jobs), { + initialProps: { jobs: {} as Record }, + }); + + await waitFor(() => expect(result.current.statusByAgent["claude-acp"]).toBeDefined()); + rerender({ jobs: { "claude-acp": job({ job_id: "job-2" }) } }); + await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(2)); + expect(result.current.statusByAgent["claude-acp"]?.check_state).toBe("update_available"); + + rerender({ jobs: { "claude-acp": job({ job_id: "job-2" }) } }); + await waitFor(() => expect(listAgentUpdateStatusesMock).toHaveBeenCalledTimes(3)); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.ts b/apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.ts new file mode 100644 index 0000000000..258eed4ec6 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { listAgentUpdateStatuses, type AgentUpdateJob, type AgentUpdateStatus } from "@/lib/api"; + +type StatusByAgent = Record; + +function indexStatuses(statuses: AgentUpdateStatus[]): StatusByAgent { + return Object.fromEntries(statuses.map((status) => [status.agent_name, status])); +} + +export function useAgentRuntimeUpdateStatuses(updateJobs: Record) { + const [statusByAgent, setStatusByAgent] = useState({}); + const observedSuccessfulJobs = useRef(new Set()); + const pendingSuccessfulJobs = useRef(new Set()); + + const refresh = useCallback(async (): Promise => { + try { + const response = await listAgentUpdateStatuses({ cache: "no-store" }); + setStatusByAgent(indexStatuses(response.statuses)); + return true; + } catch { + // A status hint is advisory. Keep the last good map so a transient + // registry failure does not remove an already visible hint or disable + // the authoritative update control. + return false; + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + for (const job of Object.values(updateJobs)) { + if (job.status !== "succeeded" || observedSuccessfulJobs.current.has(job.job_id)) { + continue; + } + if (pendingSuccessfulJobs.current.has(job.job_id)) { + continue; + } + pendingSuccessfulJobs.current.add(job.job_id); + void refresh().then((succeeded) => { + pendingSuccessfulJobs.current.delete(job.job_id); + if (succeeded) { + observedSuccessfulJobs.current.add(job.job_id); + } + }); + } + }, [refresh, updateJobs]); + + return { refresh, statusByAgent }; +} diff --git a/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts b/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts index 7a523ab2e2..4d25c13990 100644 --- a/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts +++ b/apps/web/hooks/domains/settings/use-agent-runtime-updates.ts @@ -7,7 +7,9 @@ import { getAgentUpdateJob, getInstallJob, previewAgentUpdate, + previewAgentUpdateUseDefault, updateAgent, + updateAgentUseDefault, type AgentUpdateJob, } from "@/lib/api"; import { ApiError } from "@/lib/api/client"; @@ -52,9 +54,11 @@ export function useAgentRuntimeUpdates() { ); const startUpdate = useCallback( - async (agentName: string, targetVersion: string) => { + async (agentName: string, targetVersion: string, useDefault = false) => { try { - const job = await updateAgent(agentName, targetVersion); + const job = useDefault + ? await updateAgentUseDefault(agentName) + : await updateAgent(agentName, targetVersion); store.getState().upsertAgentUpdateJob(job); return job; } catch (error) { @@ -69,8 +73,10 @@ export function useAgentRuntimeUpdates() { ); const previewUpdate = useCallback( - (agentName: string, targetVersion?: string) => - previewAgentUpdate(agentName, targetVersion, { cache: "no-store" }), + (agentName: string, targetVersion?: string, useDefault = false) => + useDefault + ? previewAgentUpdateUseDefault(agentName, { cache: "no-store" }) + : previewAgentUpdate(agentName, targetVersion, { cache: "no-store" }), [], ); diff --git a/apps/web/lib/agent-runtime-update.test.ts b/apps/web/lib/agent-runtime-update.test.ts index 5c2d221af0..0ea5c5682a 100644 --- a/apps/web/lib/agent-runtime-update.test.ts +++ b/apps/web/lib/agent-runtime-update.test.ts @@ -5,19 +5,20 @@ import { canApproveAgentRuntimeUpdate, latestRuntimeVersions, resolveRuntimeActiveVersion, + resolveRuntimeEffectiveVersion, resolveRuntimeOperation, resolveRuntimeVersionPair, runtimeOperationLabelKey, } from "./agent-runtime-update"; describe("latestRuntimeVersions", () => { - it("limits the selector to the ten newest options", () => { + it("preserves the complete backend projection", () => { const versions = Array.from({ length: 12 }, (_, index) => ({ version: `1.0.${12 - index}`, latest: index === 0, })); - expect(latestRuntimeVersions(versions)).toEqual(versions.slice(0, 10)); + expect(latestRuntimeVersions(versions)).toEqual(versions); }); }); @@ -82,6 +83,30 @@ describe("resolveRuntimeActiveVersion", () => { ), ).toBe("0.62.0"); }); + + it("clears the preview selection after a successful default reset", () => { + expect( + resolveRuntimeActiveVersion( + preview({ active_version: "0.62.0" }), + job({ + status: "succeeded", + operation: "use_default", + active_version: undefined, + }), + ), + ).toBeUndefined(); + }); +}); + +describe("resolveRuntimeEffectiveVersion", () => { + it("prefers the terminal job effective version", () => { + expect( + resolveRuntimeEffectiveVersion( + preview({ effective_version: "0.62.0" }), + job({ status: "succeeded", effective_version: "0.64.0" }), + ), + ).toBe("0.64.0"); + }); }); describe("canApproveAgentRuntimeUpdate", () => { diff --git a/apps/web/lib/agent-runtime-update.ts b/apps/web/lib/agent-runtime-update.ts index 393b40ccbf..3792a230e1 100644 --- a/apps/web/lib/agent-runtime-update.ts +++ b/apps/web/lib/agent-runtime-update.ts @@ -6,10 +6,8 @@ import type { } from "@/lib/api"; import { t } from "@/lib/i18n"; -const MAX_VISIBLE_RUNTIME_VERSIONS = 10; - export function latestRuntimeVersions(versions: AgentUpdateVersion[]): AgentUpdateVersion[] { - return versions.slice(0, MAX_VISIBLE_RUNTIME_VERSIONS); + return versions; } export type RuntimeVersionPair = { @@ -43,9 +41,24 @@ export function resolveRuntimeActiveVersion( preview: AgentUpdatePreview, job?: AgentUpdateJob, ): string | undefined { + if (job?.status === "succeeded" && job.operation === "use_default" && !job.active_version) { + return undefined; + } return job?.active_version ?? preview.active_version; } +export function resolveRuntimeEffectiveVersion( + preview: AgentUpdatePreview, + job?: AgentUpdateJob, +): string { + return ( + job?.effective_version ?? + preview.effective_version ?? + resolveRuntimeActiveVersion(preview, job) ?? + resolveRuntimeVersionPair(preview, job).currentVersion + ); +} + export function resolveRuntimeOperation( preview: AgentUpdatePreview | null, job?: AgentUpdateJob, @@ -61,6 +74,8 @@ export function runtimeOperationLabelKey(operation: AgentUpdateOperation | undef return "agents:repairRuntime"; case "up_to_date": return "agents:upToDateRuntime"; + case "use_default": + return "agents:useKandevDefault"; case "update": default: return "agents:updateRuntime"; diff --git a/apps/web/lib/api/domains/agent-update-api.test.ts b/apps/web/lib/api/domains/agent-update-api.test.ts index 07fd094a1c..786e754b99 100644 --- a/apps/web/lib/api/domains/agent-update-api.test.ts +++ b/apps/web/lib/api/domains/agent-update-api.test.ts @@ -4,18 +4,27 @@ vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), })); -import { previewAgentUpdate, updateAgent } from "./agent-update-api"; +import { + listAgentUpdateStatuses, + previewAgentUpdate, + previewAgentUpdateUseDefault, + updateAgent, + updateAgentUseDefault, +} from "./agent-update-api"; const fetchSpy = vi.fn(); +const API_BASE_URL = "http://api.test"; +const AGENT_NAME = "opencode-acp"; +const TARGET_VERSION = "1.18.5"; beforeEach(() => { fetchSpy.mockReset(); fetchSpy.mockResolvedValue( new Response( JSON.stringify({ - agent_name: "opencode-acp", + agent_name: AGENT_NAME, package: "opencode-ai", - target_version: "1.18.5", + target_version: TARGET_VERSION, operation: "rollback", available_versions: [], command: [], @@ -31,20 +40,46 @@ afterEach(() => vi.unstubAllGlobals()); describe("managed runtime update API", () => { it("encodes an optional exact target in the preview query", async () => { - await previewAgentUpdate("opencode-acp", "1.18.5", { cache: "no-store" }); + await previewAgentUpdate(AGENT_NAME, TARGET_VERSION, { cache: "no-store" }); const [input, init] = fetchSpy.mock.calls[0] ?? []; expect(String(input)).toBe( - "http://api.test/api/v1/agent-update/opencode-acp/preview?target_version=1.18.5", + `${API_BASE_URL}/api/v1/agent-update/${AGENT_NAME}/preview?target_version=${TARGET_VERSION}`, ); expect(init?.cache).toBe("no-store"); }); it("sends only the exact target version in the update body", async () => { - await updateAgent("opencode-acp", "1.18.5"); + await updateAgent(AGENT_NAME, TARGET_VERSION); const [, init] = fetchSpy.mock.calls[0] ?? []; expect(init?.method).toBe("POST"); - expect(init?.body).toBe(JSON.stringify({ target_version: "1.18.5" })); + expect(init?.body).toBe(JSON.stringify({ target_version: TARGET_VERSION })); + }); + + it("previews returning to the Kandev default with a structural query", async () => { + await previewAgentUpdateUseDefault(AGENT_NAME, { cache: "no-store" }); + + const [input, init] = fetchSpy.mock.calls[0] ?? []; + expect(String(input)).toBe( + `${API_BASE_URL}/api/v1/agent-update/${AGENT_NAME}/preview?use_default=true`, + ); + expect(init?.cache).toBe("no-store"); + }); + + it("sends a structural use_default update request", async () => { + await updateAgentUseDefault(AGENT_NAME); + + const [, init] = fetchSpy.mock.calls[0] ?? []; + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ use_default: true })); + }); + + it("loads the read-only batch status endpoint without mutation", async () => { + await listAgentUpdateStatuses({ cache: "no-store" }); + + const [input, init] = fetchSpy.mock.calls[0] ?? []; + expect(String(input)).toBe(`${API_BASE_URL}/api/v1/agent-update/status`); + expect(init?.cache).toBe("no-store"); }); }); diff --git a/apps/web/lib/api/domains/agent-update-api.ts b/apps/web/lib/api/domains/agent-update-api.ts index 1760b138d9..b87e515991 100644 --- a/apps/web/lib/api/domains/agent-update-api.ts +++ b/apps/web/lib/api/domains/agent-update-api.ts @@ -8,7 +8,9 @@ export type AgentUpdateJobStatus = | "succeeded" | "failed"; -export type AgentUpdateOperation = "update" | "rollback" | "repair" | "up_to_date"; +export type AgentUpdateOperation = "update" | "rollback" | "repair" | "up_to_date" | "use_default"; + +export type AgentUpdateCheckState = "update_available" | "up_to_date" | "unknown"; export type AgentUpdateVersion = { version: string; @@ -21,7 +23,9 @@ export type AgentUpdateJob = { status: AgentUpdateJobStatus; operation?: AgentUpdateOperation; current_version?: string; + default_version?: string; active_version?: string; + effective_version?: string; target_version?: string; output?: string; error?: string; @@ -34,7 +38,9 @@ export type AgentUpdatePreview = { agent_name: string; package: string; current_version?: string; + default_version?: string; active_version?: string; + effective_version?: string; target_version: string; operation?: AgentUpdateOperation; available_versions?: AgentUpdateVersion[]; @@ -54,6 +60,16 @@ export async function previewAgentUpdate( ); } +export async function previewAgentUpdateUseDefault( + agentName: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson( + `/api/v1/agent-update/${encodeURIComponent(agentName)}/preview?use_default=true`, + options, + ); +} + export async function updateAgent( agentName: string, targetVersion: string, @@ -69,6 +85,37 @@ export async function updateAgent( }); } +export async function updateAgentUseDefault( + agentName: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`/api/v1/agent-update/${encodeURIComponent(agentName)}`, { + ...options, + init: { + method: "POST", + body: JSON.stringify({ use_default: true }), + ...(options?.init ?? {}), + }, + }); +} + +export type AgentUpdateStatus = { + agent_name: string; + package: string; + default_version: string; + active_version?: string; + effective_version: string; + latest_version?: string; + checked_at?: string; + check_state: AgentUpdateCheckState; +}; + +export async function listAgentUpdateStatuses( + options?: ApiRequestOptions, +): Promise<{ statuses: AgentUpdateStatus[] }> { + return fetchJson<{ statuses: AgentUpdateStatus[] }>("/api/v1/agent-update/status", options); +} + export async function listAgentUpdateJobs( options?: ApiRequestOptions, ): Promise<{ jobs: AgentUpdateJob[] }> { diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 0aff09ae00..56aa5bb523 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -231,7 +231,9 @@ export type RuntimeUpdate = { supported: boolean; package: string; current_version?: string; + default_version?: string; active_version?: string; + effective_version?: string; }; export type AvailableAgent = { diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 0491b3b62b..222fcaaa71 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -185,16 +185,27 @@ "runtimeUpdateSessionsNote": "Active sessions keep running; only later probes and launches use the refreshed runtime.", "runtimeUpdatedRefreshFailed": "Runtime updated, but capabilities could not be refreshed: {{error}}", "runtimeUpdatedSuccess": "Runtime updated successfully.", + "runtimeVersionQuickChoices": "Quick choices", + "browseRuntimeVersions": "Browse all versions", + "searchRuntimeVersions": "Search versions", + "noMatchingRuntimeVersions": "No versions match your search.", "activeRuntimeVersion": "Active version: {{version}}", "activeRuntimeVersionMarker": "active", + "effectiveRuntimeVersion": "Effective version: {{version}}", + "kandevDefaultVersion": "Kandev default: {{version}}", + "kandevDefaultRuntimeVersionMarker": "Kandev default", "latestRuntimeVersion": "latest", + "runtimeUpdateStatusUnknown": "Latest version for {{name}} is unavailable. The update control remains usable.", "runtimeVersionMarkerGroup": "({{markers}})", "runtimeVersionOption": "{{version}} {{markers}}", "selectRuntimeVersion": "Version to activate", + "updateAvailableWithVersions": "Update {{name}} from {{current}} to {{latest}}", "updateRuntime": "Update runtime", "rollBackRuntime": "Roll back runtime", "repairRuntime": "Repair runtime", "upToDateRuntime": "Up to date", + "useKandevDefault": "Use Kandev default", + "useKandevDefaultVersion": "Use Kandev default ({{version}})", "scanningForInstalledAgents": "Scanning for installed agents...", "searchModels": "Search models...", "searchModes": "Search modes...", diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index b770254d65..b24a7821f5 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -185,16 +185,27 @@ "runtimeUpdateSessionsNote": "Àćţĩvē śēśśĩōńś ķēēƥ ŕũńńĩńĝ; ōńĺŷ ĺàţēŕ ƥŕōƀēś àńď ĺàũńćĥēś ũśē ţĥē ŕēƒŕēśĥēď ŕũńţĩḿē.", "runtimeUpdatedRefreshFailed": "Ŕũńţĩḿē ũƥďàţēď, ƀũţ ćàƥàƀĩĺĩţĩēś ćōũĺď ńōţ ƀē ŕēƒŕēśĥēď: {{error}}", "runtimeUpdatedSuccess": "Ŕũńţĩḿē ũƥďàţēď śũććēśśƒũĺĺŷ.", + "runtimeVersionQuickChoices": "Qũĩćķ ćĥōĩćēś", + "browseRuntimeVersions": "Ɓŕōŵśē àĺĺ vēŕśĩōńś", + "searchRuntimeVersions": "Śēàŕćĥ vēŕśĩōńś", + "noMatchingRuntimeVersions": "Ńō vēŕśĩōńś ḿàţćĥ ŷōũŕ śēàŕćĥ.", "activeRuntimeVersion": "Àćţĩvē vēŕśĩōń: {{version}}", "activeRuntimeVersionMarker": "àćţĩvē", + "effectiveRuntimeVersion": "Ēƒƒēćţĩvē vēŕśĩōń: {{version}}", + "kandevDefaultVersion": "Ķàńďēv ďēƒàũĺţ: {{version}}", + "kandevDefaultRuntimeVersionMarker": "Ķàńďēv ďēƒàũĺţ", "latestRuntimeVersion": "ĺàţēśţ", + "runtimeUpdateStatusUnknown": "Ĺàţēśţ vēŕśĩōń ƒōŕ {{name}} ĩś ũńàvàĩĺàƀĺē. Ţĥē ũƥďàţē ćōńţŕōĺ ŕēḿàĩńś ũśàƀĺē.", "runtimeVersionMarkerGroup": "({{markers}})", "runtimeVersionOption": "{{version}} {{markers}}", "selectRuntimeVersion": "Vēŕśĩōń ţō àćţĩvàţē", + "updateAvailableWithVersions": "Ũƥďàţē {{name}} ƒŕōḿ {{current}} ţō {{latest}}", "updateRuntime": "Ũƥďàţē ŕũńţĩḿē", "rollBackRuntime": "Ŕōĺĺ ƀàćķ ŕũńţĩḿē", "repairRuntime": "Ŕēƥàĩŕ ŕũńţĩḿē", "upToDateRuntime": "Ũƥ ţō ďàţē", + "useKandevDefault": "Ũśē Ķàńďēv ďēƒàũĺţ", + "useKandevDefaultVersion": "Ũśē Ķàńďēv ďēƒàũĺţ ({{version}})", "scanningForInstalledAgents": "Śćàńńĩńĝ ƒōŕ ĩńśţàĺĺēď àĝēńţś...", "searchModels": "Śēàŕćĥ ḿōďēĺś...", "searchModes": "Śēàŕćĥ ḿōďēś...", diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index 84a58f2e4b..3cb07df1bc 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -175,16 +175,27 @@ "runtimeUpdateSessionsNote": "As sessões ativas continuam a correr; só as sondagens e os arranques posteriores usam o runtime atualizado.", "runtimeUpdatedRefreshFailed": "O runtime foi atualizado, mas não foi possível atualizar as capacidades: {{error}}", "runtimeUpdatedSuccess": "Runtime atualizado com êxito.", + "runtimeVersionQuickChoices": "Escolhas rápidas", + "browseRuntimeVersions": "Procurar todas as versões", + "searchRuntimeVersions": "Pesquisar versões", + "noMatchingRuntimeVersions": "Nenhuma versão corresponde à pesquisa.", "activeRuntimeVersion": "Versão ativa: {{version}}", "activeRuntimeVersionMarker": "ativa", + "effectiveRuntimeVersion": "Versão efetiva: {{version}}", + "kandevDefaultVersion": "Predefinição do Kandev: {{version}}", + "kandevDefaultRuntimeVersionMarker": "predefinição do Kandev", "latestRuntimeVersion": "mais recente", + "runtimeUpdateStatusUnknown": "A versão mais recente de {{name}} não está disponível. O controlo de atualização continua utilizável.", "runtimeVersionMarkerGroup": "({{markers}})", "runtimeVersionOption": "{{version}} {{markers}}", "selectRuntimeVersion": "Versão a ativar", + "updateAvailableWithVersions": "Atualizar {{name}} de {{current}} para {{latest}}", "updateRuntime": "Atualizar runtime", "rollBackRuntime": "Reverter runtime", "repairRuntime": "Reparar runtime", "upToDateRuntime": "Atualizado", + "useKandevDefault": "Usar a predefinição do Kandev", + "useKandevDefaultVersion": "Usar a predefinição do Kandev ({{version}})", "scanningForInstalledAgents": "À procura de agentes instalados...", "searchModels": "Pesquisar modelos...", "searchModes": "Pesquisar modos...", diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index 52f5becf9a..c7a3310507 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -182,16 +182,27 @@ "runtimeUpdateSessionsNote": "活动会话会继续运行;仅后续探测与启动会使用刷新后的运行时。", "runtimeUpdatedRefreshFailed": "运行时已更新,但无法刷新能力:{{error}}", "runtimeUpdatedSuccess": "运行时更新成功。", + "runtimeVersionQuickChoices": "快速选择", + "browseRuntimeVersions": "浏览所有版本", + "searchRuntimeVersions": "搜索版本", + "noMatchingRuntimeVersions": "没有符合搜索条件的版本。", "activeRuntimeVersion": "活动版本:{{version}}", "activeRuntimeVersionMarker": "活动", + "effectiveRuntimeVersion": "生效版本:{{version}}", + "kandevDefaultVersion": "Kandev 默认版本:{{version}}", + "kandevDefaultRuntimeVersionMarker": "Kandev 默认", "latestRuntimeVersion": "最新", + "runtimeUpdateStatusUnknown": "无法获取 {{name}} 的最新版本。更新控件仍可使用。", "runtimeVersionMarkerGroup": "({{markers}})", "runtimeVersionOption": "{{version}} {{markers}}", "selectRuntimeVersion": "要激活的版本", + "updateAvailableWithVersions": "将 {{name}} 从 {{current}} 更新到 {{latest}}", "updateRuntime": "更新运行时", "rollBackRuntime": "回滚运行时", "repairRuntime": "修复运行时", "upToDateRuntime": "已是最新", + "useKandevDefault": "使用 Kandev 默认版本", + "useKandevDefaultVersion": "使用 Kandev 默认版本({{version}})", "scanningForInstalledAgents": "正在扫描已安装的智能体…", "searchModels": "搜索模型…", "searchModes": "搜索模式…", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 3234e2b4c2..7de077a4db 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -182,16 +182,27 @@ "runtimeUpdateSessionsNote": "活動工作階段會繼續執行;僅後續探測與啓動會使用刷新後的執行時。", "runtimeUpdatedRefreshFailed": "執行時已更新,但無法刷新能力:{{error}}", "runtimeUpdatedSuccess": "執行時更新成功。", + "runtimeVersionQuickChoices": "快速選擇", + "browseRuntimeVersions": "瀏覽所有版本", + "searchRuntimeVersions": "搜尋版本", + "noMatchingRuntimeVersions": "沒有符合搜尋條件的版本。", "activeRuntimeVersion": "活動版本:{{version}}", "activeRuntimeVersionMarker": "活動", + "effectiveRuntimeVersion": "生效版本:{{version}}", + "kandevDefaultVersion": "Kandev 預設版本:{{version}}", + "kandevDefaultRuntimeVersionMarker": "Kandev 預設", "latestRuntimeVersion": "最新", + "runtimeUpdateStatusUnknown": "無法擷取 {{name}} 的最新版本。更新控制項仍可使用。", "runtimeVersionMarkerGroup": "({{markers}})", "runtimeVersionOption": "{{version}} {{markers}}", - "selectRuntimeVersion": "要激活的版本", + "selectRuntimeVersion": "要啟用的版本", + "updateAvailableWithVersions": "將 {{name}} 從 {{current}} 更新到 {{latest}}", "updateRuntime": "更新執行時", "rollBackRuntime": "回滾執行時", "repairRuntime": "修復執行時", "upToDateRuntime": "已是最新", + "useKandevDefault": "使用 Kandev 預設版本", + "useKandevDefaultVersion": "使用 Kandev 預設版本({{version}})", "scanningForInstalledAgents": "正在掃描已安裝的代理程式…", "searchModels": "搜尋模型…", "searchModes": "搜尋模式…", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 3a4a65ee49..3ebce4a002 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -182,16 +182,27 @@ "runtimeUpdateSessionsNote": "活動工作階段會繼續執行;僅後續探測與啟動會使用重新整理後的執行時。", "runtimeUpdatedRefreshFailed": "執行時已更新,但無法重新整理能力:{{error}}", "runtimeUpdatedSuccess": "執行時更新成功。", + "runtimeVersionQuickChoices": "快速選擇", + "browseRuntimeVersions": "瀏覽所有版本", + "searchRuntimeVersions": "搜尋版本", + "noMatchingRuntimeVersions": "沒有符合搜尋條件的版本。", "activeRuntimeVersion": "活動版本:{{version}}", "activeRuntimeVersionMarker": "活動", + "effectiveRuntimeVersion": "生效版本:{{version}}", + "kandevDefaultVersion": "Kandev 預設版本:{{version}}", + "kandevDefaultRuntimeVersionMarker": "Kandev 預設", "latestRuntimeVersion": "最新", + "runtimeUpdateStatusUnknown": "無法擷取 {{name}} 的最新版本。更新控制項仍可使用。", "runtimeVersionMarkerGroup": "({{markers}})", "runtimeVersionOption": "{{version}} {{markers}}", "selectRuntimeVersion": "要啟用的版本", + "updateAvailableWithVersions": "將 {{name}} 從 {{current}} 更新到 {{latest}}", "updateRuntime": "更新執行時", "rollBackRuntime": "回滾執行時", "repairRuntime": "修復執行時", "upToDateRuntime": "已是最新", + "useKandevDefault": "使用 Kandev 預設版本", + "useKandevDefaultVersion": "使用 Kandev 預設版本({{version}})", "scanningForInstalledAgents": "正在掃描已安裝的代理程式…", "searchModels": "搜尋模型…", "searchModes": "搜尋模式…", diff --git a/docs/decisions/0034-agentclientprotocol-codex-acp.md b/docs/decisions/0034-agentclientprotocol-codex-acp.md index 678e08c44c..34d5bc90fe 100644 --- a/docs/decisions/0034-agentclientprotocol-codex-acp.md +++ b/docs/decisions/0034-agentclientprotocol-codex-acp.md @@ -10,11 +10,12 @@ Kandev's `codex-acp` agent previously launched `@zed-industries/codex-acp`. That ## Decision -Kandev's `codex-acp` agent launches the unversioned -`@agentclientprotocol/codex-acp` package for ACP chat and one-shot inference -sessions. Normal launches prefer npm's execution cache. The install script -still installs `@openai/codex` for `codex login`; that native authentication -helper is separate from the managed ACP runtime. +Kandev's `codex-acp` agent launches the exact effective version of +`@agentclientprotocol/codex-acp` for ACP chat and one-shot inference sessions. +The reviewed Kandev default is currently `1.6.0`; an install-wide operator +selection can override it. Normal launches prefer npm's execution cache. The +install script still installs `@openai/codex` for `codex login`; that native +authentication helper is separate from the managed ACP runtime. The host operator can deliberately refresh the ACP package through **Settings > Agents > Update agent**. Kandev then re-probes the bridge and @@ -29,10 +30,11 @@ Codex ACP sessions receive the model catalogue and config options from the Agent Codex-specific ACP `cli_flags` are not advertised because the bridge entrypoint does not apply the native Codex `-c` config overrides to chat sessions. Kandev still exposes the universal agentctl auto-approve toggle for ACP permission requests and keeps native Codex flags scoped to passthrough mode. -Kandev source no longer determines the exact bridge release. Incident -diagnosis uses the version reported during ACP initialization. Compatibility -is enforced through ACP protocol negotiation and advertised capabilities, so -an upstream same-protocol regression can still require operator intervention. +Kandev source supplies a reviewed default and records any operator-selected +exact version. Incident diagnosis also uses the version reported during ACP +initialization. Compatibility is enforced through ACP protocol negotiation and +advertised capabilities, so an upstream same-protocol regression can still +require operator intervention. ## Alternatives Considered diff --git a/docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md b/docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md index 7d23a003a1..32b492306d 100644 --- a/docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md +++ b/docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md @@ -1,8 +1,8 @@ # ADR-2026-08-12-validated-managed-runtime-version-selection: Validate and Persist Managed Runtime Version Selection -**Status:** accepted (amended 2026-08-16) +**Status:** accepted (amended 2026-08-21) **Date:** 2026-08-12 -**Area:** backend, frontend, protocol +**Area:** backend, frontend, protocol, workflow **Supersedes:** [ADR-2026-07-26-user-managed-agent-runtime-updates](2026-07-26-user-managed-agent-runtime-updates.md) @@ -21,6 +21,14 @@ identity, registry location, and ACP arguments are trusted Kandev metadata. Users need to choose among valid versions of that package, validate the candidate, and keep a known-good version across Kandev restarts. +Persisting a selection only after a manual update still leaves every fresh +installation and every unselected agent on an unversioned `npx ` +command. Removing npm from the launch path would require Kandev-owned or +user-installed runtime artifacts and is not a requirement for this iteration. +The immediate invariant is narrower: every managed npm ACP command names an +exact top-level package version, while npm remains responsible for cache and +network behavior. + ## Decision Kandev lets an operator select an exact stable version for a built-in managed @@ -38,12 +46,18 @@ the candidate capabilities. This ordering makes the persisted active version the last known good version; automatic silent rollback is unnecessary. -Future standalone host probes, utility calls, and local agent sessions resolve -the persisted exact version. Existing sessions keep their current process. -SSH, container, and other remote runtimes do not inherit the host selection and -continue to resolve their own runtime. When no selection record exists, Kandev -retains the legacy unversioned host behavior until the first candidate is -successfully activated. +Each trusted managed package has an exact default version shipped in Kandev. +Command resolution uses the persisted operator selection when its package +identity still matches; otherwise it uses the shipped default. Kandev does not +copy the default into the database. An installation with no explicit selection +therefore follows newer defaults when Kandev upgrades, while an operator +selection remains stable until the operator changes or clears it. + +The resulting effective version applies to every Kandev-built ACP command for +that managed package, including capability probes, utility prompts, local +sessions, containers, and SSH executors. Existing sessions keep their current +process. Native binaries, passthrough commands, and separately distributed +authentication helpers remain outside this package selection. The managed package and exact-version override belong only to an agent's ACP command surfaces. They do not replace `PassthroughConfig.PassthroughCmd`, an @@ -53,25 +67,52 @@ and a separately distributed native CLI for terminal passthrough. The selection is install-wide and per built-in agent. A saved selection applies only while its stored package identity matches the agent's current trusted -package metadata; a package change starts with no selection and requires fresh -validation. The selection survives backend and browser restarts independently -of npm's best-effort cache. If reading a saved -selection fails, Kandev fails the new host-managed launch or probe visibly -instead of bypassing the selection with an unversioned package. If npm evicts -the selected version's cache, the exact version is prepared again when needed. +package metadata; a package change falls back to the replacement package's +shipped default. The selection survives backend and browser restarts +independently of npm's best-effort cache. If reading a saved selection fails, +Kandev fails the new managed launch or probe visibly instead of bypassing the +selection. If npm evicts the effective version's cache, npm may prepare that +same exact version again when needed. + +Settings checks the registry for each managed package when the Agents page asks +for update status. The backend caches successful results for a bounded period, +returns an explicit unknown state when the registry is unavailable, and never +updates a runtime from this read-only check. The UI marks an available newer +stable version on the existing update control. Opening the control performs the +authoritative preview and retains the validated prepare, probe, and activation +flow. + +Normal launches retain `--prefer-offline` because an exact package version +removes version-tag drift while preserving npm cache reuse. Explicit previews, +manual updates, and the bounded stale-metadata retry use online-preferred +metadata. Kandev accepts that a cold or incomplete npm cache can still place the +registry on the launch path. + +A weekly repository workflow compares the shipped defaults with each trusted +package's stable npm `latest` tag and opens a reviewable PR for changes. It does +not merge automatically. The same centralized version catalogue drives command +construction, tests, and the workflow so runtime pins cannot drift across +separate constants. ## Consequences Operators can recover from a broken latest release in Settings without shell -access or a Kandev restart. Runtime changes become attributable: after the -first activation, host launches use the selected exact version until another -candidate passes validation. +access or a Kandev restart. Runtime changes are attributable: every managed npm +launch uses either a reviewed Kandev default or an explicit validated operator +selection. The backend now owns a small durable selection record and must route it through -every host-local managed-runtime command path. Version catalogue lookup remains -dependent on registry availability, and cached versions are not a Kandev-owned -artifact inventory. A version that has disappeared from the registry can still -fail to reinstall after npm cache eviction. +every managed-runtime command path. It also owns a bounded, process-local update +status cache. Version catalogue lookup remains dependent on registry +availability, and cached versions are not a Kandev-owned artifact inventory. A +version that has disappeared from the registry can still fail to reinstall +after npm cache eviction. + +An exact top-level package pin does not lock transitive dependency ranges. npm +process startup, cold-cache downloads, registry failures, and cache eviction +remain accepted costs. The weekly PR requires maintenance and compatibility +review, while the Settings override lets operators move ahead of a Kandev +release when necessary. The ACP probe is the activation boundary, not an assertion that every provider, model, or future prompt will succeed. Authentication-required or failed probes @@ -86,11 +127,22 @@ capabilities. Settings request execute an arbitrary npm package. - Automatic fallback after a failed launch was rejected because it hides the version actually used and can make separate launches behave differently. +- Persisting the shipped default as an operator selection was rejected because + it would prevent existing unmodified installations from inheriting a newer + reviewed default in a later Kandev release. +- Removing `--prefer-offline` was rejected because exact package specs already + prevent top-level version drift, while online-preferred metadata on every + launch adds registry checks and latency. Explicit update paths remain the + freshness boundary. +- Automatically installing npm `latest` after an update check was rejected. + The checker is informational, and every version change remains an explicit + operator action or a reviewed Kandev pin PR. - A Kandev-owned package directory and lockfile were rejected for this iteration. Exact npm package specs and version-specific execution keys provide transactional selection without reimplementing package storage. -- Applying the host selection to remote executors was rejected because each - remote runtime has separate platform, cache, credentials, and ownership. +- User-installed bridge discovery was rejected for this iteration because it + would require path ownership, compatibility discovery, and a separate update + contract. npm remains the supported bridge distribution mechanism. - Reusing the managed ACP package for terminal passthrough was rejected because an ACP JSON-RPC stdio adapter is not an interactive PTY application, and some integrations distribute those surfaces as different packages and binaries. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index d50b4b368e..cdfcfa8efb 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -84,7 +84,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-24-opt-in-authentication | [Opt-in Authentication and Per-User Workspace Scoping](2026-07-24-opt-in-authentication.md) | accepted | backend, frontend, protocol, security | 2026-07-24 | | 2026-07-24-semantic-notification-events | [Semantic notification events come from domain occurrences](2026-07-24-semantic-notification-events.md) | accepted | backend, frontend, desktop | 2026-07-24 | | 2026-07-26-user-managed-agent-runtime-updates | [User-Managed Agent Runtime Updates](2026-07-26-user-managed-agent-runtime-updates.md) | superseded by 2026-08-12-validated-managed-runtime-version-selection | backend, frontend, protocol | 2026-07-26 | -| 2026-08-12-validated-managed-runtime-version-selection | [Validate and Persist Managed Runtime Version Selection](2026-08-12-validated-managed-runtime-version-selection.md) | accepted | backend, frontend, protocol | 2026-08-12 | +| 2026-08-12-validated-managed-runtime-version-selection | [Validate and Persist Managed Runtime Version Selection](2026-08-12-validated-managed-runtime-version-selection.md) | accepted (amended 2026-08-21) | backend, frontend, protocol, workflow | 2026-08-12 | | 2026-07-26-single-session-model-switching | [Single-Session Model Switching](2026-07-26-single-session-model-switching.md) | accepted (amended 2026-07-27) | workflow, infra | 2026-07-26 | | 2026-07-27-task-git-credential-policy | [Separate GitHub Automation From Task Git Credential Policy](2026-07-27-task-git-credential-policy.md) | accepted | backend, frontend, security | 2026-07-27 | | 2026-07-27-spa-failure-containment-and-deployment-recovery | [SPA Failure Containment and Deployment Recovery](2026-07-27-spa-failure-containment-and-deployment-recovery.md) | accepted | frontend | 2026-07-27 | diff --git a/docs/plans/managed-runtime-npm-metadata-compat/plan.md b/docs/plans/managed-runtime-npm-metadata-compat/plan.md new file mode 100644 index 0000000000..ab7b85028e --- /dev/null +++ b/docs/plans/managed-runtime-npm-metadata-compat/plan.md @@ -0,0 +1,61 @@ +--- +spec: docs/specs/agents/runtime-updates.md +created: 2026-08-22 +status: complete +--- + +# Implementation Plan: Managed Runtime npm Metadata Compatibility + +## Overview + +Normalize the version metadata returned by supported npm CLI versions before +building the managed-runtime catalogue. npm 12 wraps the result of a +multi-field `npm view` query in a one-element array, while earlier npm versions +return the object directly. The backend must accept both valid forms and keep +rejecting malformed or ambiguous metadata. + +## Backend + +### Version metadata resolver + +- Update `apps/backend/internal/agent/settings/controller/agent_update.go` + alongside `hostRuntimeUpdater.ResolveVersions` to accept the existing object + response and npm 12's one-element array response. +- Normalize both forms to the existing `RuntimeVersionMetadata` contract. An + empty or multi-entry array remains an error and cannot produce a catalogue. +- Preserve the existing exact npm argv, stable-version filtering, latest-tag + validation, and preview error behavior. + +## Tests + +- **What:** A valid npm 12-shaped one-element array resolves to the same + version catalogue and latest value as the object form. + **File:** `apps/backend/internal/agent/settings/controller/agent_update_test.go`. + **How:** Use the existing recording command executor and assert the returned + metadata plus the exact direct npm argv. +- **What:** Existing preview and malformed-metadata behavior remains covered. + **File:** `apps/backend/internal/agent/settings/controller/agent_update_test.go` + and `apps/backend/internal/agent/settings/handlers/discovery_handlers_test.go`. + **How:** Run the focused controller and handler packages after the regression + test passes. + +## Verification Results + +- `cd apps/backend && go test -run 'TestHostRuntimeUpdaterResolves|TestHostRuntimeUpdaterRejectsAmbiguousNPMRuntimeMetadata' ./internal/agent/settings/controller`: 7 tests passed. +- `cd apps/backend && go test ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed. +- `cd apps/backend && go test -race ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed. +- `cd apps/backend && golangci-lint run ./... --new-from-rev="032ea05bc8997028b1690f5f351939c83a6f77c2" --timeout=5m`: no issues. +- `git diff --check`: passed. No temporary diagnostic state was created. + +## Implementation Waves And Parallel Candidates + +Wave 1, sequential: + +- [x] [Task 01: Normalize npm version metadata](task-01-normalize-npm-version-metadata.md) + +## Risks + +- npm output is external-process data. The parser must not accept multiple + metadata records or change the trusted package, registry, or command shape. +- This repair changes only catalogue resolution; it does not alter runtime + selection, cache invalidation, or update activation. diff --git a/docs/plans/managed-runtime-npm-metadata-compat/task-01-normalize-npm-version-metadata.md b/docs/plans/managed-runtime-npm-metadata-compat/task-01-normalize-npm-version-metadata.md new file mode 100644 index 0000000000..b64472f80d --- /dev/null +++ b/docs/plans/managed-runtime-npm-metadata-compat/task-01-normalize-npm-version-metadata.md @@ -0,0 +1,72 @@ +--- +id: "01-normalize-npm-version-metadata" +title: "Normalize npm version metadata" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 01: Normalize npm version metadata + +The preview currently fails on a Sprite because npm 12 returns a one-element +array for `npm view versions dist-tags --json`, while the resolver +expects an object. Implement the smallest parser change after proving the +failure with a regression test. + +## Acceptance + +- Object-shaped npm metadata continues to resolve unchanged. +- npm 12's one-element array-shaped metadata resolves to a stable catalogue + and latest version. +- Empty, multi-entry, malformed, and missing-latest metadata still fails + closed through the existing preview error path. + +## Verification + +```bash +cd apps/backend && go test -run 'TestHostRuntimeUpdaterResolves|TestPreviewAgentUpdate' ./internal/agent/settings/controller ./internal/agent/settings/handlers +``` + +## Files likely touched + +- `apps/backend/internal/agent/settings/controller/agent_update.go` +- `apps/backend/internal/agent/settings/controller/agent_update_test.go` +- `docs/specs/agents/runtime-updates.md` +- `docs/plans/managed-runtime-npm-metadata-compat/plan.md` +- `docs/plans/managed-runtime-npm-metadata-compat/task-01-normalize-npm-version-metadata.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. + +## Inputs + +- The managed runtime preview contract and the npm metadata compatibility + scenario in `docs/specs/agents/runtime-updates.md`. +- The confirmed Sprite reproduction: npm `12.0.2` returns a top-level array + for the exact multi-field query. + +## Output contract + +Update the parser and regression coverage, record exact test results in this +task and `plan.md`, and report changed files, risks, and any remaining +environment-specific unknowns. + +## Results + +- Added a dedicated resolver regression test for npm 12's one-element array + response and rejection coverage for empty, multi-entry, and malformed + metadata. +- Updated the resolver to normalize object and one-element array payloads while + preserving the existing exact npm argv and latest-version validation. +- `cd apps/backend && go test -run 'TestHostRuntimeUpdaterResolves|TestHostRuntimeUpdaterRejectsAmbiguousNPMRuntimeMetadata' ./internal/agent/settings/controller`: 7 tests passed. +- `cd apps/backend && go test ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed. +- `cd apps/backend && go test -race ./internal/agent/settings/controller ./internal/agent/settings/handlers`: 350 tests passed. +- `cd apps/backend && golangci-lint run ./... --new-from-rev="032ea05bc8997028b1690f5f351939c83a6f77c2" --timeout=5m`: no issues. +- `git diff --check`: passed. No temporary diagnostic state was created. diff --git a/docs/plans/managed-runtime-version-awareness/plan.md b/docs/plans/managed-runtime-version-awareness/plan.md new file mode 100644 index 0000000000..6d495bfdca --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/plan.md @@ -0,0 +1,284 @@ +--- +spec: docs/specs/agents/runtime-updates.md +decision: docs/decisions/2026-08-12-validated-managed-runtime-version-selection.md +created: 2026-08-21 +status: complete +--- + +# Implementation Plan: Managed Runtime Version Awareness + +## Overview + +Replace every unversioned managed npm ACP package invocation with an exact +effective version. The effective version is an install-wide validated operator +selection when present and a reviewed Kandev default otherwise. Build on the +existing version dialog and selection store, add a cached read-only update +status API and accessible blue-dot indicator, and add weekly automation that +opens reviewable pin-update PRs without changing runtimes automatically. + +--- + +## Backend + +### Central managed-runtime pins + +- Add one machine-readable version catalogue at + `apps/backend/internal/agent/agents/managed_npm_runtime_versions.json` for + Claude, Codex, OpenCode, Copilot, and Gemini. Package identity remains in the + trusted agent metadata; the catalogue maps only those package names to exact + stable SemVer defaults. +- Embed and validate the catalogue from a focused Go helper beside + `managed_npm_runtime.go`. Startup must fail clearly if a trusted managed + package has no valid default; no command may fall back to an unversioned + package. +- Add `DefaultVersion` to `ManagedNPMRuntimeSpec`. `PackageSpec("")`, + `CachedACPCommand()`, runtime configs, inference configs, and cache keys use + the default. A non-empty validated override continues to produce + `package@override`. +- Keep launch commands on `npx --yes --prefer-offline`. Candidate preparation + and the existing bounded stale-metadata retry remain online-preferred. + +### Effective version and operator selection + +- Define one effective-version resolver: read + `managed_runtime.active.` from the install-wide settings store, + require the saved package to match trusted metadata, and otherwise return the + built-in default. +- Route the effective version through every centrally built managed ACP command, + including host utility probes, command previews, standalone sessions, + containers, and SSH executors. Native-binary preference, passthrough commands, + and separately distributed login helpers remain unchanged. +- Extend `managedruntime.SelectionStore` with deletion. The version job accepts + a trusted `use_default` request, stages and ACP-probes the built-in default, + then deletes the selection only after success. Candidate failure retains the + prior selection and capabilities. +- Extend runtime catalogue, preview, and job DTOs with `default_version` and + `effective_version`. Preserve `active_version` as the optional persisted + operator selection. Add structural `use_default` operation copy; the browser + does not infer operations from translated labels or version strings. + +### Cached update status API + +- Add `GET /api/v1/agent-update/status`. It returns one entry per available + managed runtime with package, default, optional active selection, effective + version, optional latest version, `checked_at`, and `check_state`. +- Implement a process-local, mutex-protected per-package cache in the agent + settings controller. Successful npm `latest` results live for six hours; + failed lookups produce `unknown` for fifteen minutes. Use injected clock and + resolver seams in tests and bound concurrent stale lookups to the five trusted + packages. +- Compare strict stable SemVer in the backend. Return `update_available` only + when latest is newer than effective, `up_to_date` otherwise, and `unknown` + when metadata is unavailable or invalid. One failed package does not fail the + batch. +- Invalidate the affected cache entry after a successful version activation or + return-to-default job. The dialog preview remains an uncached authoritative + catalogue lookup. + +--- + +## Frontend + +### API and page-local status hook + +- Add typed batch-status contracts to + `apps/web/lib/api/domains/agent-update-api.ts` and the existing API barrels. +- Add a settings-domain hook that requests status when Settings > Agents mounts, + after Rescan, and after a successful version job. Keep status page-local; the + existing WebSocket-backed update-job store remains authoritative for active + mutations. +- Treat `unknown` as no indicator, not as up to date. Do not disable the update + trigger when the checker fails. + +### Agent-card update indicator + +- Pass the backend status for each agent through + `apps/web/app/settings/agents/page.tsx` and + `installed-agent-card.tsx` into the existing runtime update control. +- When `check_state` is `update_available`, overlay a blue dot on the existing + refresh trigger. Add effective and latest versions to its accessible name and + desktop tooltip; do not rely on color alone. +- Opening the trigger continues to fetch a live preview. The dot is a hint and + never a separate action. +- Extend the existing dialog/drawer version selector with a clearly labelled + Kandev-default option when an operator selection exists. The same state and + mutation handler serve desktop and mobile. + +### Mobile design contract + +- **Desktop outcome:** the agent-group card shows a compact update hint and + opens the existing centered version dialog. +- **Mobile entry point:** the same 44 px update trigger on the agent-group card. +- **Nearest exemplar:** the existing `AgentRuntimeUpdateControl`; retain its + inset bottom `Drawer`, fixed header/footer, safe-area padding, and single + scrolling body. +- **Hierarchy and action:** the dot signals availability; tapping the trigger + opens the authoritative version summary and update/default action. No hover + is required. +- **Geometry:** the dot does not change the trigger hitbox, add a scroll owner, + or introduce horizontal overflow. Shared status and selection logic remain + viewport-neutral. + +### Localization + +- Add copy for update availability, version interpolation, Kandev default, and + unknown checker behavior to all five required locale catalogues. +- Generate Traditional Chinese catalogues through `pnpm run i18n:zh-hant` and + run the focused i18n checks; do not hardcode labels in components. + +--- + +## Weekly Pin Update Workflow + +- Add `scripts/update-agent-runtime-pins.mjs` to read the central catalogue, + query each trusted package's npm `dist-tags.latest`, reject prerelease or + invalid SemVer values, and rewrite only changed pins. Keep parsing and rewrite + logic independently testable with fixture metadata and no network. +- Add `.github/workflows/update-agent-runtime-pins.yml` with weekly and manual + triggers, least-privilege permissions, pinned action SHAs, a stable bot + branch, and one grouped Conventional Commit PR. Use a repository-approved + bot/App token so the generated PR receives normal CI events. Do not auto-merge. +- Run the updater tests, targeted managed-runtime Go tests, action-pinning lint, + and workflow contract test before opening a PR. When no pin changes, exit + without a branch or PR mutation. +- Add a workflow contract test under `.github/scripts/` and wire it into + `lint-action-pinning.yml` so schedule, permissions, token boundary, validation, + and no-auto-merge behavior cannot silently drift. + +--- + +## Tests + +- **Exact defaults and overrides:** table-driven argv tests cover all five + packages, empty-selection fallback, explicit selection, native preference, + host utility, standalone, container, and SSH command construction. +- **Selection lifecycle:** real settings-store tests cover save, matching package, + default fallback, delete-on-success, and preservation on failed candidate + validation. +- **Update status:** controller and handler tests cover newer/equal/older latest, + partial registry failure, strict SemVer, cache TTLs, invalidation, and one + read-only batch response without starting a job. +- **Frontend status:** API and hook tests cover successful, partial-unknown, and + post-update refresh behavior. Component tests cover dot visibility, + accessible version copy, usable unknown state, and return-to-default action. +- **Workflow:** offline updater fixtures and workflow-contract tests cover + changed/no-change/prerelease/error cases and secure PR creation. + +## E2E Tests + +- **Desktop update hint:** given a newer registry version, opening Settings > + Agents shows the blue dot; opening it shows the authoritative current-to-latest + preview and starts no mutation until approval. +- **Unknown status:** given one failed package lookup, that agent has no dot but + its update dialog remains usable; another package can still show an update. +- **Post-update state:** after a successful update, the status refresh removes + the dot and the selected version survives page reload. +- **Mobile parity:** on Pixel 5, the dotted 44 px trigger opens the existing + drawer, exposes the version information without hover, keeps the action + reachable, and produces no document horizontal overflow. + +Files: + +- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts` + +--- + +## Public Documentation + +- Update `docs/public/agents-and-profiles.md` with shipped defaults, explicit + selections, the blue update hint, return-to-default behavior, and the accepted + npm/network/cache limitations. +- Update `apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md` so every + documented command uses `package@effective-version` and distinguishes normal + offline-preferred launches from online-preferred updates. +- Search public and internal docs for claims that managed packages are + unversioned or host-only and reconcile only affected sources. + +--- + +## Verification Results + +Pre-remediation baseline. Backend focused tests passed 2,614 tests across seven +packages and `make -C apps/backend lint` reported zero issues. Frontend focused +tests passed 22/22, full web lint and typecheck passed, and the i18n gate +passed. Desktop and mobile E2E passed 14/14 and 4/4. Updater, +workflow-contract, action-pinning, and `zizmor` checks passed. Public-doc tests +passed 61/61 and the validator accepted 41 pages. `git diff --check` passed. + +### Review remediation + +The review pass corrected existing-PR selection and App-token propagation in +the weekly workflow, parsed GitHub output flags before catalogue selection, +preserved structural `use_default` intent, omitted failed lookup timestamps, +and retried failed page-local status refreshes. The second remediation pass +also applied least-privilege workflow permissions, package-safe selection +projection, bounded status lookups, shared default derivation, nil-cache +initialization, and default restoration when the active version is unknown. +The follow-up review pass updates terminal `current_version` from the +successful capability probe, makes the UI prefer terminal `effective_version` +and retain the complete backend version projection, derives managed-runtime +test expectations from the catalogue, and validates those commands before the +weekly workflow commits or pushes. It also keeps the App token on the push +step and refreshes existing updater PRs by number. + +Follow-up verification passes 2,594 focused backend tests, 32 focused web +tests, full web lint/typecheck/i18n, 7 pin updater tests, 8 workflow-contract +tests, 9 action-pinning tests, the 19-workflow action linter, `zizmor`, and +desktop/mobile E2E at 15/15 and 4/4. The aggregate backend phase of `make +test` passed; its full web Vitest phase did not reach a terminal summary, so it +was interrupted after the isolated `http-git-server` suite passed 3/3. + +--- + +## Implementation Waves And Parallel Candidates + +Execution is sequential in the primary conversation unless the user explicitly +authorizes subagents after choosing the implementation model. + +Wave 1: + +- [x] [Task 01: Add reviewed default pins](task-01-default-pins.md) + +Wave 2: + +- [x] [Task 02: Resolve and persist effective versions](task-02-effective-version-selection.md) +- [x] [Task 04: Automate weekly pin PRs](task-04-weekly-pin-workflow.md) + +Task 04 is parallel-safe after Task 01 because it owns workflow and script files; +Task 02 owns runtime selection and API mutation files. + +Wave 3: + +- [x] [Task 03: Add cached update status](task-03-update-status-api.md) + +Wave 4: + +- [x] [Task 05: Show update awareness in Settings](task-05-settings-update-indicator.md) + +Wave 5: + +- [x] [Task 06: Prove desktop and mobile flows](task-06-update-awareness-e2e.md) +- [x] [Task 07: Update runtime documentation](task-07-runtime-version-docs.md) + +Task 07 is parallel-safe after Tasks 01-05 because it owns documentation only; +Task 06 owns E2E fixtures and specs. + +--- + +## Risks + +- An exact top-level npm package does not lock transitive ranges. The design + intentionally accepts npm/cache/network behavior rather than claiming full + artifact reproducibility. +- A user selection is install-wide and affects remote commands built by the + same backend, but candidate ACP validation runs on the Kandev host. A remote + platform can still reject a host-validated package and must surface its normal + launch error. +- Registry checks must not delay application boot or agent launches. They are + page-triggered, read-only, cached, and separate from the agent catalogue boot + payload. +- A bot-created PR must trigger the repository's normal required checks. The + workflow must not silently fall back to `GITHUB_TOKEN` behavior that suppresses + those PR events. diff --git a/docs/plans/managed-runtime-version-awareness/task-01-default-pins.md b/docs/plans/managed-runtime-version-awareness/task-01-default-pins.md new file mode 100644 index 0000000000..5c408a0111 --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/task-01-default-pins.md @@ -0,0 +1,75 @@ +--- +id: "01-default-pins" +title: "Add reviewed default pins" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 01: Add reviewed default pins + +## Acceptance + +- One validated machine-readable catalogue defines an exact stable default for + every built-in managed npm runtime. +- Every command built without an operator selection uses + `package@default_version`; no supported managed runtime can fall back to an + unversioned package. +- Normal launches retain `--prefer-offline`, while explicit preparation and the + bounded stale-metadata retry retain online-preferred behavior. + +## Verification + +```bash +cd apps/backend && go test ./internal/agent/agents ./internal/agent/registry ./internal/agent/runtime/lifecycle +``` + +## Files likely touched + +- `apps/backend/internal/agent/agents/managed_npm_runtime_versions.json` +- `apps/backend/internal/agent/agents/managed_npm_runtime_versions.go` +- `apps/backend/internal/agent/agents/managed_npm_runtime.go` +- `apps/backend/internal/agent/agents/agent.go` +- `apps/backend/internal/agent/agents/{claude_acp,codex_acp,opencode_acp,copilot_acp,gemini}.go` +- Focused `*_test.go` files beside the managed agents and lifecycle command builder + +## Dependencies + +None. + +## Parallelism + +Sequential. This establishes the shared catalogue and command invariant used by +all later tasks. + +## Inputs + +- Spec: managed package set, effective version semantics, command routing +- Plan: Central managed-runtime pins +- Existing pattern: `ManagedNPMRuntimeSpec.PackageSpec` and command tests + +## Output contract + +Report the selected initial pins, files changed, exact test result, absence of +unversioned managed commands, risks, and synchronized task/plan status. + +## Results + +Complete. The catalogue pins are Claude `0.70.0`, Codex `1.6.0`, OpenCode +`1.18.18`, Copilot `1.0.75`, and Gemini `0.52.0`. The embedded Go validator +rejects missing, malformed, prerelease, and non-stable defaults. Empty-version +commands now use `package@default_version`; explicit validated selections use +`package@selection`, while normal launches remain `--prefer-offline` and +preparation/recovery remain online-preferred. + +Verification: `go test ./internal/agent/agents ./internal/agent/registry +./internal/agent/runtime/lifecycle -count=1` passed as part of the current +2,646-test post-remediation scoped backend run. + +Follow-up review verification removed duplicated current-pin snapshots from +the managed-agent and lifecycle tests. Their command expectations now derive +from the embedded catalogue, so the weekly updater can change a pin without +making the generated PR fail on stale test literals. The focused catalogue and +command suite passed again as part of the 2,594-test follow-up backend run. diff --git a/docs/plans/managed-runtime-version-awareness/task-02-effective-version-selection.md b/docs/plans/managed-runtime-version-awareness/task-02-effective-version-selection.md new file mode 100644 index 0000000000..22a6ea23d5 --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/task-02-effective-version-selection.md @@ -0,0 +1,79 @@ +--- +id: "02-effective-version-selection" +title: "Resolve and persist effective versions" +status: complete +wave: 2 +depends_on: ["01-default-pins"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 02: Resolve and persist effective versions + +## Acceptance + +- Effective version resolution uses a matching validated DB selection first and + the reviewed default otherwise across host, container, and SSH managed ACP + commands. +- Runtime preview and job contracts expose default, optional active selection, + and effective version without changing trusted package identity. +- **Use Kandev default** validates the exact default and deletes the selection + only after a successful candidate probe; failures retain the prior selection. + +## Verification + +```bash +cd apps/backend && go test ./internal/agent/managedruntime ./internal/agent/hostutility ./internal/agent/runtime/lifecycle ./internal/agent/settings/controller ./internal/agent/settings/handlers +``` + +## Files likely touched + +- `apps/backend/internal/agent/managedruntime/selection.go` +- `apps/backend/internal/agent/managedruntime/selection_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go` +- `apps/backend/internal/agent/hostutility/manager.go` +- `apps/backend/internal/agent/settings/dto/dto.go` +- `apps/backend/internal/agent/settings/controller/agent_update.go` +- `apps/backend/internal/agent/settings/controller/agent_update_job.go` +- `apps/backend/internal/agent/settings/handlers/handlers.go` +- Focused controller, handler, host utility, and lifecycle tests + +## Dependencies + +Task 01. + +## Parallelism + +Parallel-safe with Task 04 after Task 01. This task owns runtime selection and +mutation contracts; Task 04 owns automation files. + +## Inputs + +- Spec: version semantics, activation lifecycle, command routing, persistence +- ADR: install-wide selection precedence and accepted npm boundary +- Existing patterns: `managedruntime.Store`, `PreviewAgentUpdate`, update jobs + +## Output contract + +Report API changes, persistence behavior, execution surfaces covered, exact +tests and results, blockers/risks, and synchronized task/plan status. + +## Results + +Complete. Matching install-wide selections now take precedence over reviewed +defaults across host utility, standalone, container, and SSH command builders. +Runtime catalogue, preview, job, and executor contracts expose default, +active, and effective versions. Structural `use_default` previews and jobs +validate and probe the exact default before deleting the stored selection; +failed candidates retain the previous selection. + +Verification: the post-remediation command `go test ./internal/agent/agents +./internal/agent/registry ./internal/agent/runtime/lifecycle +./internal/agent/managedruntime ./internal/agent/hostutility +./internal/agent/settings/controller ./internal/agent/settings/handlers -count=1` +passed 2,646 tests. + +Follow-up review verification added terminal assertions for normal update, +rollback, and return-to-default jobs. Successful capability probes now update +the terminal `current_version` before publication; the focused follow-up +backend command passed 2,594 tests. diff --git a/docs/plans/managed-runtime-version-awareness/task-03-update-status-api.md b/docs/plans/managed-runtime-version-awareness/task-03-update-status-api.md new file mode 100644 index 0000000000..0cad494730 --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/task-03-update-status-api.md @@ -0,0 +1,70 @@ +--- +id: "03-update-status-api" +title: "Add cached update status" +status: complete +wave: 3 +depends_on: ["02-effective-version-selection"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 03: Add cached update status + +## Acceptance + +- One read-only batch endpoint returns structural update status for every + available managed runtime and tolerates per-package registry failures. +- The backend performs strict stable SemVer comparison, caches successful + checks for six hours and failures for fifteen minutes, and never starts an + update job from a status request. +- Successful activation or return-to-default invalidates the affected cache; + tests use injected clock/resolver seams with no real registry dependency. + +## Verification + +```bash +cd apps/backend && go test ./internal/agent/settings/controller ./internal/agent/settings/handlers +``` + +## Files likely touched + +- `apps/backend/internal/agent/settings/dto/dto.go` +- `apps/backend/internal/agent/settings/controller/agent_update_status.go` +- `apps/backend/internal/agent/settings/controller/controller.go` +- `apps/backend/internal/agent/settings/controller/agent_update_job.go` +- `apps/backend/internal/agent/settings/handlers/handlers.go` +- `apps/backend/internal/agent/settings/controller/agent_update_status_test.go` +- `apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go` + +## Dependencies + +Task 02. + +## Parallelism + +Sequential. The checker depends on final effective-version and job-invalidation +contracts. + +## Inputs + +- Spec: Update status API, failure behavior, cache lifetimes +- Plan: Cached update status API +- Existing pattern: `RuntimeUpdater.ResolveTarget` and handler error contracts + +## Output contract + +Report the endpoint shape, cache and concurrency behavior, partial-failure +evidence, exact tests/results, risks, and synchronized task/plan status. + +## Results + +Complete. `GET /api/v1/agent-update/status` returns one structural status per +available managed runtime, compares strict stable SemVer, tolerates individual +registry failures as `unknown`, bounds stale lookups to five concurrent +packages, and does not create update jobs. Successful checks cache for six +hours, failures for fifteen minutes, and successful activation/default reset +invalidates the affected package. + +Verification: controller and handler tests passed in the post-remediation +2,646-test scoped backend run, including cache TTL, invalidation, bounded +lookups, partial failure, and read-only endpoint coverage. diff --git a/docs/plans/managed-runtime-version-awareness/task-04-weekly-pin-workflow.md b/docs/plans/managed-runtime-version-awareness/task-04-weekly-pin-workflow.md new file mode 100644 index 0000000000..457114bc53 --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/task-04-weekly-pin-workflow.md @@ -0,0 +1,74 @@ +--- +id: "04-weekly-pin-workflow" +title: "Automate weekly pin PRs" +status: complete +wave: 2 +depends_on: ["01-default-pins"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 04: Automate weekly pin PRs + +## Acceptance + +- A fixture-tested script updates only trusted catalogue entries to valid stable + npm `latest` versions and handles no-change, prerelease, malformed, and lookup + failure cases without partial writes. +- A weekly/manual least-privilege workflow runs validation and opens at most one + grouped Conventional Commit PR through a token that permits normal PR CI. +- The workflow uses pinned actions, never auto-merges, and has a contract test + wired into the repository's action-pinning workflow. + +## Verification + +```bash +node --test scripts/update-agent-runtime-pins.test.mjs && python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py && python3 .github/scripts/lint-action-pinning_test.py && python3 .github/scripts/lint-action-pinning.py && zizmor .github/workflows/update-agent-runtime-pins.yml && git diff --check +``` + +## Files likely touched + +- `scripts/update-agent-runtime-pins.mjs` +- `scripts/update-agent-runtime-pins.test.mjs` +- `.github/workflows/update-agent-runtime-pins.yml` +- `.github/scripts/update-agent-runtime-pins-workflow-contract_test.py` +- `.github/workflows/lint-action-pinning.yml` + +## Dependencies + +Task 01. + +## Parallelism + +Parallel-safe with Task 02 after Task 01. Ownership is restricted to scripts and +`.github/**`; do not edit runtime selection files or the pin catalogue format. + +## Inputs + +- ADR: reviewed weekly PR, no automatic merge or runtime mutation +- Plan: Weekly Pin Update Workflow +- Scoped rules: `.github/AGENTS.md` + +## Output contract + +Report the required token boundary, workflow/branch behavior, files changed, +all security and contract checks, external side effects (expected: none during +local tests), risks, and synchronized task/plan status. + +## Results + +Complete. The updater changes only existing trusted catalogue entries, rejects +missing, malformed, and prerelease values before its atomic write, and the +weekly/manual workflow uses a pinned GitHub App token, a stable bot branch, one +grouped PR, and no auto-merge or `GITHUB_TOKEN` fallback. + +Verification: updater tests passed 7/7; workflow contract tests passed 8/8; +action-pinning tests passed 9/9 and the linter accepted all 19 workflow files; +`zizmor .github/workflows/update-agent-runtime-pins.yml` reported no findings. +Local checks caused no external workflow or PR side effects. + +Follow-up review verification keeps the App token in the push step, edits an +existing updater PR by its number, and runs the focused managed-runtime Go +suite after the catalogue update and before any commit or push. The workflow +contract now asserts that validation boundary; the contract passed 8/8 and +the action-pinning checks remained green. diff --git a/docs/plans/managed-runtime-version-awareness/task-05-settings-update-indicator.md b/docs/plans/managed-runtime-version-awareness/task-05-settings-update-indicator.md new file mode 100644 index 0000000000..7f0e2c544c --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/task-05-settings-update-indicator.md @@ -0,0 +1,77 @@ +--- +id: "05-settings-update-indicator" +title: "Show update awareness in Settings" +status: complete +wave: 4 +depends_on: ["03-update-status-api"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 05: Show update awareness in Settings + +## Acceptance + +- Settings > Agents loads page-local batch status and shows a blue dot only for + structural `update_available`; unknown status leaves the update control usable. +- The trigger exposes effective and latest versions in accessible copy, and + opening it still fetches the authoritative live preview before mutation. +- Desktop keeps the existing dialog, mobile keeps the existing 44 px trigger + and inset drawer, and **Use Kandev default** shares the same state/action logic. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- lib/api/domains/agent-update-api.test.ts hooks/domains/settings/use-agent-runtime-update-statuses.test.tsx components/settings/agent-runtime-update-control.test.tsx && pnpm --filter @kandev/web exec eslint app/settings/agents/page.tsx components/settings/installed-agent-card.tsx components/settings/agent-runtime-update-control.tsx hooks/domains/settings/use-agent-runtime-update-statuses.ts lib/api/domains/agent-update-api.ts && pnpm --filter @kandev/web i18n:check && cd web && pnpm run typecheck +``` + +## Files likely touched + +- `apps/web/lib/api/domains/agent-update-api.ts` +- `apps/web/lib/api/domains/agent-update-api.test.ts` +- `apps/web/lib/types/http-agents.ts` +- `apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.ts` +- `apps/web/hooks/domains/settings/use-agent-runtime-update-statuses.test.tsx` +- `apps/web/app/settings/agents/page.tsx` +- `apps/web/components/settings/installed-agent-card.tsx` +- `apps/web/components/settings/agent-runtime-update-control.tsx` +- `apps/web/components/settings/agent-runtime-update-control.test.tsx` +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/agents.json` + +## Dependencies + +Task 03. + +## Parallelism + +Sequential. It consumes the final backend status and reset contracts. + +## Inputs + +- Spec: Desktop and mobile behavior, update status, failure modes +- Plan: Frontend and Mobile design contract +- Mobile exemplar: existing `AgentRuntimeUpdateControl` + +## Output contract + +Report rendered states, accessibility/mobile behavior, locale changes, exact +unit/lint/i18n/typecheck results, risks, and synchronized task/plan status. + +## Results + +Complete. Settings now loads page-local cached status, shows the blue dot only +for structural `update_available`, keeps unknown controls usable, refreshes +after Rescan and successful jobs, and shares the exact preview/mutation logic +between the desktop dialog and mobile drawer. Accessible labels include the +effective/latest versions, all new copy is localized in five catalogues, and +the 44 px mobile trigger remains intact. + +Verification: focused frontend unit tests passed 49/49; full web lint passed; +`pnpm run typecheck` passed; `pnpm run i18n:check` passed with pseudo and all +four translated catalogues complete. + +Follow-up review verification keeps the complete backend version projection in +the selector, prefers terminal effective versions, and clears a stale active +selection after a successful default reset. The focused frontend tests passed +32/32, the changed component lint passed, full web lint/typecheck/i18n passed, +and desktop/mobile E2E passed 15/15 and 4/4. diff --git a/docs/plans/managed-runtime-version-awareness/task-06-update-awareness-e2e.md b/docs/plans/managed-runtime-version-awareness/task-06-update-awareness-e2e.md new file mode 100644 index 0000000000..d5904ef52e --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/task-06-update-awareness-e2e.md @@ -0,0 +1,72 @@ +--- +id: "06-update-awareness-e2e" +title: "Prove desktop and mobile flows" +status: complete +wave: 5 +depends_on: ["05-settings-update-indicator"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 06: Prove desktop and mobile flows + +## Acceptance + +- Desktop E2E proves the update dot, authoritative preview, no pre-approval + mutation, successful dot removal, persisted selection, and partial-unknown + behavior through the UI. +- Mobile E2E taps the dotted 44 px trigger, completes the same update flow in + the existing drawer, and proves containment and no document horizontal + overflow. +- Tests use deterministic status/preview fixtures, stable test IDs, causal + waits, and no raw sleeps. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run tests/settings/agent-runtime-update.spec.ts && pnpm e2e:run --project mobile-chrome tests/settings/mobile-agent-runtime-update.spec.ts +``` + +## Files likely touched + +- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/agent-runtime-update-helpers.ts` + +## Dependencies + +Task 05. + +## Parallelism + +Parallel-safe with Task 07 after Tasks 01-05. This task owns only E2E files. + +## Inputs + +- Spec scenarios for update, unknown status, persistence, and mobile parity +- `/e2e` fixture and cleanup rules +- Existing runtime-update desktop/mobile specs and helper routes + +## Output contract + +Report RED/GREEN evidence, exact discovered test counts and commands, rendered +artifact paths if produced, cleanup evidence, risks, and synchronized task/plan +status. + +## Results + +Complete. Desktop coverage proves the blue dot, authoritative preview, no +pre-approval POST, successful dot removal, persisted active selection after +reload, unknown-status usability, and structural return-to-default behavior. +Mobile coverage proves the dotted 44 px trigger, drawer update flow, contained +scrolling, and no document horizontal overflow. + +Verification: the production-build desktop run +`pnpm e2e:run --project chromium tests/settings/agent-runtime-update.spec.ts` +passed 15/15, and the production-build mobile run +`pnpm e2e:run --project mobile-chrome +tests/settings/mobile-agent-runtime-update.spec.ts` passed 4/4. No raw sleeps +were added and the fixture uses causal waits. + +Follow-up review coverage adds the complete backend projection and successful +default-reset state, including removal of the stale active-version display. diff --git a/docs/plans/managed-runtime-version-awareness/task-07-runtime-version-docs.md b/docs/plans/managed-runtime-version-awareness/task-07-runtime-version-docs.md new file mode 100644 index 0000000000..483d722328 --- /dev/null +++ b/docs/plans/managed-runtime-version-awareness/task-07-runtime-version-docs.md @@ -0,0 +1,64 @@ +--- +id: "07-runtime-version-docs" +title: "Update runtime version documentation" +status: complete +wave: 5 +depends_on: ["01-default-pins", "02-effective-version-selection", "03-update-status-api", "05-settings-update-indicator"] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 07: Update runtime version documentation + +## Acceptance + +- Public agent documentation explains Kandev defaults, operator selections, + the blue update hint, return-to-default behavior, and active-session effects. +- Internal bridge documentation shows exact effective-version commands and + distinguishes offline-preferred launch from online-preferred update behavior. +- A focused search finds no live documentation claiming managed packages are + unversioned or that selections are host-only. + +## Verification + +```bash +node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs && git diff --check +``` + +## Files likely touched + +- `docs/public/agents-and-profiles.md` +- `apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md` +- Other directly affected public/spec/decision references found by focused `rg` + +## Dependencies + +Tasks 01, 02, 03, and 05. + +## Parallelism + +Parallel-safe with Task 06 after Tasks 01-05. This task owns documentation only. + +## Inputs + +- Updated runtime-updates spec and ADR +- Final API labels and command behavior from Tasks 01-05 +- `/docs-maintainer` public documentation boundary + +## Output contract + +Report public/internal docs changed, content type, focused stale-claim search, +exact validation results, risks, and synchronized task/plan status. + +## Results + +Complete. Public and internal runtime documentation now explains reviewed +defaults, install-wide selections, return-to-default, blue update hints, +unknown checks, active-session behavior, exact effective-version commands, and +offline-preferred launches versus online-preferred updates. The current Codex +decision was reconciled, and the superseded unversioned/host-only claims remain +only as historical context in superseded records. + +Verification: public-doc tests passed 61/61 and the validator accepted 41 +published pages; `git diff --check` passed; focused stale-claim search found no +current public or bridge documentation making the old claim. diff --git a/docs/plans/managed-runtime-version-picker/plan.md b/docs/plans/managed-runtime-version-picker/plan.md new file mode 100644 index 0000000000..09ca7eec85 --- /dev/null +++ b/docs/plans/managed-runtime-version-picker/plan.md @@ -0,0 +1,100 @@ +--- +spec: docs/specs/agents/runtime-updates.md +created: 2026-08-22 +status: complete +--- + +# Implementation Plan: Managed Runtime Version Picker + +## Overview + +Improve the existing managed-runtime update dialog without changing its API or +selection semantics. The dialog will show a compact status summary and quick +latest/default choices, while a searchable browser exposes the complete stable +catalogue on demand in both the desktop dialog and mobile drawer. + +## Frontend + +### Compact update dialog + +- Refine `apps/web/components/settings/agent-runtime-update-control.tsx` so + the status summary does not repeat the up-to-date label and the full version + list is not rendered in the initial view. +- Keep current/effective/default values visible as compact metadata and retain + the existing approval, preview, and job state wiring. +- Keep the initial preview free of body overflow on desktop and mobile. Allow + the shared body to scroll only for long version lists or streamed output, + while preserving 44px mobile actions and a reachable footer. + +### Version browser + +- Add a focused picker component beside the existing control for quick latest + and Kandev-default actions plus an explicit browse trigger. +- Use the shared command/search primitives for version filtering and 44px + selectable rows. Preserve latest, active, and default markers and call the + existing target/default callbacks. +- Keep the browser inside the existing dialog body/drawer so desktop and mobile + share selection state and one internal scroll owner. + +### Localization + +- Add the browse, search, quick-choice, and empty-search copy to all required + agent locale catalogues. Generate Traditional Chinese updates with the + repository i18n command. + +## Tests + +- **What:** The initial dialog exposes compact quick choices and does not render + all version rows. + **File:** `apps/web/components/settings/agent-runtime-update-control.test.tsx`. + **How:** Render a long preview and assert the browse trigger plus quick + actions; assert a distant version is absent before browsing. +- **What:** Searching the browser filters the catalogue and selecting a result + uses the existing target-preview callback. + **File:** `apps/web/components/settings/agent-runtime-update-control.test.tsx`. + **How:** Open the browser, fill the search field, assert matching/nonmatching + rows, then select the matching row and assert the callback. +- **What:** The browser works in the mobile drawer with contained long content. + **File:** `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`. + **How:** Use the mobile fixture to open the update drawer, browse/search, and + select a version while asserting no document horizontal overflow. + +## E2E Tests + +- **Scenario:** A long catalogue opens as a compact dialog with quick choices, + and browsing exposes searchable versions. + **File:** `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts`. + **What to verify:** The summary and browse trigger are visible, the full list + appears only after browsing, and a search result can be selected. +- **Scenario:** The same browse/search/selection flow works on Pixel 5. + **File:** `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`. + **What to verify:** The drawer contains the browser, touch rows are reachable, + and document horizontal overflow remains zero. + +## Verification Results + +- `cd apps && pnpm --filter @kandev/web test -- --run components/settings/agent-runtime-update-control.test.tsx` passed (6 tests). +- `cd apps/web && pnpm run typecheck` passed. +- `cd apps/web && pnpm run i18n:check` passed; all five locale catalogues are complete. +- Changed-file ESLint passed with zero warnings or errors. +- `cd apps/web && pnpm e2e:run --project chromium e2e/tests/settings/agent-runtime-update.spec.ts` passed (15 tests). +- `cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts` passed (5 tests). +- `git diff --check` passed. +- The compactness refinement passed the rendered no-initial-overflow checks on + desktop and mobile; streamed output still produced a scrollable body. +- Fresh desktop and mobile preview screenshots were validated and compressed + for PR publication. +- The Traditional Chinese generator was attempted but refused to write because of the existing `dynamicProfileSettings` residual warning in both Traditional Chinese catalogues. The five new values were added in the generator's OpenCC-equivalent form, and the full i18n check passed afterward. + +## Implementation Waves And Parallel Candidates + +Wave 1, sequential: + +- [x] [Task 01: Implement the compact searchable picker](task-01-compact-searchable-picker.md) + +## Risks + +- The picker must reuse the existing callbacks and not change backend target + validation or preview semantics. +- A mobile browser must not create a second page-level scroll container or + hide the primary update action behind an unreachable surface. diff --git a/docs/plans/managed-runtime-version-picker/task-01-compact-searchable-picker.md b/docs/plans/managed-runtime-version-picker/task-01-compact-searchable-picker.md new file mode 100644 index 0000000000..6b45e6e21a --- /dev/null +++ b/docs/plans/managed-runtime-version-picker/task-01-compact-searchable-picker.md @@ -0,0 +1,84 @@ +--- +id: "01-compact-searchable-picker" +title: "Implement the compact searchable picker" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 01: Implement the compact searchable picker + +Replace the always-visible native 50-option selector with compact latest and +Kandev-default quick choices plus an explicit searchable browser. Keep the +existing target preview, default reset, approval, and job lifecycle callbacks. + +## Acceptance + +- Opening the dialog with a long catalogue shows the status summary and quick + choices, not the full version history. +- Browsing exposes a searchable list with latest, active, and default markers; + selecting a result previews that exact target. +- Desktop and mobile preserve the same selection behavior, with one contained + scroll owner and touch-sized rows on mobile. +- Existing locales remain complete and the focused desktop/mobile tests pass. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/settings/agent-runtime-update-control.test.tsx +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm e2e:run --project chromium e2e/tests/settings/agent-runtime-update.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts +``` + +## Files likely touched + +- `apps/web/components/settings/agent-runtime-update-control.tsx` +- `apps/web/components/settings/runtime-version-picker.tsx` +- `apps/web/components/settings/agent-runtime-update-control.test.tsx` +- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts` +- `apps/web/src/locales/en/agents.json` +- `apps/web/src/locales/pt-pt/agents.json` +- `apps/web/src/locales/zh-cn/agents.json` +- `apps/web/src/locales/zh-hk/agents.json` +- `apps/web/src/locales/zh-tw/agents.json` +- `docs/specs/agents/runtime-updates.md` +- `docs/plans/managed-runtime-version-picker/plan.md` +- `docs/plans/managed-runtime-version-picker/task-01-compact-searchable-picker.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. + +## Inputs + +- Option 1 selected by the user: compact summary, quick latest/default + choices, explicit browse action, searchable complete catalogue. +- Existing `AgentRuntimeUpdateControl` callbacks and desktop/mobile surface. +- Mobile parity contract: one drawer body scroll owner and 44px touch rows. + +## Output contract + +Update the picker, localized copy, focused component/E2E coverage, and these +plan results. Report exact commands, changed files, and any remaining browser +verification blockers. + +## Results + +- Implemented the compact quick-choice picker and on-demand searchable version browser. +- Preserved preview/default callbacks and updated desktop/mobile selectors, rollback, reset, and status assertions. +- Added localized copy and regenerated the pseudo catalogue. +- Component test, typecheck, i18n check, changed-file lint, desktop E2E (15), mobile E2E (5), and `git diff --check` all pass. +- Traditional Chinese generation was blocked by the pre-existing `dynamicProfileSettings` residual warning; equivalent generated values were added manually and the catalog checks pass. +- Follow-up compactness refinement keeps the initial desktop dialog and mobile + drawer free of body overflow, while long streamed output remains scrollable. +- Fresh desktop and mobile screenshots were captured, inspected, mapped in the + PR asset manifest, and compressed before publication. diff --git a/docs/plans/managed-runtime-version-popover/plan.md b/docs/plans/managed-runtime-version-popover/plan.md new file mode 100644 index 0000000000..16b12911ce --- /dev/null +++ b/docs/plans/managed-runtime-version-popover/plan.md @@ -0,0 +1,64 @@ +--- +spec: docs/specs/agents/runtime-updates.md +created: 2026-08-22 +status: completed +--- + +# Fix Plan: Anchored Managed Runtime Version Popover + +## Root cause + +`RuntimeVersionPicker` renders `RuntimeVersionBrowser` as a conditional block +inside the update body. The browser's command list therefore participates in +normal layout flow and expands the dialog or mobile drawer instead of behaving +like an anchored selector. + +## Repair + +Use the existing `@kandev/ui/popover` primitive around the version trigger and +render the searchable `Command` content in `PopoverContent`. Keep the existing +shared selection callbacks and close the popover after target selection. The +desktop dialog and mobile drawer must use the same popover state and catalogue; +only their trigger surface and available viewport geometry differ. + +## Regression coverage + +- Desktop: opening a long catalogue renders a `PopoverContent` outside the + dialog's layout subtree and leaves the dialog height unchanged. +- Mobile: opening the same catalogue renders the popover outside the drawer's + layout subtree, leaves the drawer height unchanged, keeps options at least + 44px high, and preserves selection. +- Existing searchable selection and preview callback coverage remains green. + +## Files + +- `apps/web/components/settings/runtime-version-picker.tsx` +- `apps/web/components/settings/agent-runtime-update-control.test.tsx` +- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts` +- `docs/specs/agents/runtime-updates.md` + +## Verification + +Completed on 2026-08-22: + +- Component tests: 6 passed. +- Desktop managed-runtime E2E: 15 passed. +- Mobile managed-runtime E2E: 5 passed. +- Web typecheck passed. +- Web i18n check passed. +- Changed-file ESLint passed. +- `git diff --check` passed. + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/settings/agent-runtime-update-control.test.tsx +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm e2e:run --project chromium e2e/tests/settings/agent-runtime-update.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/settings/mobile-agent-runtime-update.spec.ts +``` + +## Out of scope + +- Changing version resolution, selection semantics, or update job behavior. +- Replacing the shared dialog, drawer, command, or popover primitives. diff --git a/docs/plans/managed-runtime-version-popover/task-01-anchored-popover.md b/docs/plans/managed-runtime-version-popover/task-01-anchored-popover.md new file mode 100644 index 0000000000..3fd1d7ecde --- /dev/null +++ b/docs/plans/managed-runtime-version-popover/task-01-anchored-popover.md @@ -0,0 +1,32 @@ +--- +id: "01-anchored-popover" +title: "Render the managed runtime catalogue in an anchored popover" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/runtime-updates.md" +--- + +# Task 01: Render the managed runtime catalogue in an anchored popover + +## Acceptance + +- The version trigger opens the searchable catalogue as a popover instead of + inserting the catalogue into dialog/drawer layout flow. +- Opening the catalogue does not increase the desktop dialog or mobile drawer + height. +- Desktop and mobile preserve searchable selection, preview callbacks, and + 44px touch-safe option rows. +- Selecting a version closes the popover. + +## TDD sequence + +1. [x] Add desktop and mobile geometry assertions and confirm they fail against + the current inline browser. +2. [x] Wrap the trigger and browser with `@kandev/ui/popover`. +3. [x] Rerun the focused tests and refactor only after the behavior is green. + +## Validation + +Run the commands in `plan.md`, plus `git diff --check` and changed-file ESLint. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 56d9bc61aa..af01d644b1 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -49,6 +49,20 @@ The status shown on this page is authoritative for the current host. A CLI that The update icon is available on managed Claude, Codex, OpenCode, Copilot, and Gemini agent cards. It updates the runtime on the Kandev host. +Each managed runtime has a reviewed Kandev default. If you have not selected a +version, Kandev uses that exact default for probes, sessions, standalone +inference, containers, and SSH commands. A successful version update stores +your exact selection for this Kandev installation. The selection takes +precedence over the default until you choose **Use Kandev default**. Kandev +does not store the default as a user selection, so later Kandev releases can +move unmodified installations to their reviewed defaults. + +When the cached npm check finds a newer stable release, the update control has +a blue dot and its accessible label includes the effective and latest +versions. The dot is only a hint. Opening the control performs a fresh, +authoritative preview before any update starts. If the check is unavailable, +the control has no dot but remains usable. + 1. Select the update icon. 2. Review the current version, active version, available stable versions, and command. 3. Keep the latest version selected for a normal update, or select an older stable version to roll back. @@ -65,7 +79,10 @@ configuration options, commands, and runtime version without a page reload. - The active exact version survives Kandev and browser restarts. - Later host-local probes, utility calls, and standalone sessions use the active exact version. - Active sessions keep running. They are not restarted or hot-swapped. -- Passthrough agents, authentication helpers, remote executors, and running containers are unchanged. +- Passthrough agents and authentication helpers remain unchanged. The Settings + update job prepares the host runtime only; remote executor and new-container + commands still use the effective exact version and must resolve that package + in their own environment. Running sessions and containers are unchanged. - If preparation, ACP validation, authentication, or persistence fails, Kandev keeps the previous active version and capability catalogue. Select another stable version or retry the same target. - Kandev may prepare the exact version again if npm removes its cache entry. Kandev does not own an offline package inventory, and global npm cache cleanup is not required. diff --git a/docs/specs/agents/runtime-updates.md b/docs/specs/agents/runtime-updates.md index a69a697713..b95b24a27f 100644 --- a/docs/specs/agents/runtime-updates.md +++ b/docs/specs/agents/runtime-updates.md @@ -1,11 +1,11 @@ --- status: approved created: 2026-07-26 -updated: 2026-08-16 +updated: 2026-08-22 owner: Kandev --- -# Managed Agent Runtime Version Recovery +# Managed Agent Runtime Versions and Updates ## Why @@ -15,6 +15,11 @@ partly published, incompatible with ACP, or otherwise cannot start. Rebuilding an npm cache is not sufficient when an unversioned command selects the same broken release again. +Operators also need to know that a newer managed agent bridge exists before +they open its version dialog. Kandev must provide a stable reviewed default for +unmodified installations without preventing an operator from selecting a newer +validated version between Kandev releases. + Managed runtimes can also fail when npm has stale package metadata. In that case, npm can report `ETARGET` and `No matching version found` for a dependency that the configured registry now contains. Kandev currently reports the later @@ -35,13 +40,18 @@ action unless they inspect backend logs. - Kandev stages the exact trusted `package@version`, ACP-probes that candidate, and activates it only after a successful probe. Candidate failure preserves the prior active version and capability catalogue. -- A successful activation persists the exact version for this Kandev install. - Later standalone host probes, utility calls, and local sessions use that - exact package spec. Active sessions continue unchanged. -- When no active selection exists, legacy host launches remain unversioned. - The first successful update, rollback, or repair establishes the durable - active version. -- SSH, containers, and other remote executors do not use the host selection. +- Every managed npm runtime has an exact Kandev default version. A successful + activation persists an operator-selected exact version for this Kandev + install. The effective version is the selected version when present and the + Kandev default otherwise. +- Kandev does not persist the default as a selection. An installation without + an operator selection follows default-pin changes delivered by later Kandev + releases. +- Every Kandev-built ACP command for the managed package uses the effective + exact version, including probes, utility calls, standalone sessions, + containers, and SSH executors. Active sessions continue unchanged. +- Settings lets the operator clear the selected version and return to the + Kandev default after that default passes the normal candidate validation. - Package name, ACP arguments, and command shape remain trusted built-in metadata. A caller can submit only a version returned by the trusted package's npm metadata. Tags, prereleases, package specs, registry URLs, and @@ -57,6 +67,29 @@ action unless they inspect backend logs. and retries the same trusted package and version once. - If the retry also fails, Kandev reports an npm runtime preparation error and offers one runtime retry action. The user does not need to understand ACP. +- When the Agents settings page loads, Kandev checks each available managed + package against npm's stable `latest` version through one batch status + request. A newer version marks the existing update control with a blue dot + and accessible update information. +- An update-status check is read-only. It never prepares, probes, selects, or + starts a runtime. Registry failure leaves the status unknown, keeps the update + control usable, and does not show a false up-to-date result. +- Kandev caches successful registry status checks for six hours per package and + failed checks for fifteen minutes. Opening the version dialog still performs + an authoritative live preview. +- The authoritative preview normalizes valid stable package metadata returned + by supported npm CLI versions, including npm versions that wrap a + multi-field `npm view` response in a one-element array. A valid response + always produces the same stable version catalogue for the operator. +- The version dialog presents a compact status summary and quick choices for + the latest and Kandev default versions. The complete stable catalogue stays + behind an explicit browse action and can be searched by version. +- The initial preview fits within the desktop dialog and phone drawer without + body overflow. The shared body becomes scrollable only when the version + browser or streamed job output needs more space. +- Opening the version browser uses an anchored popover. The popover must not + increase the dialog or drawer height, must remain viewport-contained, and + must close after a version is selected. The managed package set is: @@ -76,23 +109,33 @@ Decision: ## Version and operation semantics -Kandev distinguishes three version values: +Kandev distinguishes six version values: - `current_version` is the version reported by the last successful host ACP probe. It can be absent after a failed probe. -- `active_version` is the exact version persisted for future host-local - managed-runtime commands. It can be absent before the first activation. +- `default_version` is the exact reviewed version shipped with Kandev. +- `active_version` is the exact operator-selected version persisted for future + managed-runtime commands. It is absent when the operator follows the Kandev + default. +- `effective_version` is `active_version` when present and `default_version` + otherwise. It is never empty for a supported managed runtime. +- `latest_version` is npm's stable `latest` version from the most recent update + status check or authoritative preview. It is absent when that check fails. - `target_version` is the stable version selected by the operator. -The backend derives the operation from `active_version` when present and -otherwise from `current_version`: +The backend derives the operation from `effective_version` and +`current_version`. When an active operator selection exists and the operator +chooses `default_version`, `use_default` takes precedence over version +comparison. Without that reset condition, the backend classifies the request as +`update`, `rollback`, `repair`, or `up_to_date`: | Condition | Operation | Approval | | --- | --- | --- | | Target is newer than the effective version. | `update` | **Update runtime** | | Target is older than the effective version. | `rollback` | **Roll back runtime** | -| Current is unknown, versions cannot be compared, or the observed version matches but has not been activated exactly. | `repair` | **Repair runtime** | -| Active, current, and target versions match. | `up_to_date` | Disabled as **Up to date** | +| Current is unknown or versions cannot be compared. | `repair` | **Repair runtime** | +| Effective, current, and target versions match. | `up_to_date` | Disabled as **Up to date** | +| An active operator selection exists and the operator chooses the shipped default. | `use_default` | **Use Kandev default** | Only strict stable SemVer values from npm's published version list are selectable. Kandev does not offer prereleases. Version ordering and operation @@ -110,13 +153,45 @@ Installed-agent catalogue entries expose optional runtime-management metadata: "supported": true, "package": "opencode-ai", "current_version": "1.18.5", - "active_version": "1.18.5" + "default_version": "1.18.5", + "effective_version": "1.18.5" } } ``` `current_version` and `active_version` are independently omitted when unknown -or not yet established. Unmanaged agents omit `runtime_update`. +or not selected. `default_version` and `effective_version` are required when +`supported` is true. Unmanaged agents omit `runtime_update`. + +### Update status + +- `GET /api/v1/agent-update/status` returns one status for every available + built-in managed runtime. +- The backend queries stale package entries concurrently with a fixed bound and + reuses fresh per-package cache entries. +- One package lookup failure does not fail the full response. + +```json +{ + "statuses": [ + { + "agent_name": "opencode-acp", + "package": "opencode-ai", + "default_version": "1.18.5", + "effective_version": "1.18.5", + "latest_version": "1.18.16", + "check_state": "update_available", + "checked_at": "timestamp" + } + ] +} +``` + +`check_state` is `update_available`, `up_to_date`, or `unknown`. The backend +sets `update_available` only when strict stable SemVer ordering proves that +`latest_version` is newer than `effective_version`. It does not flag an older +upstream version as an update. An unknown entry omits `latest_version` and +`checked_at` when no successful cached value exists. ### Preview and jobs @@ -124,8 +199,12 @@ or not yet established. Unmanaged agents omit `runtime_update`. and previews the upstream latest stable target. - `GET /api/v1/agent-update/:agentName/preview?target_version=1.18.5` validates and previews one selected version without mutation. +- `GET /api/v1/agent-update/:agentName/preview?use_default=true` previews + returning to the trusted shipped default. - `POST /api/v1/agent-update/:agentName` accepts JSON - `{ "target_version": "1.18.5" }` and starts or returns the active job. + `{ "target_version": "1.18.5" }` or `{ "use_default": true }` and starts or + returns the active job. `use_default` resolves the target from trusted agent + metadata; callers do not supply both fields. - `GET /api/v1/agent-update/jobs` and `GET /api/v1/agent-update/jobs/:id` retain their current polling behavior. - State-changing requests use the Settings mutation interlock. @@ -137,7 +216,8 @@ A preview contains: "agent_name": "opencode-acp", "package": "opencode-ai", "current_version": "1.18.5", - "active_version": "1.18.5", + "default_version": "1.18.5", + "effective_version": "1.18.5", "target_version": "1.18.16", "operation": "update", "available_versions": [ @@ -174,6 +254,8 @@ authoritative operation and active version: "operation": "rollback", "current_version": "1.18.16", "active_version": "1.18.16", + "default_version": "1.18.5", + "effective_version": "1.18.16", "target_version": "1.18.5", "output": "", "error": "", @@ -183,8 +265,11 @@ authoritative operation and active version: } ``` -`active_version` reflects the persisted selection at that job snapshot. It -changes to the target only after successful validation and persistence. The +`active_version` reflects the persisted operator selection at that job +snapshot. `effective_version` reflects the version future managed commands use. +For a selected target, both become the target only after successful validation +and persistence. For `use_default`, `active_version` is removed and +`effective_version` becomes `default_version` only after successful validation. The existing `agent.update.started`, `agent.update.output`, and `agent.update.finished` WebSocket notifications carry the same job fields. @@ -193,37 +278,38 @@ existing `agent.update.started`, `agent.update.output`, and | State | Backend behavior | Observable behavior | | --- | --- | --- | | `queued` | Accept the selected version and maintenance claim. | The version picker and action are disabled. | -| `resolving` | Re-read npm versions, validate the exact target, and classify the operation. | The UI shows the selected target and resolving progress. | +| `resolving` | Re-read npm versions, resolve the requested exact target or trusted default, and classify the operation. | The UI shows the selected target and resolving progress. | | `updating` | Prepare `package@target` in its version-specific npm execution tree. On first failure, invalidate only that exact tree and retry once. | Bounded stdout and stderr stream into the dialog. | -| `refreshing` | Probe the candidate command without replacing cached capabilities. On success, persist the target and then publish the candidate capabilities. | The UI explains that Kandev is validating before activation. | +| `refreshing` | Probe the candidate command without replacing cached capabilities. On success, save the selected target or delete the selection for `use_default`, then publish the candidate capabilities. | The UI explains that Kandev is validating before activation. | | `succeeded` | The exact target is active and its capabilities are published. | The catalogue and models refresh without a page reload. | -| `failed` | Resolution, preparation, probe, or persistence failed. The previous active version and capabilities remain unchanged. | The UI shows the captured error and permits a new selection or retry. | +| `failed` | Resolution, preparation, probe, or persistence failed. The previous effective version and capabilities remain unchanged. | The UI shows the captured error and permits a new selection or retry. | -Jobs are terminal after `succeeded` or `failed`. Selecting the active, healthy -version produces `up_to_date` in preview and starts no job. Selecting the active -version while its current probe is unknown produces a repair job. +Jobs are terminal after `succeeded` or `failed`. Selecting the effective, +healthy version produces `up_to_date` in preview and starts no job. Returning +to the default still validates that default before deleting an existing +selection. Selecting the effective version while its current probe is unknown +produces a repair job. -## Host command routing +## Command routing -- A standalone managed-agent launch reads the active selection immediately - before building its command and passes the exact version through trusted - command options. -- Boot probes, manual capability refreshes, model-configuration resolution, and - sessionless utility prompts use the same active-version resolver. +- Every managed-agent command resolves the effective version immediately before + building its command and emits the trusted `package@effective_version` spec. +- Boot probes, manual capability refreshes, model-configuration resolution, + sessionless utility prompts, standalone sessions, containers, and SSH + executors use the same effective-version resolver. - Native-binary preference continues to win when an agent deliberately selects its supported native binary path. - Passthrough command construction does not receive or apply the active managed ACP version. It continues to use the agent's declared interactive CLI. -- SSH and container command builders receive no host version override. -- A saved selection read error fails the new host command with an actionable - error. It does not fall back to an unversioned package. +- A saved selection read error fails the new managed command with an actionable + error. It does not fall back to the default or an unversioned package. - Candidate validation bypasses the active selection only for the trusted exact candidate command created by the version job. +- No supported managed-runtime command emits an unversioned package spec. ## Launch-time stale metadata recovery -- Normal host-local managed runtime commands continue to use - `--prefer-offline`. +- Normal managed runtime commands continue to use `--prefer-offline`. - Kandev inspects bounded process stderr when ACP initialization ends before a response. Automatic recovery requires both npm `ETARGET` and a matching `No matching version found for package@version` message. @@ -232,7 +318,7 @@ version while its current probe is unknown produces a repair job. - Recovery removes only the deterministic `_npx` execution tree for the trusted built-in package specification. It never clears npm's full cache. - Kandev starts the same managed runtime once with `--prefer-online`. The - command keeps the same trusted package, exact active version when one exists, + command keeps the same trusted package, exact effective version, ACP arguments, configured npm registry, command prefix, permissions, model, and session identity. - Recovery is limited to one retry for each launch attempt. A delayed event @@ -250,6 +336,9 @@ version while its current probe is unknown produces a repair job. ## Failure and recovery behavior - Registry failure during preview keeps approval disabled and runs no command. +- A valid package metadata response in a supported npm CLI output shape is not + treated as a registry failure. Malformed, empty, or ambiguous metadata still + fails closed and keeps approval disabled. - Registry failure or target disappearance after approval fails before staging. - Preparation failure invalidates only the deterministic `_npx` tree for the exact `package@version` and retries once. Kandev never runs a global npm cache @@ -264,20 +353,32 @@ version while its current probe is unknown produces a repair job. - Active sessions are never restarted, replaced, or hot-swapped. - A launch-time stale metadata retry is not a version rollback. It prepares the same package selection again and does not change the active version. +- Registry failure during the batch update-status check returns `unknown` for + only the affected package. It does not disable the update control, show an + error badge, or claim that the package is up to date. +- A stale update-available dot can remain until the successful six-hour cache + entry expires. Opening the dialog always replaces that hint with a live + authoritative preview. A successful update or reset invalidates the affected + status cache and refreshes the page state. ## Persistence guarantees -- The trusted package identity and active version are stored install-wide per - built-in agent in the system settings store and survive backend and browser +- The trusted package identity and operator-selected version are stored + install-wide per built-in agent in the system settings store and survive backend and browser restarts. A record whose package no longer matches the agent's built-in - metadata is treated as having no active selection and is not applied to the - replacement package. -- The active version is written only after successful candidate validation, so - it is also the last known good selection. + metadata is treated as having no active selection; the replacement package's + reviewed default becomes effective. +- The Kandev default is compiled into the managed runtime catalogue and is not + copied into the settings database. Clearing a selection deletes its settings + record only after the default candidate passes validation. +- An operator-selected version is written only after successful candidate + validation, so it is also the last known good selection. - Jobs, process output, and capability cache remain process-local and do not survive a backend restart. +- Update-status results and timestamps are process-local cache entries. They do + not survive a backend restart and do not require a database migration. - npm's execution cache is best-effort and is not Kandev-owned inventory. If an - exact selected cache entry disappears, npm may prepare that same exact + exact effective cache entry disappears, npm may prepare that same exact version again; it must not advance to another version. - Dialog selection, output, and result remain page-local after a browser page restart. @@ -289,16 +390,27 @@ version while its current probe is unknown produces a repair job. - The existing agent-card update icon remains the entry point on desktop and mobile and keeps a minimum 44 px touch target. +- When `check_state` is `update_available`, the update icon shows a blue dot. + The button's accessible name and desktop tooltip state the current effective + and latest versions, so color is not the only signal. The dot is absent for + `up_to_date` and `unknown`. - Desktop uses the existing dialog. Phone layouts use the existing bottom drawer; no nested drawer is introduced. - The version selector is inside the shared body, is keyboard and touch accessible, and shows latest and active markers without encoding state in color alone. +- The compact dialog does not render the complete catalogue by default. The + browse surface exposes the complete list only after the operator requests it, + provides a search field, and keeps the selected, latest, active, and default + markers available. - The body is the single internal scroll owner. The safe-area-aware footer keeps the operation action reachable while long version lists and process output remain viewport-contained. - Selection state, preview loading, operation labels, request payloads, and terminal results are shared across desktop and mobile presentations. +- Tapping the dotted control on a phone opens the same version drawer and shows + the authoritative version summary. The dot itself is not a separate touch + target and does not depend on hover. - A failed automatic launch retry uses the existing inline recovery card in Kanban chat and Office chat. It does not open a dialog or drawer. - The card states that npm could not prepare the agent runtime. It states that @@ -317,26 +429,60 @@ version while its current probe is unknown produces a repair job. **Roll back runtime**, **THEN** Kandev prepares and probes that exact version, persists it only after success, and restores its model list without restart. - **GIVEN** a healthy exact active version, **WHEN** Kandev restarts, **THEN** - boot probes and later standalone sessions use the same exact version. + boot probes and later managed commands use the same exact version. +- **GIVEN** an agent has no operator selection, **WHEN** Kandev builds any of + its managed npm ACP commands, **THEN** the command uses the exact reviewed + Kandev default and never an unversioned package spec. +- **GIVEN** an agent has a validated operator selection, **WHEN** Kandev builds + a local, container, or SSH managed npm ACP command, **THEN** the command uses + that exact selection instead of the Kandev default. +- **GIVEN** an operator selection exists, **WHEN** the operator chooses **Use + Kandev default**, **THEN** Kandev validates the exact default, deletes the + selection only after success, and future commands follow shipped defaults. - **GIVEN** a candidate fails ACP initialization, **WHEN** the job ends, **THEN** the previous active version and capabilities remain authoritative. -- **GIVEN** the current version is unknown and there is no active selection, +- **GIVEN** the current version is unknown and the effective version is known, **WHEN** the operator selects a published target, **THEN** the UI offers - **Repair runtime** and validation establishes the first exact active version. -- **GIVEN** active, current, and target versions match, **WHEN** the dialog + **Repair runtime** and validation establishes the selected exact version. +- **GIVEN** effective, current, and target versions match, **WHEN** the dialog opens, **THEN** it shows **Up to date** and starts no job. - **GIVEN** a different target is submitted while a job is active for the same agent, **WHEN** the backend receives it, **THEN** it returns the existing job and does not run a second candidate concurrently. -- **GIVEN** an SSH or container session, **WHEN** the host active version - changes, **THEN** that executor's command remains unchanged. - **GIVEN** an agent whose interactive passthrough CLI is separate from its - managed ACP adapter, **WHEN** the host active ACP version changes, **THEN** + managed ACP adapter, **WHEN** the effective ACP version changes, **THEN** later passthrough sessions still launch the declared interactive CLI and do not launch the ACP package under a PTY. - **GIVEN** a phone viewport and a long version catalogue or process log, **WHEN** the operator selects and activates a version, **THEN** the drawer remains contained and the primary action remains touch-reachable. +- **GIVEN** npm reports a newer stable version than the effective version, + **WHEN** the Agents settings page loads, **THEN** the existing update control + shows a blue dot and exposes both versions in accessible update information. +- **GIVEN** the update-status lookup fails for one managed package, **WHEN** the + Agents settings page loads, **THEN** that package shows no blue dot, its + update control remains usable, and other package statuses still render. +- **GIVEN** npm returns valid stable package metadata in either its object form + or a supported one-element collection form, **WHEN** the operator opens a + managed runtime update dialog, **THEN** the dialog lists the stable versions, + selects npm's stable latest version, and does not show a resolution error. +- **GIVEN** a managed runtime has a long stable version catalogue, **WHEN** the + operator opens its update dialog, **THEN** the dialog shows the status summary + and quick choices without rendering the full version history. +- **GIVEN** the operator opens the full version browser and enters a version + fragment, **WHEN** matching versions exist, **THEN** only matching stable + versions remain selectable and selecting one previews that exact target. +- **GIVEN** the operator opens the full version browser on a phone, **WHEN** the + operator searches or selects a version, **THEN** the existing update drawer + keeps one contained scroll owner, exposes 44px touch rows, and preserves the + same target selection behavior as desktop. +- **GIVEN** the operator opens a long version catalogue, **WHEN** the operator + opens the version selector, **THEN** the catalogue appears in an anchored + popover without increasing the dialog or drawer height, and selecting a + version closes the popover. +- **GIVEN** a dotted update control on a phone, **WHEN** the operator taps it, + **THEN** the existing update drawer opens and shows a live authoritative + preview without requiring hover. - **GIVEN** a host-local managed runtime exits before ACP initialization with npm `ETARGET` and a matching missing dependency version, **WHEN** Kandev reads the captured stderr, **THEN** it removes only the trusted package's @@ -355,12 +501,14 @@ version while its current probe is unknown produces a repair job. ## Out of scope -- Scheduled or automatic updates and automatic rollback after launch failure. +- Automatic runtime installation, automatic operator-selection changes, and + automatic rollback after launch failure. - Global npm cache cleanup, registry replacement, dependency substitution, or automatic selection of another package version. - Prerelease, tag, arbitrary package-spec, registry, or shell-command input. - Kandev-owned npm artifact retention or a package lockfile. -- Applying host selections to SSH, container, or other remote runtimes. +- Removing npm or network access from the launch path, or locking transitive + dependency ranges inside upstream packages. - Restarting or hot-swapping active sessions. - Native-only update channels and separately distributed passthrough or authentication packages. diff --git a/scripts/update-agent-runtime-pins.mjs b/scripts/update-agent-runtime-pins.mjs new file mode 100644 index 0000000000..33943ec9b6 --- /dev/null +++ b/scripts/update-agent-runtime-pins.mjs @@ -0,0 +1,162 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +export const DEFAULT_CATALOGUE_PATH = path.resolve( + SCRIPT_DIR, + "../apps/backend/internal/agent/agents/managed_npm_runtime_versions.json", +); +const STABLE_SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/; + +export function parseStableVersion(value) { + if (typeof value !== "string" || !STABLE_SEMVER.test(value)) { + throw new Error(`value is not a stable SemVer: ${String(value)}`); + } + return value; +} + +function assertCatalogue(catalogue) { + if (!catalogue || typeof catalogue !== "object" || Array.isArray(catalogue)) { + throw new Error("runtime pin catalogue must be a JSON object"); + } + const packages = Object.keys(catalogue); + if (packages.length === 0) { + throw new Error("runtime pin catalogue is empty"); + } + for (const packageName of packages) { + if (!packageName.trim()) { + throw new Error("runtime pin catalogue contains an empty package name"); + } + parseStableVersion(catalogue[packageName]); + } +} + +export function updatePins(catalogue, latestByPackage) { + assertCatalogue(catalogue); + if (!latestByPackage || typeof latestByPackage !== "object") { + throw new Error("latest version lookup did not return an object"); + } + + const next = {}; + let changed = false; + for (const packageName of Object.keys(catalogue)) { + const latest = latestByPackage[packageName]; + if (latest === undefined || latest === null || latest === "") { + throw new Error(`latest version is missing for ${packageName}`); + } + parseStableVersion(latest); + next[packageName] = latest; + changed ||= latest !== catalogue[packageName]; + } + return { catalogue: next, changed }; +} + +export function formatCatalogue(catalogue) { + assertCatalogue(catalogue); + return `${JSON.stringify(catalogue, Object.keys(catalogue).sort(), 2)}\n`; +} + +async function fetchLatest(packageName, fetchImpl) { + const response = await fetchImpl( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, + { headers: { accept: "application/json" } }, + ); + if (!response.ok) { + throw new Error(`npm lookup for ${packageName} returned HTTP ${response.status}`); + } + const metadata = await response.json(); + const latest = metadata?.["dist-tags"]?.latest; + parseStableVersion(latest); + return latest; +} + +export async function fetchLatestVersions(packages, fetchImpl = globalThis.fetch) { + if (typeof fetchImpl !== "function") { + throw new Error("fetch is unavailable"); + } + const pairs = await Promise.all( + packages.map(async (packageName) => [packageName, await fetchLatest(packageName, fetchImpl)]), + ); + return Object.fromEntries(pairs); +} + +export async function readCatalogue(cataloguePath = DEFAULT_CATALOGUE_PATH) { + const raw = await fs.readFile(cataloguePath, "utf8"); + const catalogue = JSON.parse(raw); + assertCatalogue(catalogue); + return catalogue; +} + +export async function writeCatalogueAtomically(cataloguePath, catalogue) { + const directory = path.dirname(cataloguePath); + const temporaryPath = path.join( + directory, + `.${path.basename(cataloguePath)}.${process.pid}.tmp`, + ); + await fs.writeFile(temporaryPath, formatCatalogue(catalogue), "utf8"); + await fs.rename(temporaryPath, cataloguePath); +} + +export async function updateCatalogue({ + cataloguePath = DEFAULT_CATALOGUE_PATH, + fetchImpl = globalThis.fetch, +} = {}) { + const catalogue = await readCatalogue(cataloguePath); + const latestByPackage = await fetchLatestVersions(Object.keys(catalogue), fetchImpl); + const result = updatePins(catalogue, latestByPackage); + if (result.changed) { + await writeCatalogueAtomically(cataloguePath, result.catalogue); + } + return result; +} + +function outputChanged(outputPath, changed) { + if (!outputPath) { + return; + } + return fs.appendFile(outputPath, `changed=${changed ? "true" : "false"}\n`); +} + +export function parseArguments(argv) { + let cataloguePath = DEFAULT_CATALOGUE_PATH; + let outputPath = ""; + let positionalPathSeen = false; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--github-output") { + outputPath = argv[index + 1] ?? ""; + if (!outputPath || outputPath.startsWith("--")) { + throw new Error("--github-output requires a path"); + } + index += 1; + continue; + } + if (argument.startsWith("--")) { + throw new Error(`unknown option: ${argument}`); + } + if (positionalPathSeen) { + throw new Error("only one catalogue path is supported"); + } + cataloguePath = argument; + positionalPathSeen = true; + } + + return { cataloguePath, outputPath }; +} + +async function main(argv = process.argv.slice(2)) { + const { cataloguePath, outputPath } = parseArguments(argv); + const result = await updateCatalogue({ cataloguePath }); + await outputChanged(outputPath, result.changed); + process.stdout.write(result.changed ? "Managed runtime pins changed.\n" : "Managed runtime pins are current.\n"); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`Managed runtime pin update failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/update-agent-runtime-pins.test.mjs b/scripts/update-agent-runtime-pins.test.mjs new file mode 100644 index 0000000000..fd91fc407d --- /dev/null +++ b/scripts/update-agent-runtime-pins.test.mjs @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_CATALOGUE_PATH, + parseStableVersion, + parseArguments, + updatePins, + formatCatalogue, +} from "./update-agent-runtime-pins.mjs"; + +const catalogue = { + "@example/claude": "1.0.0", + "opencode-ai": "2.0.0", +}; + +test("updates only existing trusted catalogue entries", () => { + const result = updatePins(catalogue, { + "@example/claude": "1.1.0", + "opencode-ai": "2.0.0", + "@example/untrusted": "9.9.9", + }); + + assert.equal(result.changed, true); + assert.deepEqual(result.catalogue, { + "@example/claude": "1.1.0", + "opencode-ai": "2.0.0", + }); +}); + +test("does not report a change when every latest value matches", () => { + const result = updatePins(catalogue, { + "@example/claude": "1.0.0", + "opencode-ai": "2.0.0", + }); + + assert.equal(result.changed, false); + assert.deepEqual(result.catalogue, catalogue); +}); + +test("rejects prerelease, malformed, and missing latest values before rewriting", () => { + for (const latest of ["1.1.0-beta.1", "latest", "", undefined]) { + assert.throws( + () => updatePins(catalogue, { "@example/claude": latest, "opencode-ai": "2.0.0" }), + /stable SemVer|latest version/i, + ); + } +}); + +test("rejects an invalid current catalogue pin", () => { + assert.throws( + () => updatePins({ "@example/claude": "1.0.0-beta.1" }, { "@example/claude": "1.1.0" }), + /stable SemVer/i, + ); +}); + +test("accepts build metadata but not prerelease syntax", () => { + assert.equal(parseStableVersion("1.2.3+build.7"), "1.2.3+build.7"); + assert.throws(() => parseStableVersion("1.2.3-rc.1"), /stable SemVer/i); +}); + +test("parses the workflow output flag before the default catalogue path", () => { + assert.deepEqual(parseArguments(["--github-output", "/tmp/github-output"]), { + cataloguePath: DEFAULT_CATALOGUE_PATH, + outputPath: "/tmp/github-output", + }); +}); + +test("formats the catalogue as one complete atomic-write payload", () => { + assert.equal( + formatCatalogue({ "@example/claude": "1.1.0", "opencode-ai": "2.0.0" }), + '{\n "@example/claude": "1.1.0",\n "opencode-ai": "2.0.0"\n}\n', + ); +});