mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
@@ -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": "设备",
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
163
packages/views/runtimes/components/runtime-row-menu.test.tsx
Normal file
163
packages/views/runtimes/components/runtime-row-menu.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user