diff --git a/packages/views/locales/zh-Hans/runtimes.json b/packages/views/locales/zh-Hans/runtimes.json index 7cbbd7e15..9139c9700 100644 --- a/packages/views/locales/zh-Hans/runtimes.json +++ b/packages/views/locales/zh-Hans/runtimes.json @@ -81,7 +81,7 @@ "all_runtimes": "全部运行时", "read_only": "只读", "delete_button": "删除运行时", - "delete_disabled_tooltip": "该运行时由正在运行的本地 daemon 托管,删除后会在数秒内自动重新注册。需停止 daemon 进程才能彻底移除。", + "delete_disabled_tooltip": "该运行时由正在运行的本地守护进程托管,删除后会在数秒内自动重新注册。需停止守护进程才能彻底移除。", "last_seen": "最后活跃 {{when}}", "fact_owner": "所有者", "fact_device": "设备", diff --git a/packages/views/runtimes/components/runtime-columns.tsx b/packages/views/runtimes/components/runtime-columns.tsx index 9f6b76f52..87c141d7c 100644 --- a/packages/views/runtimes/components/runtime-columns.tsx +++ b/packages/views/runtimes/components/runtime-columns.tsx @@ -47,6 +47,7 @@ import { HealthIcon, useHealthLabel } from "./shared"; import { computeCostInWindow, formatLastSeen, + isSelfHealingRuntime, isVersionNewer, pctChange, } from "../utils"; @@ -485,8 +486,13 @@ function RowMenu({ const { t } = useT("runtimes"); const deleteMutation = useDeleteRuntime(wsId); const [deleteOpen, setDeleteOpen] = useState(false); + // Delete is currently the only row action; if the row can't run it, drop + // the kebab entirely so the column doesn't render an empty popover. The + // self-healing case (local + online) is the runtime-detail parity fix — + // see isSelfHealingRuntime for the rationale. + const selfHealing = isSelfHealingRuntime(runtime); - if (!canDelete) { + if (!canDelete || selfHealing) { return ; } diff --git a/packages/views/runtimes/components/runtime-detail.tsx b/packages/views/runtimes/components/runtime-detail.tsx index 82efb3f27..ee5287504 100644 --- a/packages/views/runtimes/components/runtime-detail.tsx +++ b/packages/views/runtimes/components/runtime-detail.tsx @@ -41,7 +41,7 @@ import { import { ActorAvatar } from "../../common/actor-avatar"; import { AppLink, useNavigation } from "../../navigation"; import { availabilityConfig, workloadConfig } from "../../agents/presence"; -import { formatLastSeen } from "../utils"; +import { formatLastSeen, isSelfHealingRuntime } from "../utils"; import { HealthBadge } from "./shared"; import { ProviderLogo } from "./provider-logo"; import { UpdateSection } from "./update-section"; @@ -484,10 +484,7 @@ function DiagnosticsCard({ }) { const { t } = useT("runtimes"); const isLocal = runtime.runtime_mode === "local"; - // A live local daemon re-registers itself within seconds of a server-side - // delete (daemon self-heal, #2404), so deleting an online local runtime - // from the UI has no lasting effect — disable it and explain why. - const selfHealing = isLocal && runtime.status === "online"; + const selfHealing = isSelfHealingRuntime(runtime); // canDelete here doubles as the "can edit runtime" predicate — it already // means "workspace owner/admin OR runtime owner", which is the same gate // the server enforces for the visibility PATCH. diff --git a/packages/views/runtimes/components/runtime-row-menu.test.tsx b/packages/views/runtimes/components/runtime-row-menu.test.tsx new file mode 100644 index 000000000..8a4ae6d57 --- /dev/null +++ b/packages/views/runtimes/components/runtime-row-menu.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type { AgentRuntime } from "@multica/core/types"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enRuntimes from "../../locales/en/runtimes.json"; +import enAgents from "../../locales/en/agents.json"; + +const TEST_RESOURCES = { + en: { common: enCommon, runtimes: enRuntimes, agents: enAgents }, +}; + +// Stub the workspace queries the columns reach into. None of them feed the +// row menu directly, but `createRuntimeColumns` wires CliCell + CostCell +// against the same query client, so we still need useQuery to resolve. +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQuery: vi.fn(() => ({ data: [], isLoading: false })), + }; +}); + +vi.mock("@multica/core/runtimes/mutations", () => ({ + useDeleteRuntime: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@multica/core/runtimes", () => ({ + deriveRuntimeHealth: () => "online", + runtimeUsageOptions: () => ({ kind: "usage" }), +})); + +vi.mock("@multica/core/agents", () => ({ + deriveWorkload: () => "idle", +})); + +vi.mock("../../common/use-viewing-timezone", () => ({ + useViewingTimezone: () => "UTC", +})); + +vi.mock("./provider-logo", () => ({ ProviderLogo: () => null })); +vi.mock("./shared", () => ({ + HealthIcon: () => null, + useHealthLabel: () => () => "Online", +})); + +import { createRuntimeColumns, type RuntimeRow } from "./runtime-columns"; +import { useT } from "../../i18n"; + +function makeRuntime(overrides: Partial): AgentRuntime { + return { + id: "rt-1", + workspace_id: "ws-1", + daemon_id: null, + name: "rt", + runtime_mode: "local", + provider: "claude", + launch_header: "", + status: "online", + device_info: "", + metadata: {}, + owner_id: "user-1", + visibility: "private", + last_seen_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function makeRow(runtime: AgentRuntime, canDelete = true): RuntimeRow { + return { + runtime, + ownerMember: null, + workload: { agentIds: [], runningCount: 0, queuedCount: 0 }, + canDelete, + }; +} + +// The row menu lives inside the "actions" column cell. To exercise it +// without rendering the entire DataTable, we resolve the cell renderer from +// createRuntimeColumns and render its output directly inside a minimal table +// row (the cell expects React table context, but our shape — `row.original` +// — is the only field RowMenu reads, so a hand-built shim suffices). +function renderActionsCell(row: RuntimeRow) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function Harness() { + const { t } = useT("runtimes"); + const columns = createRuntimeColumns({ + showOwner: false, + latestCliVersion: null, + wsId: "ws-1", + now: Date.now(), + t, + }); + const actions = columns.find((c) => c.id === "actions"); + if (!actions || typeof actions.cell !== "function") { + throw new Error("actions column missing or has no cell renderer"); + } + // The cell renderer only reads `row.original`. Casting through unknown + // keeps us honest about not implementing the full tanstack-table cell + // context. + const cell = actions.cell({ + row: { original: row }, + } as unknown as Parameters[0]); + return <>{cell}; + } + + return render( + + + + + , + ); +} + +describe("runtime list row menu", () => { + beforeEach(() => vi.clearAllMocks()); + + it("hides the kebab menu for an online local runtime (self-healing)", () => { + // Deleting an online local runtime is a no-op (daemon re-registers in + // seconds), so the row menu drops the only action — Delete — entirely. + renderActionsCell( + makeRow(makeRuntime({ runtime_mode: "local", status: "online" })), + ); + expect(screen.queryByLabelText("Row actions")).not.toBeInTheDocument(); + }); + + it("renders the kebab menu for an offline local runtime", () => { + renderActionsCell( + makeRow(makeRuntime({ runtime_mode: "local", status: "offline" })), + ); + expect(screen.getByLabelText("Row actions")).toBeInTheDocument(); + }); + + it("renders the kebab menu for a cloud runtime regardless of status", () => { + renderActionsCell( + makeRow(makeRuntime({ runtime_mode: "cloud", status: "online" })), + ); + expect(screen.getByLabelText("Row actions")).toBeInTheDocument(); + }); + + it("hides the kebab menu when the caller lacks delete permission", () => { + // Pre-existing behavior — re-asserted so the new self-healing guard + // doesn't accidentally regress it (both paths return the same empty + // span). + renderActionsCell( + makeRow( + makeRuntime({ runtime_mode: "local", status: "offline" }), + /* canDelete */ false, + ), + ); + expect(screen.queryByLabelText("Row actions")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/runtimes/utils.test.ts b/packages/views/runtimes/utils.test.ts index 120a76a1a..baa6acbf0 100644 --- a/packages/views/runtimes/utils.test.ts +++ b/packages/views/runtimes/utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store"; -import type { RuntimeUsage } from "@multica/core/types"; +import type { AgentRuntime, RuntimeUsage } from "@multica/core/types"; import { addDaysIso, @@ -10,6 +10,7 @@ import { computeCostInWindow, estimateCost, isModelPriced, + isSelfHealingRuntime, sliceWindow, todayIso, weekStartIso, @@ -27,6 +28,61 @@ const zeroUsage = { cache_write_tokens: 0, }; +describe("isSelfHealingRuntime", () => { + function makeRuntime(overrides: Partial): AgentRuntime { + return { + id: "rt-1", + workspace_id: "ws-1", + daemon_id: null, + name: "rt", + runtime_mode: "local", + provider: "claude", + launch_header: "", + status: "online", + device_info: "", + metadata: {}, + owner_id: null, + visibility: "private", + last_seen_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; + } + + it("flags an online local runtime as self-healing", () => { + expect( + isSelfHealingRuntime( + makeRuntime({ runtime_mode: "local", status: "online" }), + ), + ).toBe(true); + }); + + it("treats an offline local runtime as safe to delete", () => { + // Daemon isn't running, so the server-side delete is final — no + // re-registration race to worry about. + expect( + isSelfHealingRuntime( + makeRuntime({ runtime_mode: "local", status: "offline" }), + ), + ).toBe(false); + }); + + it("treats cloud runtimes as safe to delete regardless of status", () => { + // Cloud workers are managed by Fleet, not a self-restarting local daemon. + expect( + isSelfHealingRuntime( + makeRuntime({ runtime_mode: "cloud", status: "online" }), + ), + ).toBe(false); + expect( + isSelfHealingRuntime( + makeRuntime({ runtime_mode: "cloud", status: "offline" }), + ), + ).toBe(false); + }); +}); + describe("estimateCost", () => { it("prices the canonical Anthropic Sonnet 4.6 SKU", () => { const cost = estimateCost({ diff --git a/packages/views/runtimes/utils.ts b/packages/views/runtimes/utils.ts index 9c1a6bc8c..833466786 100644 --- a/packages/views/runtimes/utils.ts +++ b/packages/views/runtimes/utils.ts @@ -1,9 +1,18 @@ import type { + AgentRuntime, RuntimeUsage, RuntimeUsageByAgent, } from "@multica/core/types"; import { getCustomPricing } from "@multica/core/runtimes/custom-pricing-store"; +// A live local daemon re-registers itself within seconds of a server-side +// delete (daemon self-heal, #2404), so deleting an online local runtime from +// the UI has no lasting effect. Both the detail page and the list row menu +// gate their Delete affordance on this same predicate. +export function isSelfHealingRuntime(runtime: AgentRuntime): boolean { + return runtime.runtime_mode === "local" && runtime.status === "online"; +} + // --------------------------------------------------------------------------- // Formatting helpers // ---------------------------------------------------------------------------