fix: handle same-version agent runtime updates (#2120)
This commit is contained in:
@@ -131,19 +131,25 @@ func (s *AgentUpdateJobStore) run(
|
||||
defer cancel()
|
||||
s.setStatus(job, dto.AgentUpdateJobStatusResolving)
|
||||
|
||||
if caps, ok := s.updater.CurrentCapabilities(job.AgentName); ok {
|
||||
s.mu.Lock()
|
||||
job.CurrentVersion = caps.AgentVersion
|
||||
s.mu.Unlock()
|
||||
}
|
||||
target, err := s.updater.ResolveTarget(ctx, spec.Package)
|
||||
if err != nil {
|
||||
s.finishFailed(job, ctx, fmt.Errorf("resolve target version: %w", err), ref)
|
||||
return
|
||||
}
|
||||
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()
|
||||
job.TargetVersion = target
|
||||
s.mu.Unlock()
|
||||
if currentVersion != "" && currentVersion == target {
|
||||
s.finishAlreadyUpToDate(job, ref)
|
||||
return
|
||||
}
|
||||
|
||||
s.setStatus(job, dto.AgentUpdateJobStatusUpdating)
|
||||
flusher := newUpdateOutputFlusher(s, job)
|
||||
@@ -201,6 +207,18 @@ func (s *AgentUpdateJobStore) finishFailed(
|
||||
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(
|
||||
job *AgentUpdateJob,
|
||||
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) {
|
||||
updater := &fakeRuntimeUpdater{
|
||||
target: "1.1.0",
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
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 { RuntimeUpdate } from "@/lib/types/http";
|
||||
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 }) {
|
||||
if (!job) return null;
|
||||
const { versionsMatch } = resolveRuntimeVersionPair(null, job);
|
||||
if (job.status === "succeeded" && job.refresh_error) {
|
||||
return (
|
||||
<p
|
||||
@@ -66,7 +71,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
|
||||
role="status"
|
||||
data-testid={`agent-update-result-${agentName}`}
|
||||
>
|
||||
Runtime updated successfully.
|
||||
{versionsMatch ? "Runtime already up to date." : "Runtime updated successfully."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -94,6 +99,30 @@ type UpdateBodyProps = {
|
||||
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({
|
||||
agentName,
|
||||
preview,
|
||||
@@ -143,14 +172,7 @@ function UpdateBody({
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
<RuntimeVersionSummary preview={preview} job={job} />
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<p>
|
||||
This updates the managed runtime on this Kandev host, then refreshes its models and
|
||||
@@ -205,31 +227,6 @@ type UpdateFooterProps = {
|
||||
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({
|
||||
agentName,
|
||||
preview,
|
||||
@@ -244,8 +241,9 @@ function UpdateFooter({
|
||||
}: UpdateFooterProps) {
|
||||
const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status));
|
||||
const canRetry = job?.status === "failed";
|
||||
const canApprove = canApproveUpdate({
|
||||
const canApprove = canApproveAgentRuntimeUpdate({
|
||||
preview,
|
||||
job,
|
||||
previewError,
|
||||
loading,
|
||||
updateInFlight,
|
||||
|
||||
@@ -104,6 +104,59 @@ test.describe("managed agent runtime updates", () => {
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
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
|
||||
runtime on the Kandev host. Before anything changes, the update dialog shows
|
||||
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
|
||||
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
|
||||
|
||||
@@ -27,6 +27,10 @@ cache is already present.
|
||||
resolves and shows the current runtime version, upstream target version,
|
||||
exact built-in command, host-only scope, capability refresh, and active
|
||||
session behavior before an update can start.
|
||||
- When the resolved current and upstream target versions are identical, the
|
||||
update dialog shows the version once with an **Up to date** status and keeps
|
||||
approval disabled because running the package update would not advance the
|
||||
managed runtime.
|
||||
- The update starts only after the operator approves it in the dialog. The
|
||||
dialog then shows live stdout/stderr, progress, and the terminal success or
|
||||
failure state.
|
||||
@@ -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
|
||||
`0.62.0 → 0.63.0`, the exact built-in command, and how the host update
|
||||
affects capabilities and active sessions without starting the command.
|
||||
- **GIVEN** the current runtime and upstream target both report version
|
||||
`0.64.0`, **WHEN** the operator opens the update action, **THEN** the dialog
|
||||
shows `0.64.0` once with **Up to date**, does not show a version transition,
|
||||
keeps **Approve update** disabled on desktop and mobile, and starts no update
|
||||
job.
|
||||
- **GIVEN** the update dialog has a resolved preview, **WHEN** the operator
|
||||
approves the update, **THEN** Kandev starts exactly one update and the dialog
|
||||
streams stdout/stderr until the job is terminal.
|
||||
|
||||
Reference in New Issue
Block a user