fix: handle same-version agent runtime updates (#2120)
This commit is contained in:
@@ -131,19 +131,25 @@ func (s *AgentUpdateJobStore) run(
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
s.setStatus(job, dto.AgentUpdateJobStatusResolving)
|
s.setStatus(job, dto.AgentUpdateJobStatusResolving)
|
||||||
|
|
||||||
if caps, ok := s.updater.CurrentCapabilities(job.AgentName); ok {
|
|
||||||
s.mu.Lock()
|
|
||||||
job.CurrentVersion = caps.AgentVersion
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
target, err := s.updater.ResolveTarget(ctx, spec.Package)
|
target, err := s.updater.ResolveTarget(ctx, spec.Package)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.finishFailed(job, ctx, fmt.Errorf("resolve target version: %w", err), ref)
|
s.finishFailed(job, ctx, fmt.Errorf("resolve target version: %w", err), ref)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
currentVersion := ""
|
||||||
|
if caps, ok := s.updater.CurrentCapabilities(job.AgentName); ok {
|
||||||
|
currentVersion = caps.AgentVersion
|
||||||
|
s.mu.Lock()
|
||||||
|
job.CurrentVersion = currentVersion
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
job.TargetVersion = target
|
job.TargetVersion = target
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if currentVersion != "" && currentVersion == target {
|
||||||
|
s.finishAlreadyUpToDate(job, ref)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.setStatus(job, dto.AgentUpdateJobStatusUpdating)
|
s.setStatus(job, dto.AgentUpdateJobStatusUpdating)
|
||||||
flusher := newUpdateOutputFlusher(s, job)
|
flusher := newUpdateOutputFlusher(s, job)
|
||||||
@@ -201,6 +207,18 @@ func (s *AgentUpdateJobStore) finishFailed(
|
|||||||
s.scheduleEviction(job.ID)
|
s.scheduleEviction(job.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AgentUpdateJobStore) finishAlreadyUpToDate(job *AgentUpdateJob, ref MaintenanceJobRef) {
|
||||||
|
s.mu.Lock()
|
||||||
|
job.Status = dto.AgentUpdateJobStatusSucceeded
|
||||||
|
_, _ = job.Output.Write([]byte("Runtime already up to date.\n"))
|
||||||
|
s.maintenance.release(job.AgentName, ref)
|
||||||
|
s.finishLocked(job)
|
||||||
|
snapshot := job.snapshot()
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.broadcast(ws.ActionAgentUpdateFinished, snapshot)
|
||||||
|
s.scheduleEviction(job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AgentUpdateJobStore) finishRefresh(
|
func (s *AgentUpdateJobStore) finishRefresh(
|
||||||
job *AgentUpdateJob,
|
job *AgentUpdateJob,
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -364,6 +364,37 @@ func TestAgentUpdateJobResolvesUpdatesRefreshesAndStreams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentUpdateJobSkipsCommandWhenRuntimeIsAlreadyUpToDate(t *testing.T) {
|
||||||
|
updater := &fakeRuntimeUpdater{
|
||||||
|
current: hostutility.AgentCapabilities{AgentVersion: "1.1.0"},
|
||||||
|
currentFound: true,
|
||||||
|
target: "1.1.0",
|
||||||
|
refreshCaps: hostutility.AgentCapabilities{Status: hostutility.StatusOK, AgentVersion: "1.1.0"},
|
||||||
|
}
|
||||||
|
store, completed := newUpdateTestStore(updater, newMaintenanceCoordinator(), nil)
|
||||||
|
|
||||||
|
job, err := store.Enqueue("managed-acp", managedRuntimeSpec())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
final := waitForUpdateStatus(t, completed, job.ID, dto.AgentUpdateJobStatusSucceeded)
|
||||||
|
if 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 != "Runtime already up to date.\n" {
|
||||||
|
t.Fatalf("Output = %q, want no-op message", final.Output)
|
||||||
|
}
|
||||||
|
|
||||||
|
updater.mu.Lock()
|
||||||
|
defer updater.mu.Unlock()
|
||||||
|
if updater.runCalls != 0 {
|
||||||
|
t.Fatalf("update calls = %d, want 0", updater.runCalls)
|
||||||
|
}
|
||||||
|
if updater.refreshCalls != 0 {
|
||||||
|
t.Fatalf("refresh calls = %d, want 0", updater.refreshCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentUpdateAuthRequiredIsPackageSuccessWithRefreshError(t *testing.T) {
|
func TestAgentUpdateAuthRequiredIsPackageSuccessWithRefreshError(t *testing.T) {
|
||||||
updater := &fakeRuntimeUpdater{
|
updater := &fakeRuntimeUpdater{
|
||||||
target: "1.1.0",
|
target: "1.1.0",
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import {
|
|||||||
} from "@kandev/ui/drawer";
|
} from "@kandev/ui/drawer";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||||
|
import {
|
||||||
|
canApproveAgentRuntimeUpdate,
|
||||||
|
resolveRuntimeVersionPair,
|
||||||
|
} from "@/lib/agent-runtime-update";
|
||||||
import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
|
import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
|
||||||
import type { RuntimeUpdate } from "@/lib/types/http";
|
import type { RuntimeUpdate } from "@/lib/types/http";
|
||||||
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
|
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
|
||||||
@@ -48,6 +52,7 @@ function updatePhase(status: AgentUpdateJob["status"] | undefined): string | nul
|
|||||||
|
|
||||||
function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) {
|
function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) {
|
||||||
if (!job) return null;
|
if (!job) return null;
|
||||||
|
const { versionsMatch } = resolveRuntimeVersionPair(null, job);
|
||||||
if (job.status === "succeeded" && job.refresh_error) {
|
if (job.status === "succeeded" && job.refresh_error) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
@@ -66,7 +71,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
|
|||||||
role="status"
|
role="status"
|
||||||
data-testid={`agent-update-result-${agentName}`}
|
data-testid={`agent-update-result-${agentName}`}
|
||||||
>
|
>
|
||||||
Runtime updated successfully.
|
{versionsMatch ? "Runtime already up to date." : "Runtime updated successfully."}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,6 +99,30 @@ type UpdateBodyProps = {
|
|||||||
onRetryPreview: () => void;
|
onRetryPreview: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RuntimeVersionSummary({
|
||||||
|
preview,
|
||||||
|
job,
|
||||||
|
}: {
|
||||||
|
preview: AgentUpdatePreview;
|
||||||
|
job?: AgentUpdateJob;
|
||||||
|
}) {
|
||||||
|
const { currentVersion, targetVersion, versionsMatch } = resolveRuntimeVersionPair(preview, job);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">Runtime version</p>
|
||||||
|
<p className="break-words font-mono text-sm">
|
||||||
|
{versionsMatch ? currentVersion : `${currentVersion} → ${targetVersion}`}
|
||||||
|
</p>
|
||||||
|
{versionsMatch && (
|
||||||
|
<p className="text-sm text-muted-foreground" role="status">
|
||||||
|
Up to date
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UpdateBody({
|
function UpdateBody({
|
||||||
agentName,
|
agentName,
|
||||||
preview,
|
preview,
|
||||||
@@ -143,14 +172,7 @@ function UpdateBody({
|
|||||||
)}
|
)}
|
||||||
{preview && (
|
{preview && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<RuntimeVersionSummary preview={preview} job={job} />
|
||||||
<p className="font-medium">Runtime version</p>
|
|
||||||
<p className="break-words font-mono text-sm">
|
|
||||||
{(job?.current_version || preview.current_version || "Unknown") +
|
|
||||||
" → " +
|
|
||||||
(job?.target_version || preview.target_version)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-muted-foreground">
|
<div className="space-y-1 text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
This updates the managed runtime on this Kandev host, then refreshes its models and
|
This updates the managed runtime on this Kandev host, then refreshes its models and
|
||||||
@@ -205,31 +227,6 @@ type UpdateFooterProps = {
|
|||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function canApproveUpdate({
|
|
||||||
preview,
|
|
||||||
previewError,
|
|
||||||
loading,
|
|
||||||
updateInFlight,
|
|
||||||
starting,
|
|
||||||
installInFlight,
|
|
||||||
}: {
|
|
||||||
preview: AgentUpdatePreview | null;
|
|
||||||
previewError: string | null;
|
|
||||||
loading: boolean;
|
|
||||||
updateInFlight: boolean;
|
|
||||||
starting: boolean;
|
|
||||||
installInFlight: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
Boolean(preview?.current_version) &&
|
|
||||||
!previewError &&
|
|
||||||
!loading &&
|
|
||||||
!updateInFlight &&
|
|
||||||
!starting &&
|
|
||||||
!installInFlight
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpdateFooter({
|
function UpdateFooter({
|
||||||
agentName,
|
agentName,
|
||||||
preview,
|
preview,
|
||||||
@@ -244,8 +241,9 @@ function UpdateFooter({
|
|||||||
}: UpdateFooterProps) {
|
}: UpdateFooterProps) {
|
||||||
const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status));
|
const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status));
|
||||||
const canRetry = job?.status === "failed";
|
const canRetry = job?.status === "failed";
|
||||||
const canApprove = canApproveUpdate({
|
const canApprove = canApproveAgentRuntimeUpdate({
|
||||||
preview,
|
preview,
|
||||||
|
job,
|
||||||
previewError,
|
previewError,
|
||||||
loading,
|
loading,
|
||||||
updateInFlight,
|
updateInFlight,
|
||||||
|
|||||||
@@ -104,6 +104,59 @@ test.describe("managed agent runtime updates", () => {
|
|||||||
expect(runtime.postCount()).toBe(0);
|
expect(runtime.postCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows an up-to-date preview and disables approval when versions match", async ({
|
||||||
|
testPage,
|
||||||
|
prCapture,
|
||||||
|
}) => {
|
||||||
|
const runtime = await installRuntimeUpdateFixture(testPage, {
|
||||||
|
previewResponse: {
|
||||||
|
agent_name: "claude-acp",
|
||||||
|
package: "@agentclientprotocol/claude-agent-acp",
|
||||||
|
current_version: "0.64.0",
|
||||||
|
target_version: "0.64.0",
|
||||||
|
command: ["npm", "exec"],
|
||||||
|
command_string: "npm exec",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await testPage.goto("/settings/agents");
|
||||||
|
await testPage.getByTestId(`agent-update-trigger-${runtime.agentName}`).click();
|
||||||
|
|
||||||
|
const dialog = testPage.getByTestId(`agent-update-dialog-${runtime.agentName}`);
|
||||||
|
await expect(dialog.getByText("0.64.0", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Up to date", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog).not.toContainText("0.64.0 → 0.64.0");
|
||||||
|
await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toBeDisabled();
|
||||||
|
expect(runtime.postCount()).toBe(0);
|
||||||
|
await prCapture.screenshot("desktop-update-up-to-date", {
|
||||||
|
caption: "Desktop update preview when the managed runtime is up to date",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the job versions for a stale retry decision", async ({ testPage }) => {
|
||||||
|
const runtime = await installRuntimeUpdateFixture(testPage, {
|
||||||
|
postResponse: updateJob({
|
||||||
|
status: "failed",
|
||||||
|
current_version: "0.63.0",
|
||||||
|
target_version: "0.63.0",
|
||||||
|
error: "The package registry is unavailable",
|
||||||
|
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}`);
|
||||||
|
const retryUpdate = testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`);
|
||||||
|
await retryUpdate.click();
|
||||||
|
|
||||||
|
await expect(dialog.getByText("0.63.0", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByText("Up to date", { exact: true })).toBeVisible();
|
||||||
|
await expect(retryUpdate).toHaveText("Retry update");
|
||||||
|
await expect(retryUpdate).toBeDisabled();
|
||||||
|
expect(runtime.postCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("retries an update after its job fails", async ({ testPage }) => {
|
test("retries an update after its job fails", async ({ testPage }) => {
|
||||||
const runtime = await installRuntimeUpdateFixture(testPage);
|
const runtime = await installRuntimeUpdateFixture(testPage);
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,35 @@ test.describe("managed agent runtime updates on mobile", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows an up-to-date preview and disables approval when versions match", async ({
|
||||||
|
testPage,
|
||||||
|
prCapture,
|
||||||
|
}) => {
|
||||||
|
const runtime = await installRuntimeUpdateFixture(testPage, {
|
||||||
|
previewResponse: {
|
||||||
|
agent_name: "claude-acp",
|
||||||
|
package: "@agentclientprotocol/claude-agent-acp",
|
||||||
|
current_version: "0.64.0",
|
||||||
|
target_version: "0.64.0",
|
||||||
|
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 expect(drawer.getByText("0.64.0", { exact: true })).toBeVisible();
|
||||||
|
await expect(drawer.getByText("Up to date", { exact: true })).toBeVisible();
|
||||||
|
await expect(drawer).not.toContainText("0.64.0 → 0.64.0");
|
||||||
|
await expect(testPage.getByTestId(`agent-update-confirm-${runtime.agentName}`)).toBeDisabled();
|
||||||
|
expect(runtime.postCount()).toBe(0);
|
||||||
|
await prCapture.screenshot("mobile-update-up-to-date", {
|
||||||
|
caption: "Mobile update preview when the managed runtime is up to date",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps retry available after an update job fails", async ({ testPage }) => {
|
test("keeps retry available after an update job fails", async ({ testPage }) => {
|
||||||
const runtime = await installRuntimeUpdateFixture(testPage);
|
const runtime = await installRuntimeUpdateFixture(testPage);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
|
||||||
|
import { canApproveAgentRuntimeUpdate, resolveRuntimeVersionPair } from "./agent-runtime-update";
|
||||||
|
|
||||||
|
const preview = (overrides: Partial<AgentUpdatePreview> = {}): AgentUpdatePreview => ({
|
||||||
|
agent_name: "claude-acp",
|
||||||
|
package: "@agentclientprotocol/claude-agent-acp",
|
||||||
|
current_version: "0.62.0",
|
||||||
|
target_version: "0.63.0",
|
||||||
|
command: ["npm", "exec"],
|
||||||
|
command_string: "npm exec",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = (overrides: Partial<AgentUpdateJob> = {}): AgentUpdateJob => ({
|
||||||
|
job_id: "runtime-update-job-1",
|
||||||
|
agent_name: "claude-acp",
|
||||||
|
status: "failed",
|
||||||
|
started_at: "2026-07-26T12:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRuntimeVersionPair", () => {
|
||||||
|
it("uses job versions when a retry has newer resolved values", () => {
|
||||||
|
expect(
|
||||||
|
resolveRuntimeVersionPair(
|
||||||
|
preview(),
|
||||||
|
job({ current_version: "0.63.0", target_version: "0.63.0" }),
|
||||||
|
),
|
||||||
|
).toEqual({ currentVersion: "0.63.0", targetVersion: "0.63.0", versionsMatch: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to preview versions before a job exists", () => {
|
||||||
|
expect(resolveRuntimeVersionPair(preview())).toEqual({
|
||||||
|
currentVersion: "0.62.0",
|
||||||
|
targetVersion: "0.63.0",
|
||||||
|
versionsMatch: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canApproveAgentRuntimeUpdate", () => {
|
||||||
|
const ready = {
|
||||||
|
preview: preview(),
|
||||||
|
job: undefined,
|
||||||
|
previewError: null,
|
||||||
|
loading: false,
|
||||||
|
updateInFlight: false,
|
||||||
|
starting: false,
|
||||||
|
installInFlight: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["differing versions", ready, true],
|
||||||
|
["equal versions", { ...ready, preview: preview({ target_version: "0.62.0" }) }, false],
|
||||||
|
["missing current version", { ...ready, preview: preview({ current_version: "" }) }, false],
|
||||||
|
["missing target version", { ...ready, preview: preview({ target_version: "" }) }, false],
|
||||||
|
["loading preview", { ...ready, loading: true }, false],
|
||||||
|
["preview error", { ...ready, previewError: "preview failed" }, false],
|
||||||
|
["update in flight", { ...ready, updateInFlight: true }, false],
|
||||||
|
["starting update", { ...ready, starting: true }, false],
|
||||||
|
["install in flight", { ...ready, installInFlight: true }, false],
|
||||||
|
[
|
||||||
|
"stale retry with equal job versions",
|
||||||
|
{ ...ready, job: job({ current_version: "0.63.0", target_version: "0.63.0" }) },
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
])("returns %s = %s", (_name, state, expected) => {
|
||||||
|
expect(canApproveAgentRuntimeUpdate(state)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
|
||||||
|
|
||||||
|
export type RuntimeVersionPair = {
|
||||||
|
currentVersion: string;
|
||||||
|
targetVersion?: string;
|
||||||
|
versionsMatch: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveRuntimeVersionPair(
|
||||||
|
preview: AgentUpdatePreview | null,
|
||||||
|
job?: AgentUpdateJob,
|
||||||
|
): RuntimeVersionPair {
|
||||||
|
const currentVersion = job?.current_version || preview?.current_version || "Unknown";
|
||||||
|
const targetVersion = job?.target_version || preview?.target_version;
|
||||||
|
const versionsMatch = Boolean(
|
||||||
|
targetVersion && currentVersion !== "Unknown" && currentVersion === targetVersion,
|
||||||
|
);
|
||||||
|
return { currentVersion, targetVersion, versionsMatch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canApproveAgentRuntimeUpdate({
|
||||||
|
preview,
|
||||||
|
job,
|
||||||
|
previewError,
|
||||||
|
loading,
|
||||||
|
updateInFlight,
|
||||||
|
starting,
|
||||||
|
installInFlight,
|
||||||
|
}: {
|
||||||
|
preview: AgentUpdatePreview | null;
|
||||||
|
job?: AgentUpdateJob;
|
||||||
|
previewError: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
updateInFlight: boolean;
|
||||||
|
starting: boolean;
|
||||||
|
installInFlight: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const { currentVersion, targetVersion } = resolveRuntimeVersionPair(preview, job);
|
||||||
|
return (
|
||||||
|
Boolean(
|
||||||
|
preview && currentVersion !== "Unknown" && targetVersion && currentVersion !== targetVersion,
|
||||||
|
) &&
|
||||||
|
!previewError &&
|
||||||
|
!loading &&
|
||||||
|
!updateInFlight &&
|
||||||
|
!starting &&
|
||||||
|
!installInFlight
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
spec: docs/specs/agents/runtime-updates.md
|
||||||
|
created: 2026-07-31
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fix Plan: Handle Same-Version Agent Runtime Updates
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The update preview already reports both the installed and upstream versions,
|
||||||
|
but the shared approval predicate checks only that the current version is
|
||||||
|
present. Render an identical target as one version with an **Up to date**
|
||||||
|
status, tighten the predicate so that preview is non-actionable, then cover the
|
||||||
|
shared desktop dialog and mobile drawer and document the no-op state.
|
||||||
|
|
||||||
|
## Confirmed root cause
|
||||||
|
|
||||||
|
`canApproveUpdate` in
|
||||||
|
`apps/web/components/settings/agent-runtime-update-control.tsx` returns true for
|
||||||
|
any successful preview with a non-empty `current_version`. It never requires a
|
||||||
|
non-empty `target_version` or compares the two values, so a preview such as
|
||||||
|
`0.64.0 → 0.64.0` both suggests a transition that does not exist and enables
|
||||||
|
**Approve update**, which can send the update POST.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Shared approval predicate
|
||||||
|
|
||||||
|
Update `canApproveUpdate` so approval requires non-empty current and target
|
||||||
|
versions whose reported values differ, in addition to the existing preview,
|
||||||
|
loading, maintenance, and request-state guards. Keep the normal upgrade and
|
||||||
|
failed-job retry paths unchanged when current and target versions differ.
|
||||||
|
|
||||||
|
Update the version summary in `UpdateBody` so identical reported values render
|
||||||
|
the version once with a visible **Up to date** status and no arrow. Differing
|
||||||
|
versions continue to render the existing `current → target` transition.
|
||||||
|
|
||||||
|
### Desktop and mobile behavior
|
||||||
|
|
||||||
|
The existing desktop `Dialog` and phone `Drawer` both render `UpdateFooter`, so
|
||||||
|
the state rule remains shared rather than duplicated. This fix does not change
|
||||||
|
composition, navigation, scrolling, safe-area behavior, or touch targets. The
|
||||||
|
nearest mobile exemplar remains the shipped agent runtime update drawer in
|
||||||
|
`mobile-agent-runtime-update.spec.ts`; focused mobile Playwright coverage will
|
||||||
|
prove that the same-version preview is visible and its shared approval action
|
||||||
|
is disabled without sending a POST.
|
||||||
|
|
||||||
|
### Public documentation
|
||||||
|
|
||||||
|
Update `docs/public/agents-and-profiles.md` to say that a matching target is
|
||||||
|
shown once as **Up to date** and **Approve update** is available only when the
|
||||||
|
upstream target differs from the reported current version.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **What:** A successful preview with identical non-empty current and target
|
||||||
|
versions shows a single version with **Up to date** and cannot start an
|
||||||
|
update.
|
||||||
|
**File:** `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts`.
|
||||||
|
**How:** Configure the existing route fixture with `0.64.0` for both values,
|
||||||
|
open the desktop dialog, assert the single-version summary, **Up to date**
|
||||||
|
status, absence of the arrow transition, disabled confirmation button, and a
|
||||||
|
zero POST count.
|
||||||
|
- **What:** Existing differing-version approval and failed-job retry behavior
|
||||||
|
stays enabled.
|
||||||
|
**File:** Existing scenarios in
|
||||||
|
`apps/web/e2e/tests/settings/agent-runtime-update.spec.ts` and
|
||||||
|
`apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`.
|
||||||
|
**How:** Run both focused spec files after the predicate change.
|
||||||
|
- **What:** Public documentation remains structurally valid.
|
||||||
|
**File:** `docs/public/agents-and-profiles.md`.
|
||||||
|
**How:** Run the public-doc validators.
|
||||||
|
|
||||||
|
No new public function or API contract is introduced; the private version
|
||||||
|
resolution helper and approval predicate are covered by focused unit tests and
|
||||||
|
the rendered shared action is exercised through the mocked update boundary.
|
||||||
|
|
||||||
|
## E2E Tests
|
||||||
|
|
||||||
|
- **Desktop scenario:** **GIVEN** current and target are both `0.64.0`, **WHEN**
|
||||||
|
the operator opens the update dialog, **THEN** it shows `0.64.0` once with
|
||||||
|
**Up to date**, omits the arrow transition, disables **Approve update**, and
|
||||||
|
sends no update request.
|
||||||
|
**File:** `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts`.
|
||||||
|
- **Mobile scenario:** **GIVEN** the same preview on a phone viewport, **WHEN**
|
||||||
|
the operator opens the update drawer, **THEN** it uses the same single-version
|
||||||
|
**Up to date** presentation, the shared approval action is disabled, and no
|
||||||
|
update request is sent.
|
||||||
|
**File:**
|
||||||
|
`apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`.
|
||||||
|
|
||||||
|
## Implementation Wave
|
||||||
|
|
||||||
|
- [x] [Task 01: Disable same-version approval](task-01-disable-same-version-approval.md) — done
|
||||||
|
|
||||||
|
Execution is sequential in the primary conversation. No subagents are
|
||||||
|
authorized.
|
||||||
|
|
||||||
|
## Risks and out of scope
|
||||||
|
|
||||||
|
- Equality is based on the two reported preview strings. Normalizing alternate
|
||||||
|
SemVer spellings such as `v0.64.0` versus `0.64.0` is out of scope.
|
||||||
|
- The update icon remains available so operators can open a fresh read-only
|
||||||
|
preview; this change disables only the confirmation action after equality is
|
||||||
|
known.
|
||||||
|
|
||||||
|
## Stale preview guard
|
||||||
|
|
||||||
|
The backend resolves the target and then re-reads the current runtime version
|
||||||
|
before invoking the package command. If the versions now match, it completes a
|
||||||
|
successful no-op job with an explicit message and skips the update and refresh
|
||||||
|
commands.
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
id: "01-disable-same-version-approval"
|
||||||
|
title: "Disable same-version approval"
|
||||||
|
status: done
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
plan: "plan.md"
|
||||||
|
spec: "../../specs/agents/runtime-updates.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 01: Disable same-version approval
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- A resolved preview with identical non-empty current and target versions shows
|
||||||
|
the version once with **Up to date**, omits the arrow transition, keeps
|
||||||
|
**Approve update** disabled, and sends no update POST.
|
||||||
|
- Differing-version approval and failed-job retry remain enabled.
|
||||||
|
- Desktop and mobile use the same predicate, and the public Agents guide
|
||||||
|
explains the unavailable approval state.
|
||||||
|
- A stale preview cannot invoke a package command after the backend resolves
|
||||||
|
that the runtime is already up to date.
|
||||||
|
|
||||||
|
## TDD sequence
|
||||||
|
|
||||||
|
1. Add the equal-version desktop and mobile Playwright scenarios and run each
|
||||||
|
focused test to confirm it fails because the UI shows an arrow transition
|
||||||
|
and the confirmation button is enabled.
|
||||||
|
2. Render one version plus **Up to date**, then tighten `canApproveUpdate` with
|
||||||
|
the minimal target-presence and inequality checks.
|
||||||
|
3. Run both runtime-update spec files to prove the new regression and existing
|
||||||
|
enabled paths.
|
||||||
|
4. Update and validate the public documentation.
|
||||||
|
5. Revalidate the current runtime version in the backend before executing a
|
||||||
|
package command and cover the no-op job path with a Go unit test.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- From `apps/` in a fresh worktree, install dependencies:
|
||||||
|
`pnpm install --frozen-lockfile`
|
||||||
|
- Desktop E2E:
|
||||||
|
`pnpm --filter @kandev/web e2e:run --host tests/settings/agent-runtime-update.spec.ts`
|
||||||
|
- Mobile E2E:
|
||||||
|
`pnpm --filter @kandev/web e2e:run --host tests/settings/mobile-agent-runtime-update.spec.ts -- --project=mobile-chrome`
|
||||||
|
- Frontend typecheck:
|
||||||
|
`pnpm --filter @kandev/web run typecheck`
|
||||||
|
- Frontend unit tests:
|
||||||
|
`pnpm --filter @kandev/web test -- --run lib/agent-runtime-update.test.ts`
|
||||||
|
- Backend unit test:
|
||||||
|
`cd ../apps/backend && go test -run TestAgentUpdateJobSkipsCommandWhenRuntimeIsAlreadyUpToDate ./internal/agent/settings/controller`
|
||||||
|
- Public docs:
|
||||||
|
`node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs`
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
- `apps/web/components/settings/agent-runtime-update-control.tsx`
|
||||||
|
- `apps/web/e2e/tests/settings/agent-runtime-update.spec.ts`
|
||||||
|
- `apps/web/e2e/tests/settings/mobile-agent-runtime-update.spec.ts`
|
||||||
|
- `apps/web/lib/agent-runtime-update.ts`
|
||||||
|
- `apps/web/lib/agent-runtime-update.test.ts`
|
||||||
|
- `apps/backend/internal/agent/settings/controller/agent_update_job.go`
|
||||||
|
- `apps/backend/internal/agent/settings/controller/agent_update_test.go`
|
||||||
|
- `docs/public/agents-and-profiles.md`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Parallelism
|
||||||
|
|
||||||
|
Sequential. The E2E scenarios depend on the same shared component change and
|
||||||
|
the task is one focused behavior slice.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Equal-version behavior in `docs/specs/agents/runtime-updates.md`
|
||||||
|
- Shared body presentation and footer state in
|
||||||
|
`apps/web/components/settings/agent-runtime-update-control.tsx`
|
||||||
|
- Existing desktop and mobile runtime-update fixtures and scenarios
|
||||||
|
- Mobile parity contract in `plan.md`
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Preserve failed-job retries when preview versions differ.
|
||||||
|
- Do not disable the card's update icon before a fresh preview has resolved.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
Report RED/GREEN evidence, changed files, desktop/mobile results, docs
|
||||||
|
validation, any blockers or risks, and update this task plus `plan.md` status in
|
||||||
|
the same conversation.
|
||||||
@@ -36,7 +36,12 @@ release.
|
|||||||
Select the update icon to deliberately check npm and refresh the managed
|
Select the update icon to deliberately check npm and refresh the managed
|
||||||
runtime on the Kandev host. Before anything changes, the update dialog shows
|
runtime on the Kandev host. Before anything changes, the update dialog shows
|
||||||
the current and upstream target versions, the exact command Kandev will run,
|
the current and upstream target versions, the exact command Kandev will run,
|
||||||
and how the update affects sessions. Select **Approve update** to start it.
|
and how the update affects sessions. When the reported current and target
|
||||||
|
versions match, the dialog shows the version once as **Up to date** and keeps
|
||||||
|
**Approve update** disabled. After a successful preview provides both current
|
||||||
|
and target versions and they differ, select **Approve update** to start it;
|
||||||
|
the action stays disabled when either version is missing, the preview is
|
||||||
|
loading, or the preview has an error.
|
||||||
The dialog streams progress and stdout/stderr until the update finishes; those
|
The dialog streams progress and stdout/stderr until the update finishes; those
|
||||||
details do not appear on the agent card and are cleared when you restart the
|
details do not appear on the agent card and are cleared when you restart the
|
||||||
page. After the package update, Kandev automatically starts a fresh ACP
|
page. After the package update, Kandev automatically starts a fresh ACP
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ cache is already present.
|
|||||||
resolves and shows the current runtime version, upstream target version,
|
resolves and shows the current runtime version, upstream target version,
|
||||||
exact built-in command, host-only scope, capability refresh, and active
|
exact built-in command, host-only scope, capability refresh, and active
|
||||||
session behavior before an update can start.
|
session behavior before an update can start.
|
||||||
|
- When the resolved current and upstream target versions are identical, the
|
||||||
|
update dialog shows the version once with an **Up to date** status and keeps
|
||||||
|
approval disabled because running the package update would not advance the
|
||||||
|
managed runtime.
|
||||||
- The update starts only after the operator approves it in the dialog. The
|
- The update starts only after the operator approves it in the dialog. The
|
||||||
dialog then shows live stdout/stderr, progress, and the terminal success or
|
dialog then shows live stdout/stderr, progress, and the terminal success or
|
||||||
failure state.
|
failure state.
|
||||||
@@ -199,6 +203,11 @@ Jobs are terminal after `succeeded` or `failed`. Retrying creates a new job.
|
|||||||
**WHEN** the operator opens its update action, **THEN** the dialog shows
|
**WHEN** the operator opens its update action, **THEN** the dialog shows
|
||||||
`0.62.0 → 0.63.0`, the exact built-in command, and how the host update
|
`0.62.0 → 0.63.0`, the exact built-in command, and how the host update
|
||||||
affects capabilities and active sessions without starting the command.
|
affects capabilities and active sessions without starting the command.
|
||||||
|
- **GIVEN** the current runtime and upstream target both report version
|
||||||
|
`0.64.0`, **WHEN** the operator opens the update action, **THEN** the dialog
|
||||||
|
shows `0.64.0` once with **Up to date**, does not show a version transition,
|
||||||
|
keeps **Approve update** disabled on desktop and mobile, and starts no update
|
||||||
|
job.
|
||||||
- **GIVEN** the update dialog has a resolved preview, **WHEN** the operator
|
- **GIVEN** the update dialog has a resolved preview, **WHEN** the operator
|
||||||
approves the update, **THEN** Kandev starts exactly one update and the dialog
|
approves the update, **THEN** Kandev starts exactly one update and the dialog
|
||||||
streams stdout/stderr until the job is terminal.
|
streams stdout/stderr until the job is terminal.
|
||||||
|
|||||||
Reference in New Issue
Block a user