mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 04:56:20 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
109
packages/views/runtimes/components/machine-cli-section.test.tsx
Normal file
109
packages/views/runtimes/components/machine-cli-section.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
51
packages/views/runtimes/components/machine-cli-section.tsx
Normal file
51
packages/views/runtimes/components/machine-cli-section.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user