feat(agent): pin managed runtimes and add update awareness (#2906)
This commit is contained in:
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -215,7 +215,7 @@ type CommandOptions struct {
|
||||
// the native binary (e.g. "copilot --acp") instead of "npx -y <pkg>".
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, InstallJob>;
|
||||
updateJobs: Record<string, AgentUpdateJob>;
|
||||
previewUpdate: (name: string, targetVersion?: string) => Promise<AgentUpdatePreview>;
|
||||
startUpdate: (name: string, targetVersion: string) => Promise<AgentUpdateJob>;
|
||||
previewUpdate: (
|
||||
name: string,
|
||||
targetVersion?: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdatePreview>;
|
||||
startUpdate: (
|
||||
name: string,
|
||||
targetVersion: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdateJob>;
|
||||
setTuiDialogOpen: (open: boolean) => void;
|
||||
handleRescan: () => Promise<void>;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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<string, unknown>) => (
|
||||
<div {...props}>{children}</div>
|
||||
),
|
||||
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }) => <footer>{children}</footer>,
|
||||
DialogHeader: ({ children }: { children?: ReactNode }) => <header>{children}</header>,
|
||||
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/drawer", () => ({
|
||||
Drawer: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
|
||||
open ? <>{children}</> : null,
|
||||
DrawerContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DrawerDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
|
||||
DrawerFooter: ({ children }: { children?: ReactNode }) => <footer>{children}</footer>,
|
||||
DrawerHeader: ({ children }: { children?: ReactNode }) => <header>{children}</header>,
|
||||
DrawerTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
const AGENT_NAME = "claude-acp";
|
||||
const PACKAGE_NAME = "@agentclientprotocol/claude-agent-acp";
|
||||
const ACTIVE_VERSION = "0.70.0";
|
||||
|
||||
function preview(overrides: Partial<AgentUpdatePreview> = {}): 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(
|
||||
<AgentRuntimeUpdateControl
|
||||
agentName={AGENT_NAME}
|
||||
displayName="Claude Code"
|
||||
runtimeUpdate={{
|
||||
supported: true,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
}}
|
||||
onPreview={onPreview}
|
||||
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentRuntimeUpdateControl
|
||||
agentName={AGENT_NAME}
|
||||
displayName="Claude Code"
|
||||
runtimeUpdate={{
|
||||
supported: true,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
}}
|
||||
onPreview={onPreview}
|
||||
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentRuntimeUpdateControl
|
||||
agentName={AGENT_NAME}
|
||||
displayName="Claude Code"
|
||||
runtimeUpdate={{
|
||||
supported: true,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
}}
|
||||
runtimeUpdateStatus={{
|
||||
agent_name: AGENT_NAME,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
latest_version: "0.71.0",
|
||||
check_state: "update_available",
|
||||
}}
|
||||
onPreview={vi.fn().mockResolvedValue(preview())}
|
||||
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentRuntimeUpdateControl
|
||||
agentName={AGENT_NAME}
|
||||
displayName="Claude Code"
|
||||
runtimeUpdate={{
|
||||
supported: true,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
}}
|
||||
runtimeUpdateStatus={{
|
||||
agent_name: AGENT_NAME,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
check_state: "unknown",
|
||||
}}
|
||||
onPreview={onPreview}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentRuntimeUpdateControl
|
||||
agentName={AGENT_NAME}
|
||||
displayName="Claude Code"
|
||||
runtimeUpdate={{
|
||||
supported: true,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
}}
|
||||
runtimeUpdateStatus={{
|
||||
agent_name: AGENT_NAME,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
check_state: "unknown",
|
||||
}}
|
||||
onPreview={onPreview}
|
||||
onUpdate={vi.fn().mockResolvedValue(queuedJob())}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<AgentUpdateJob>();
|
||||
const update = vi.fn().mockImplementation(async () => {
|
||||
setJob(resetJob);
|
||||
return resetJob;
|
||||
});
|
||||
return (
|
||||
<AgentRuntimeUpdateControl
|
||||
agentName={AGENT_NAME}
|
||||
displayName="Claude Code"
|
||||
runtimeUpdate={{
|
||||
supported: true,
|
||||
package: PACKAGE_NAME,
|
||||
default_version: defaultVersion,
|
||||
active_version: ACTIVE_VERSION,
|
||||
effective_version: ACTIVE_VERSION,
|
||||
}}
|
||||
onPreview={onPreview}
|
||||
onUpdate={update}
|
||||
job={job}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<StatefulControl />);
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="space-y-1" data-testid={`agent-update-version-summary-${agentName}`}>
|
||||
<p className="font-medium">{t(runtimeOperationLabelKey(operation))}</p>
|
||||
<div className="space-y-0.5" data-testid={`agent-update-version-summary-${agentName}`}>
|
||||
<p className="font-medium" role={isUpToDate ? "status" : undefined}>
|
||||
{t(runtimeOperationLabelKey(operation))}
|
||||
</p>
|
||||
<p className="break-words font-mono text-sm">
|
||||
{isUpToDate ? currentVersion : `${currentVersion} → ${targetVersion}`}
|
||||
</p>
|
||||
{activeVersion && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("agents:activeRuntimeVersion", { version: activeVersion })}
|
||||
</p>
|
||||
)}
|
||||
{isUpToDate && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{t("agents:upToDate")}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
{activeVersion && <p>{t("agents:activeRuntimeVersion", { version: activeVersion })}</p>}
|
||||
<p>{t("agents:effectiveRuntimeVersion", { version: effectiveVersion })}</p>
|
||||
{preview.default_version && (
|
||||
<p>{t("agents:kandevDefaultVersion", { version: preview.default_version })}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<label className="font-medium" htmlFor={`agent-update-version-${agentName}`}>
|
||||
{t("agents:selectRuntimeVersion")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id={`agent-update-version-${agentName}`}
|
||||
className="h-11 w-full min-w-0 appearance-none rounded-md border bg-background px-3 pr-10 text-sm sm:h-10"
|
||||
value={selectedTarget || preview.target_version}
|
||||
onChange={(event) => onSelectTarget(event.target.value)}
|
||||
disabled={Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status)) || starting || loading}
|
||||
data-testid={`agent-update-version-${agentName}`}
|
||||
>
|
||||
{versions.map((version) => {
|
||||
const markers = [
|
||||
version.latest ? t("agents:latestRuntimeVersion") : "",
|
||||
version.version === activeVersion ? t("agents:activeRuntimeVersionMarker") : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const markerLabel = markers ? t("agents:runtimeVersionMarkerGroup", { markers }) : "";
|
||||
return (
|
||||
<option key={version.version} value={version.version}>
|
||||
{t("agents:runtimeVersionOption", {
|
||||
version: version.version,
|
||||
markers: markerLabel,
|
||||
})}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-0 right-3 flex items-center"
|
||||
data-testid={loading ? `agent-update-version-loading-${agentName}` : undefined}
|
||||
role={loading ? "status" : undefined}
|
||||
>
|
||||
{loading ? (
|
||||
<IconLoader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<IconChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
{loading && <span className="sr-only">{t("agents:checkingLatestRuntimeVersion")}</span>}
|
||||
</span>
|
||||
<>
|
||||
<RuntimeVersionSummary agentName={agentName} preview={preview} job={job} />
|
||||
<RuntimeVersionPicker
|
||||
agentName={agentName}
|
||||
preview={preview}
|
||||
selectedTarget={selectedTarget}
|
||||
selectedUseDefault={selectedUseDefault}
|
||||
loading={loading}
|
||||
starting={starting}
|
||||
job={job}
|
||||
onSelectTarget={onSelectTarget}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||
<p>{t("agents:runtimeUpdateExplainer")}</p>
|
||||
<p>{t("agents:runtimeUpdateSessionsNote")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">{t("agents:commandThatWillRun")}</p>
|
||||
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground">
|
||||
{preview.command_string}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,13 +195,15 @@ function UpdateBody({
|
||||
onRetryPreview,
|
||||
selectedTarget,
|
||||
onSelectTarget,
|
||||
selectedUseDefault,
|
||||
onSelectDefault,
|
||||
starting,
|
||||
}: UpdateBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const phase = updatePhase(t, job?.status);
|
||||
return (
|
||||
<div
|
||||
className="max-h-[calc(80dvh-10rem)] min-h-0 space-y-3 overflow-y-auto overscroll-contain px-4 py-3 text-xs/relaxed"
|
||||
className="max-h-[calc(92dvh-10rem)] min-h-0 space-y-2 overflow-y-auto overscroll-contain px-4 py-2 text-xs/normal sm:max-h-[calc(92dvh-8rem)]"
|
||||
data-testid={`agent-update-dialog-body-${agentName}`}
|
||||
>
|
||||
{loading && !preview && (
|
||||
@@ -243,7 +214,7 @@ function UpdateBody({
|
||||
)}
|
||||
{previewError && (
|
||||
<div
|
||||
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-3"
|
||||
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-2"
|
||||
role="alert"
|
||||
>
|
||||
<p className="flex items-start gap-2 text-destructive">
|
||||
@@ -257,7 +228,7 @@ function UpdateBody({
|
||||
)}
|
||||
{approveError && (
|
||||
<div
|
||||
className="rounded-md border border-destructive/30 bg-destructive/5 p-3"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/5 p-2"
|
||||
role="alert"
|
||||
data-testid={`agent-update-approve-error-${agentName}`}
|
||||
>
|
||||
@@ -268,28 +239,17 @@ function UpdateBody({
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<RuntimeVersionSummary agentName={agentName} preview={preview} job={job} />
|
||||
<RuntimeVersionSelector
|
||||
agentName={agentName}
|
||||
preview={preview}
|
||||
selectedTarget={selectedTarget}
|
||||
loading={loading}
|
||||
starting={starting}
|
||||
job={job}
|
||||
onSelectTarget={onSelectTarget}
|
||||
/>
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<p>{t("agents:runtimeUpdateExplainer")}</p>
|
||||
<p>{t("agents:runtimeUpdateSessionsNote")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t("agents:commandThatWillRun")}</p>
|
||||
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
|
||||
{preview.command_string}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
<RuntimeUpdatePreviewDetails
|
||||
agentName={agentName}
|
||||
preview={preview}
|
||||
selectedTarget={selectedTarget}
|
||||
selectedUseDefault={selectedUseDefault}
|
||||
loading={loading}
|
||||
starting={starting}
|
||||
job={job}
|
||||
onSelectTarget={onSelectTarget}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
)}
|
||||
{phase && (
|
||||
<p
|
||||
@@ -304,7 +264,7 @@ function UpdateBody({
|
||||
{job?.output && (
|
||||
<pre
|
||||
data-testid={`agent-update-log-${agentName}`}
|
||||
className="whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground"
|
||||
className="whitespace-pre-wrap break-words rounded-md bg-muted p-2 font-mono text-xs text-muted-foreground"
|
||||
>
|
||||
{job.output}
|
||||
</pre>
|
||||
@@ -374,57 +334,84 @@ function UpdateFooter({
|
||||
);
|
||||
if (mobile) {
|
||||
return (
|
||||
<DrawerFooter className="border-t pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<DrawerFooter className="border-t px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
{content}
|
||||
</DrawerFooter>
|
||||
);
|
||||
}
|
||||
return <DialogFooter className="border-t px-4 py-3">{content}</DialogFooter>;
|
||||
return <DialogFooter className="border-t px-4 py-2">{content}</DialogFooter>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={installInFlight ? 0 : -1} className="inline-flex">
|
||||
<span tabIndex={installInFlight ? 0 : -1} className="relative inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 cursor-pointer active:scale-95 sm:h-7 sm:w-7"
|
||||
aria-label={t(UPDATE_AGENT_KEY, { name: displayName })}
|
||||
aria-label={statusLabel}
|
||||
disabled={installInFlight}
|
||||
onClick={onOpen}
|
||||
data-testid={`agent-update-trigger-${agentName}`}
|
||||
>
|
||||
<IconRefresh className="size-4" />
|
||||
</Button>
|
||||
{runtimeUpdateStatus?.check_state === "update_available" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-0.5 top-0.5 size-2 rounded-full bg-sky-500 ring-2 ring-background"
|
||||
data-testid={`agent-update-available-dot-${agentName}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{installInFlight
|
||||
? t("agents:agentInstallationInProgress")
|
||||
: t(UPDATE_AGENT_KEY, { name: displayName })}
|
||||
{installInFlight ? t("agents:agentInstallationInProgress") : statusLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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<AgentUpdatePreview>;
|
||||
onUpdate: (agentName: string, targetVersion: string) => Promise<AgentUpdateJob>;
|
||||
onPreview: (
|
||||
agentName: string,
|
||||
targetVersion?: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdatePreview>;
|
||||
onUpdate: (
|
||||
agentName: string,
|
||||
targetVersion: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdateJob>;
|
||||
}) {
|
||||
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({
|
||||
<UpdateTrigger
|
||||
agentName={agentName}
|
||||
displayName={displayName}
|
||||
runtimeUpdateStatus={runtimeUpdateStatus}
|
||||
installInFlight={installInFlight}
|
||||
onOpen={() => handleOpenChange(true)}
|
||||
/>
|
||||
{isMobile ? (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent className="max-h-[80dvh]" data-testid={`agent-update-drawer-${agentName}`}>
|
||||
<DrawerHeader className="shrink-0 text-left">
|
||||
<DrawerTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DrawerTitle>
|
||||
<DrawerDescription>{t("agents:reviewUpdateBeforeApplying")}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
{footer(true)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[80dvh] gap-0 p-0 sm:max-w-2xl"
|
||||
data-testid={`agent-update-dialog-${agentName}`}
|
||||
>
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DialogTitle>
|
||||
<DialogDescription>{t("agents:reviewUpdateBeforeApplying")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
{footer()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<AgentRuntimeUpdateSurface
|
||||
agentName={agentName}
|
||||
displayName={displayName}
|
||||
isMobile={isMobile}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
body={body}
|
||||
footer={footer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent
|
||||
className="max-h-[92dvh] overflow-hidden data-[vaul-drawer-direction=bottom]:max-h-[92dvh]"
|
||||
data-testid={`agent-update-drawer-${agentName}`}
|
||||
>
|
||||
<DrawerHeader className="shrink-0 px-4 py-3 text-left">
|
||||
<DrawerTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DrawerTitle>
|
||||
<DrawerDescription>{t("agents:reviewUpdateBeforeApplying")}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
{footer(true)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[92dvh] gap-0 overflow-hidden p-0 sm:min-w-[40rem] sm:max-w-2xl"
|
||||
data-testid={`agent-update-dialog-${agentName}`}
|
||||
>
|
||||
<DialogHeader className="px-4 pb-1 pt-3">
|
||||
<DialogTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DialogTitle>
|
||||
<DialogDescription>{t("agents:reviewUpdateBeforeApplying")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
{footer()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<AgentUpdatePreview>;
|
||||
onUpdate?: (agentName: string, targetVersion: string) => Promise<AgentUpdateJob>;
|
||||
onPreview?: (
|
||||
agentName: string,
|
||||
targetVersion?: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdatePreview>;
|
||||
onUpdate?: (
|
||||
agentName: string,
|
||||
targetVersion: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdateJob>;
|
||||
/**
|
||||
* 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}
|
||||
|
||||
@@ -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<AgentUpdateJob["status"]>([
|
||||
"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 (
|
||||
<PopoverContent
|
||||
id={browserId}
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] p-0"
|
||||
data-testid={browserId}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Command className="h-auto min-h-0 w-full rounded-md bg-transparent" shouldFilter>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder={t("agents:searchRuntimeVersions")}
|
||||
aria-label={t("agents:searchRuntimeVersions")}
|
||||
/>
|
||||
<CommandList className="max-h-[min(18rem,var(--radix-popover-content-available-height))]">
|
||||
<CommandEmpty>{t("agents:noMatchingRuntimeVersions")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{versions.map((version) => {
|
||||
const isSelected = !selectedUseDefault && selectedVersion === version.version;
|
||||
return (
|
||||
<CommandItem
|
||||
key={version.version}
|
||||
value={version.version}
|
||||
className="min-h-11 cursor-pointer px-3 py-1.5 sm:min-h-10"
|
||||
aria-selected={isSelected}
|
||||
data-checked={isSelected}
|
||||
data-testid={`agent-update-version-option-${agentName}-${version.version}`}
|
||||
onSelect={() => onSelectTarget(version.version)}
|
||||
>
|
||||
<span className="font-mono">{version.version}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{versionLabel(t, version, activeVersion, defaultVersion).slice(
|
||||
version.version.length,
|
||||
)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t("agents:runtimeVersionQuickChoices")}</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedVersion === latestVersion ? "secondary" : "outline"}
|
||||
className="h-11 min-h-11 min-w-0 cursor-pointer justify-start px-2 text-left text-xs sm:h-10 sm:min-h-10"
|
||||
aria-pressed={selectedVersion === latestVersion}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelectTarget(latestVersion)}
|
||||
data-testid={`agent-update-quick-latest-${agentName}`}
|
||||
>
|
||||
{versionLabel(t, { version: latestVersion, latest: true }, activeVersion, defaultVersion)}
|
||||
</Button>
|
||||
{defaultVersion && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={selectedUseDefault ? "secondary" : "outline"}
|
||||
className="h-11 min-h-11 min-w-0 cursor-pointer justify-start px-2 text-left text-xs sm:h-10 sm:min-h-10"
|
||||
aria-pressed={selectedUseDefault}
|
||||
disabled={disabled}
|
||||
onClick={onSelectDefault}
|
||||
data-testid={`agent-update-quick-default-${agentName}`}
|
||||
>
|
||||
{t("agents:useKandevDefaultVersion", { version: defaultVersion })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2" data-testid={`agent-update-version-picker-${agentName}`}>
|
||||
<RuntimeVersionQuickChoices
|
||||
agentName={agentName}
|
||||
latestVersion={latestVersion}
|
||||
activeVersion={activeVersion}
|
||||
defaultVersion={preview.default_version}
|
||||
selectedVersion={selectedVersion}
|
||||
selectedUseDefault={selectedUseDefault}
|
||||
disabled={disabled}
|
||||
onSelectTarget={selectTarget}
|
||||
onSelectDefault={selectDefault}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t("agents:selectRuntimeVersion")}</p>
|
||||
<Popover modal={false} open={browseOpen} onOpenChange={setBrowseOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-12 w-full cursor-pointer justify-between px-3"
|
||||
aria-expanded={browseOpen}
|
||||
aria-controls={browserId}
|
||||
disabled={disabled}
|
||||
data-testid={`agent-update-version-${agentName}`}
|
||||
>
|
||||
<span>{t("agents:browseRuntimeVersions")}</span>
|
||||
{loading ? (
|
||||
<span
|
||||
className="flex items-center"
|
||||
data-testid={`agent-update-version-loading-${agentName}`}
|
||||
role="status"
|
||||
>
|
||||
<IconLoader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="sr-only">{t("agents:checkingLatestRuntimeVersion")}</span>
|
||||
</span>
|
||||
) : (
|
||||
<IconChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<RuntimeVersionBrowser
|
||||
agentName={agentName}
|
||||
browserId={browserId}
|
||||
versions={versions}
|
||||
activeVersion={activeVersion}
|
||||
defaultVersion={preview.default_version}
|
||||
selectedVersion={selectedVersion}
|
||||
selectedUseDefault={selectedUseDefault}
|
||||
onSelectTarget={selectTarget}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -10,10 +10,129 @@ function errorMessage(error: unknown) {
|
||||
type UseAgentUpdateDialogStateOptions = {
|
||||
agentName: string;
|
||||
job?: AgentUpdateJob;
|
||||
onPreview: (agentName: string, targetVersion?: string) => Promise<AgentUpdatePreview>;
|
||||
onUpdate: (agentName: string, targetVersion: string) => Promise<AgentUpdateJob>;
|
||||
onPreview: (
|
||||
agentName: string,
|
||||
targetVersion?: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdatePreview>;
|
||||
onUpdate: (
|
||||
agentName: string,
|
||||
targetVersion: string,
|
||||
useDefault?: boolean,
|
||||
) => Promise<AgentUpdateJob>;
|
||||
};
|
||||
|
||||
// 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<void>;
|
||||
|
||||
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<string | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<typeof import("@/lib/api")>()),
|
||||
listAgentUpdateStatuses: (...args: unknown[]) => listAgentUpdateStatusesMock(...args),
|
||||
}));
|
||||
|
||||
import { useAgentRuntimeUpdateStatuses } from "./use-agent-runtime-update-statuses";
|
||||
|
||||
function job(overrides: Partial<AgentUpdateJob> = {}): 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<string, AgentUpdateJob> },
|
||||
});
|
||||
|
||||
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<string, AgentUpdateJob> },
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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<string, AgentUpdateStatus>;
|
||||
|
||||
function indexStatuses(statuses: AgentUpdateStatus[]): StatusByAgent {
|
||||
return Object.fromEntries(statuses.map((status) => [status.agent_name, status]));
|
||||
}
|
||||
|
||||
export function useAgentRuntimeUpdateStatuses(updateJobs: Record<string, AgentUpdateJob>) {
|
||||
const [statusByAgent, setStatusByAgent] = useState<StatusByAgent>({});
|
||||
const observedSuccessfulJobs = useRef(new Set<string>());
|
||||
const pendingSuccessfulJobs = useRef(new Set<string>());
|
||||
|
||||
const refresh = useCallback(async (): Promise<boolean> => {
|
||||
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 };
|
||||
}
|
||||
@@ -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" }),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof fetch>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AgentUpdatePreview> {
|
||||
return fetchJson<AgentUpdatePreview>(
|
||||
`/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<AgentUpdateJob> {
|
||||
return fetchJson<AgentUpdateJob>(`/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[] }> {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Śēàŕćĥ ḿōďēś...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "搜索模式…",
|
||||
|
||||
@@ -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": "搜尋模式…",
|
||||
|
||||
@@ -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": "搜尋模式…",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <package>`
|
||||
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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
+72
@@ -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 <package> 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.
|
||||
@@ -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.<agent-id>` 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user