fix(runtimes): extend self-healing delete guard to list row menu (MUL-2569) (#3081)

Follow-up to #3076. The detail-page guard left a bypass via the runtimes
list row menu — owners could still walk Runtimes → kebab → Delete → toast
→ runtime reappears. Extract isSelfHealingRuntime into the shared utils
module so detail and list agree on the predicate, and drop the kebab
entirely for self-healing rows (the menu's only item was Delete). Also
swap the lingering English "daemon" in the zh-Hans delete_disabled_tooltip
for 守护进程 to match the rest of the file.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-05-24 12:41:56 +08:00
committed by GitHub
parent fd0fe1d08a
commit 660e27b981
6 changed files with 239 additions and 8 deletions

View File

@@ -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": "设备",

View File

@@ -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 <span aria-hidden />;
}

View File

@@ -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.

View File

@@ -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<typeof import("@tanstack/react-query")>(
"@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>): 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<typeof actions.cell>[0]);
return <>{cell}</>;
}
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>
<Harness />
</QueryClientProvider>
</I18nProvider>,
);
}
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();
});
});

View File

@@ -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>): 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({

View File

@@ -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
// ---------------------------------------------------------------------------