MUL-4781: move daemon update to machine page (#5434)

* fix(runtimes): move daemon update to machine

Co-authored-by: multica-agent <github@multica.ai>

* chore(runtimes): remove obsolete update hook

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Multica Eve
2026-07-15 12:41:26 +08:00
committed by GitHub
parent 06d79c1750
commit 2c482ab366
10 changed files with 187 additions and 146 deletions

View File

@@ -55,7 +55,6 @@
"./runtimes": "./runtimes/index.ts",
"./runtimes/queries": "./runtimes/queries.ts",
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
"./dashboard": "./dashboard/index.ts",
"./dashboard/queries": "./dashboard/queries.ts",

View File

@@ -1,66 +0,0 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "../auth";
import type { AgentRuntime } from "../types";
import { runtimeListOptions, latestCliVersionOptions } from "./queries";
function stripV(v: string): string {
return v.replace(/^v/, "");
}
function isNewer(latest: string, current: string): boolean {
const l = stripV(latest).split(".").map(Number);
const c = stripV(current).split(".").map(Number);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0;
const cv = c[i] ?? 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
function runtimeNeedsUpdate(
rt: AgentRuntime,
latestVersion: string,
userId: string,
): boolean {
if (rt.runtime_mode !== "local") return false;
// Only show to the user who owns this runtime.
if (rt.owner_id !== userId) return false;
// Desktop-managed runtimes are updated by the Desktop app's own auto-updater;
// the platform should not surface CLI update prompts for them.
if (rt.metadata && rt.metadata.launched_by === "desktop") {
return false;
}
const cliVersion =
rt.metadata && typeof rt.metadata.cli_version === "string"
? rt.metadata.cli_version
: null;
if (!cliVersion) return false;
return isNewer(latestVersion, cliVersion);
}
/**
* Returns a Set of runtime IDs that belong to the current user and have updates available.
* Accepts wsId as parameter so callers outside WorkspaceIdProvider can use it safely.
*/
export function useUpdatableRuntimeIds(wsId: string | undefined): Set<string> {
const userId = useAuthStore((s) => s.user?.id);
const { data: runtimes } = useQuery({
...runtimeListOptions(wsId ?? ""),
enabled: !!wsId,
});
const { data: latestVersion } = useQuery(latestCliVersionOptions());
return useMemo(() => {
if (!runtimes || !latestVersion || !userId) return new Set<string>();
const ids = new Set<string>();
for (const rt of runtimes) {
if (runtimeNeedsUpdate(rt, latestVersion, userId)) {
ids.add(rt.id);
}
}
return ids;
}, [runtimes, latestVersion, userId]);
}

View File

@@ -1,7 +1,6 @@
export * from "./queries";
export * from "./profiles";
export * from "./mutations";
export * from "./hooks";
export * from "./models";
export * from "./local-skills";
export * from "./types";

View File

@@ -12,7 +12,6 @@ export const runtimeKeys = {
// by-hour now follows the viewer's tz, like the other reports.
usageByHour: (rid: string, days: number, tz: string) =>
["runtimes", "usage", "by-hour", rid, days, tz] as const,
latestVersion: () => ["runtimes", "latestVersion"] as const,
};
// `tz` is the viewer's IANA name — all reports follow the viewer's tz.
@@ -54,25 +53,3 @@ export function runtimeListOptions(wsId: string, owner?: "me") {
queryFn: () => api.listRuntimes({ workspace_id: wsId, owner }),
});
}
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
export function latestCliVersionOptions() {
return queryOptions({
queryKey: runtimeKeys.latestVersion(),
queryFn: async (): Promise<string | null> => {
try {
const resp = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;
const data = await resp.json();
return (data.tag_name as string) ?? null;
} catch {
return null;
}
},
staleTime: 10 * 60 * 1000, // 10 minutes
});
}

View File

@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AgentRuntime } from "@multica/core/types";
import type { RuntimeMachine } from "./runtime-machines";
const mockUpdateSection = vi.hoisted(() => vi.fn());
vi.mock("./update-section", () => ({
UpdateSection: (props: Record<string, unknown>) => {
mockUpdateSection(props);
return <button type="button">Update</button>;
},
}));
import { MachineCliSection } from "./machine-cli-section";
function runtime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
return {
id: "runtime-1",
workspace_id: "ws-1",
daemon_id: "daemon-1",
name: "Claude (dev.local)",
runtime_mode: "local",
provider: "claude",
launch_header: "",
status: "online",
device_info: "dev.local",
metadata: { cli_version: "0.3.17" },
owner_id: "user-1",
visibility: "private",
last_seen_at: "2026-07-15T00:00:00Z",
created_at: "2026-07-15T00:00:00Z",
updated_at: "2026-07-15T00:00:00Z",
...overrides,
};
}
function machine(runtimes: AgentRuntime[]): RuntimeMachine {
return {
id: "local:daemon-1",
daemonId: "daemon-1",
title: "dev.local",
subtitle: null,
deviceInfo: "dev.local",
cliVersion: "0.3.17",
mode: "local",
section: "remote",
isCurrent: false,
health: "online",
runtimes,
onlineCount: runtimes.filter((item) => item.status === "online").length,
issueCount: 0,
runningCount: 0,
queuedCount: 0,
providerNames: runtimes.map((item) => item.provider),
lastSeenAt: "2026-07-15T00:00:00Z",
};
}
describe("MachineCliSection", () => {
beforeEach(() => {
mockUpdateSection.mockClear();
});
it("renders one update control using an online viewer-owned runtime", () => {
const offlineOwned = runtime({ id: "offline-owned", status: "offline" });
const onlineOwned = runtime({
id: "online-owned",
metadata: {
cli_version: "0.3.17",
launched_by: "desktop",
},
});
const otherUsers = runtime({ id: "other-user", owner_id: "user-2" });
render(
<MachineCliSection
machine={machine([offlineOwned, onlineOwned, otherUsers])}
currentUserId="user-1"
/>,
);
expect(screen.getAllByRole("button", { name: "Update" })).toHaveLength(1);
expect(mockUpdateSection).toHaveBeenCalledTimes(1);
expect(mockUpdateSection).toHaveBeenCalledWith({
runtimeId: "online-owned",
currentVersion: "0.3.17",
isOnline: true,
launchedBy: "desktop",
});
});
it("shows read-only machine CLI version when the viewer owns no runtime", () => {
render(
<MachineCliSection
machine={machine([runtime({ owner_id: "user-2" })])}
currentUserId="user-1"
/>,
);
expect(screen.getByText("CLI 0.3.17")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Update" }),
).not.toBeInTheDocument();
expect(mockUpdateSection).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,51 @@
import type { AgentRuntime } from "@multica/core/types";
import type { RuntimeMachine } from "./runtime-machines";
import { UpdateSection } from "./update-section";
function launchedBy(runtime: AgentRuntime): string | null {
const value = runtime.metadata?.launched_by;
return typeof value === "string" && value ? value : null;
}
/**
* Pick one viewer-owned runtime as the command channel for a machine-wide
* daemon update. An online runtime wins so the daemon can receive the request
* immediately; an offline row still keeps version/managed-state display
* available without enabling the update action.
*/
export function machineUpdateRuntime(
machine: RuntimeMachine,
currentUserId: string | undefined,
): AgentRuntime | null {
if (machine.mode !== "local" || !currentUserId) return null;
const owned = machine.runtimes.filter(
(runtime) => runtime.owner_id === currentUserId,
);
return owned.find((runtime) => runtime.status === "online") ?? owned[0] ?? null;
}
export function MachineCliSection({
machine,
currentUserId,
}: {
machine: RuntimeMachine;
currentUserId: string | undefined;
}) {
const updateRuntime = machineUpdateRuntime(machine, currentUserId);
if (!updateRuntime) {
return machine.cliVersion ? (
<span className="font-mono">CLI {machine.cliVersion}</span>
) : null;
}
return (
<UpdateSection
runtimeId={updateRuntime.id}
currentVersion={machine.cliVersion}
isOnline={updateRuntime.status === "online"}
launchedBy={launchedBy(updateRuntime)}
/>
);
}

View File

@@ -9,7 +9,6 @@ import { useWorkspacePaths } from "@multica/core/paths";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { runtimeProfileListOptions } from "@multica/core/runtimes";
import { runtimeKeys, runtimeListOptions } from "@multica/core/runtimes/queries";
import { useUpdatableRuntimeIds } from "@multica/core/runtimes/hooks";
import { useWSEvent } from "@multica/core/realtime";
import {
agentListOptions,
@@ -26,6 +25,7 @@ import {
import { RenameMachineDialog } from "./rename-machine-dialog";
import { RuntimeProfilesDialog } from "./runtime-profiles-dialog";
import { pendingRuntimesForProfiles } from "./pending-runtime";
import { MachineCliSection } from "./machine-cli-section";
import { HealthIcon, useHealthLabel } from "./shared";
import { useT, useTimeAgo } from "../../i18n";
@@ -100,7 +100,6 @@ export function RuntimeDetailPage({
const { data: runtimeProfiles = [] } = useQuery(
runtimeProfileListOptions(wsId),
);
const updatableIds = useUpdatableRuntimeIds(wsId);
const now = useNowTick();
const machineLocator = decodeRouteParam(runtimeId);
const [renameOpen, setRenameOpen] = useState(false);
@@ -259,9 +258,10 @@ export function RuntimeDetailPage({
})
: t(($) => $.machine.metrics.workload_idle)}
</span>
{machine.cliVersion && (
<span className="font-mono">CLI {machine.cliVersion}</span>
)}
<MachineCliSection
machine={machine}
currentUserId={currentUserId}
/>
{machine.lastSeenAt && (
<span>{timeAgo(machine.lastSeenAt)}</span>
)}
@@ -313,7 +313,6 @@ export function RuntimeDetailPage({
<div className="overflow-hidden rounded-lg border bg-card">
<RuntimeList
runtimes={machineRuntimes}
updatableIds={updatableIds}
now={now}
runtimeHref={(childRuntimeId) =>
paths.runtimeSettings(machine.id, childRuntimeId)

View File

@@ -118,10 +118,9 @@ vi.mock("@multica/core/runtimes/mutations", () => ({
}),
}));
// Stubbing ProviderLogo / UsageSection / UpdateSection avoids dragging in
// chart libs and additional query keys we don't care about here.
// Stubbing ProviderLogo / UsageSection avoids dragging in chart libs and
// additional query keys we don't care about here.
vi.mock("./provider-logo", () => ({ ProviderLogo: () => null }));
vi.mock("./update-section", () => ({ UpdateSection: () => null }));
vi.mock("./usage-section", () => ({ UsageSection: () => null }));
vi.mock("./shared", () => ({ HealthBadge: () => null }));
vi.mock("../../agents/presence", () => ({
@@ -201,6 +200,24 @@ describe("RuntimeDetail visibility section", () => {
expect(screen.getByText("Public")).toBeInTheDocument();
});
it("keeps daemon CLI version details without rendering update controls", () => {
renderDetail(
makeRuntime({
metadata: { cli_version: "0.3.17" },
runtime_mode: "local",
}),
);
fireEvent.click(
screen.getByRole("button", { name: "Technical details" }),
);
expect(screen.getByText("0.3.17")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Update" }),
).not.toBeInTheDocument();
});
it("flips visibility to public when the owner clicks the Public choice", async () => {
renderDetail(makeRuntime({ owner_id: "user-me", visibility: "private" }));
fireEvent.click(screen.getByText("Public"));

View File

@@ -42,7 +42,6 @@ import { AppLink, useNavigation } from "../../navigation";
import { availabilityConfig, workloadConfig } from "../../agents/presence";
import { HealthBadge } from "./shared";
import { ProviderLogo } from "./provider-logo";
import { UpdateSection } from "./update-section";
import { UsageSection } from "./usage-section";
import { DeleteRuntimeDialog } from "./delete-runtime-dialog";
import { DeleteRuntimeProfileDialog } from "./delete-runtime-profile-dialog";
@@ -59,17 +58,6 @@ function getCliVersion(metadata: Record<string, unknown>): string | null {
return null;
}
function getLaunchedBy(metadata: Record<string, unknown>): string | null {
if (
metadata &&
typeof metadata.launched_by === "string" &&
metadata.launched_by
) {
return metadata.launched_by;
}
return null;
}
function shortDaemonId(id: string | null): string | null {
if (!id) return null;
if (id.length <= 10) return id;
@@ -105,9 +93,6 @@ export function RuntimeDetail({
const timeAgo = useTimeAgo();
const cliVersion =
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;
const launchedBy =
runtime.runtime_mode === "local" ? getLaunchedBy(runtime.metadata) : null;
const user = useAuthStore((s) => s.user);
const wsId = useWorkspaceId();
const paths = useWorkspacePaths();
@@ -215,8 +200,6 @@ export function RuntimeDetail({
/>
<DiagnosticsCard
runtime={runtime}
cliVersion={cliVersion}
launchedBy={launchedBy}
canEdit={!!canEditRuntime}
canDelete={!!canDelete}
onDelete={() => setDeleteOpen(true)}
@@ -484,21 +467,16 @@ function ServingAgentsCard({
function DiagnosticsCard({
runtime,
cliVersion,
launchedBy,
canEdit,
canDelete,
onDelete,
}: {
runtime: AgentRuntime;
cliVersion: string | null;
launchedBy: string | null;
canEdit: boolean;
canDelete: boolean;
onDelete: () => void;
}) {
const { t } = useT("runtimes");
const isLocal = runtime.runtime_mode === "local";
return (
<div className="rounded-lg border">
<div className="border-b px-4 py-2.5">
@@ -515,19 +493,6 @@ function DiagnosticsCard({
<VisibilityReadout runtime={runtime} />
)}
</div>
{isLocal && (
<div className="border-t pt-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wide text-muted-foreground">
{t(($) => $.detail.diagnostics_cli)}
</div>
<UpdateSection
runtimeId={runtime.id}
currentVersion={cliVersion}
isOnline={runtime.status === "online"}
launchedBy={launchedBy}
/>
</div>
)}
{canDelete && (
// The button stays clickable even when the runtime is a live
// local daemon (self-healing). The owner explicitly asked for

View File

@@ -464,8 +464,8 @@ export function CliCell({ runtime }: { runtime: AgentRuntime }) {
// The separate `cli_version` is the shared multica daemon CLI, identical
// for every runtime on one machine; surfacing it here made all agents
// show the same number (#3838). The daemon CLI version and its update
// prompt belong to the machine — they live in the machine meta strip and
// the detail page's UpdateSection, not on a per-agent row.
// prompt belong to the machine — they live in the machine header, not on a
// per-agent row.
const version =
meta && typeof meta.version === "string" ? meta.version : null;
@@ -613,23 +613,14 @@ export function RuntimeRowMenu({
export function RuntimeList({
runtimes,
updatableIds,
now,
runtimeHref,
}: {
runtimes: AgentRuntime[];
// Kept on the API surface for callers, but unused here: the CLI column
// shows each agent's own tool version, while the multica daemon CLI
// update prompt lives at the machine/detail level (UpdateSection), so the
// table no longer derives per-row update state. Left to avoid scope creep
// on the page-level wrapper that still computes the set.
updatableIds?: Set<string>;
now: number;
/** Machine-detail pages keep runtime settings nested under the machine. */
runtimeHref?: (runtimeId: string) => string;
}) {
void updatableIds;
const { t } = useT("runtimes");
const wsId = useWorkspaceId();
const wsPaths = useWorkspacePaths();