fix(web): restore the Feature Toggles restart action (#2236)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-06 00:33:14 +08:00
committed by GitHub
parent 08b3932011
commit d7620d912d
21 changed files with 717 additions and 101 deletions
+6 -6
View File
@@ -145,12 +145,12 @@ Start by matching CI as closely as possible, then add pressure deliberately:
full spec file or full shard with the same resource limits before declaring
a flake non-reproducible.
A test that passes only after a Playwright retry is still a failure signal.
During triage disable per-spec retry overrides and use `--retries=0`. When
isolated repeats pass but the same shard position fails, binary-search the
preceding spec files under one worker until the smallest ordered sequence
reproduces the shared-state leak; the fix is complete only when that sequence
passes with retries disabled.
The config uses `failOnFlakyTests: !CI`: local runs fail on a flaky retry, while
CI temporarily tolerates one. Either result is a failure signal for agents:
reproduce it locally with per-spec overrides disabled and `--retries=0`; never
rerun until it happens to pass. If isolated repeats stay green but the shard
fails, binary-search preceding specs in one worker. The fix is complete only
when the smallest reproducing sequence passes without retries.
Record the exact command, resource limits, repeat number, and failure artifact
path. Always inspect `error-context.md`; mobile/terminal flakes often show
+3
View File
@@ -152,3 +152,6 @@ normalized-*.jsonl
# Auth screenshot artifacts (generated by auth-screenshots.spec.ts)
apps/web/e2e/.auth-screenshots/
# Restart-action screenshot artifacts (generated by feature-toggles-restart.spec.ts)
apps/web/e2e/.restart-screenshots/
@@ -514,16 +514,27 @@ describe("PRDetailContent persistent request expiry", () => {
it("clears the expiry timer when feedback confirms the request", async () => {
vi.useFakeTimers();
feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] });
reviewMocks.requestReviewers.mockResolvedValue({ requested: true });
const view = renderPRDetail();
await act(async () => fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)));
let reviewRequest: ReturnType<typeof usePRScopedReviewRequest> | undefined;
const onReady = (value: ReturnType<typeof usePRScopedReviewRequest>) => {
reviewRequest = value;
};
const view = render(
createElement(ReviewRequestHarness, {
onReady,
reviews: [dismissedReview()],
}),
);
await act(async () => reviewRequest?.reRequest("octocat"));
expect(vi.getTimerCount()).toBe(1);
feedbackMocks.value = makeFeedback({
pr: { requested_reviewers: [{ login: "octocat", type: "user" }] },
});
view.rerender(prDetailTree());
view.rerender(
createElement(ReviewRequestHarness, {
onReady,
requestedReviewers: [{ login: "octocat", type: "user" }],
}),
);
await act(async () => undefined);
expect(vi.getTimerCount()).toBe(0);
@@ -0,0 +1,20 @@
"use client";
import { useRestartCapability } from "@/hooks/domains/system/use-restart-capability";
import { FeatureTogglesSettings } from "./feature-toggles-settings";
/**
* Route wrapper that resolves restart capability for the Feature Toggles page.
*
* Capability is a property of the *running* backend — it depends on whether a
* restart-capable supervisor launched it — so it must be fetched rather than
* baked in at build time. Toggle cards render immediately; the capability
* arrives after and only affects the pending-restart notice. A failed request
* stays `null`, which falls back to manual restart guidance rather than
* offering a button that cannot work.
*/
export function FeatureTogglesRoute() {
const { capability: restartCapability } = useRestartCapability();
return <FeatureTogglesSettings initialFlags={[]} restartCapability={restartCapability} />;
}
@@ -92,6 +92,21 @@ describe("FeatureTogglesSettings", () => {
expect(updateRuntimeFlagMock).toHaveBeenCalledWith(initial.key, true);
});
it("offers the restart action when the supervisor supports restart", () => {
render(
<TooltipProvider>
<FeatureTogglesSettings
initialFlags={[flagState()]}
restartCapability={{ supported: true, mode: "supervisor", adapter: "supervisor" }}
/>
</TooltipProvider>,
);
expect(screen.getByText("Restart required")).not.toBeNull();
expect(screen.getByRole("button", { name: "Restart" })).not.toBeNull();
expect(screen.queryByText(/terminal or service manager/)).toBeNull();
});
it("shows restart support details without offering restart when unsupported", () => {
render(
<TooltipProvider>
@@ -20,7 +20,7 @@ import { useSettingsSaveContributor } from "../settings-save-provider";
type Props = {
initialFlags: RuntimeFlagState[];
restartCapability: RestartCapability | null;
restartCapability: RestartCapability | null | undefined;
};
let bootstrapRuntimeFlagsRequest: ReturnType<typeof fetchRuntimeFlags> | null = null;
@@ -200,30 +200,31 @@ function RestartRequiredAlert({
restarting,
onRestart,
}: {
capability: RestartCapability | null;
capability: RestartCapability | null | undefined;
restarting: boolean;
onRestart: () => void;
}) {
const { t } = useTranslation();
const supported = capability?.supported === true;
const loading = capability === undefined;
const supported = !loading && capability?.supported === true;
return (
<Alert className="border-border/70 bg-muted/30">
<IconRotateClockwise className="h-4 w-4 text-muted-foreground" />
<AlertTitle className="flex items-center gap-2">
{t("system:restartRequired")}
<RestartSupportInfo supported={supported} reason={capability?.reason} />
{!loading && <RestartSupportInfo supported={supported} reason={capability?.reason} />}
</AlertTitle>
<AlertDescription className="flex flex-col gap-3 text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>
{t("system:restartPendingChanges")}
{!supported && ` ${t("system:restartManualHint")}`}
{!loading && !supported && ` ${t("system:restartManualHint")}`}
</span>
{supported && (
{!loading && supported && (
<Button
size="sm"
onClick={onRestart}
disabled={restarting}
className="w-full cursor-pointer sm:w-auto"
className="h-11 w-full cursor-pointer sm:h-6 sm:w-auto"
>
<IconPower className="mr-1 h-3.5 w-3.5" />
{t("system:restartAction")}
+1 -1
View File
@@ -24,7 +24,7 @@ export default defineConfig({
// the fixture's baseline env snapshot).
fullyParallel: true,
forbidOnly: CI,
failOnFlakyTests: CI,
failOnFlakyTests: !CI,
retries: CI ? 2 : 0,
workers: 1,
timeout: 60_000,
@@ -39,13 +39,17 @@ test.describe("Agent message comments on mobile", () => {
seedData,
}) => {
test.setTimeout(90_000);
const { task, body } = await openSeededAgentReply(
const { task, session, body } = await openSeededAgentReply(
testPage,
apiClient,
seedData,
"Mobile Agent Message Comments",
);
// This readiness helper may reload to recover stale startup state. Run it
// before opening the transient drawer so recovery cannot dismiss the UI
// that the remaining assertions exercise.
await session.waitForChatIdle({ timeout: 30_000, requireEditable: true });
await selectAgentReplyText(body, SELECTED_REPLY_TEXT);
const commentTrigger = testPage.getByTestId("agent-message-comment-trigger");
await expect(commentTrigger).toBeVisible();
@@ -2,12 +2,7 @@ import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { expectElementsNotToIntersect } from "../../helpers/layout-assertions";
async function openConfigChatFromSettings(page: Page): Promise<Locator> {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/settings/agents");
await page.waitForLoadState("networkidle");
expect(pageErrors, "settings page should render without client errors").toEqual([]);
async function openConfigChatPopover(page: Page): Promise<Locator> {
const fab = page.getByRole("button", { name: "Configuration Chat" });
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
@@ -17,6 +12,15 @@ async function openConfigChatFromSettings(page: Page): Promise<Locator> {
return popover;
}
async function openConfigChatFromSettings(page: Page): Promise<Locator> {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto("/settings/agents");
await page.waitForLoadState("networkidle");
expect(pageErrors, "settings page should render without client errors").toEqual([]);
return openConfigChatPopover(page);
}
async function startConfigChat(dialog: Locator, prompt: string) {
const setup = dialog.getByTestId("config-chat-setup");
await expect(setup).toBeVisible({ timeout: 10_000 });
@@ -76,7 +80,7 @@ test.describe("Configuration Chat", () => {
}
});
test("starts floating, expands the same session, restores, continues, and deletes", async ({
test("starts floating, expands the same session, restores, and continues", async ({
testPage,
}) => {
await testPage.setViewportSize({ width: 900, height: 520 });
@@ -116,9 +120,7 @@ test.describe("Configuration Chat", () => {
await testPage.reload();
await testPage.waitForLoadState("networkidle");
await expect(testPage.getByRole("dialog", { name: "Quick Chat" })).not.toBeVisible();
await testPage.getByRole("button", { name: "Configuration Chat" }).click();
const restored = testPage.getByTestId("config-chat-popover");
const restored = await openConfigChatPopover(testPage);
await expect(restored.getByTestId("chat-input-editor")).toBeVisible({ timeout: 10_000 });
await expect(
restored.getByText("simple mock response for e2e testing", { exact: false }),
@@ -128,9 +130,18 @@ test.describe("Configuration Chat", () => {
await expect(restored.getByText("continued config response", { exact: true })).toBeVisible({
timeout: 30_000,
});
});
await restored.getByRole("button", { name: "Open in Quick Chat" }).click();
test("deletes an expanded configuration chat and returns to setup", async ({ testPage }) => {
const popover = await openConfigChatFromSettings(testPage);
await startConfigChat(popover, "/e2e:simple-message");
await expect(
popover.getByText("simple mock response for e2e testing", { exact: false }),
).toBeVisible({ timeout: 30_000 });
await popover.getByRole("button", { name: "Open in Quick Chat" }).click();
const restoredDialog = testPage.getByRole("dialog", { name: "Quick Chat" });
await expect(restoredDialog).toBeVisible({ timeout: 10_000 });
await restoredDialog
.getByTestId("quick-chat-tab")
.getByRole("button", { name: /^Close / })
@@ -146,10 +157,8 @@ test.describe("Configuration Chat", () => {
await testPage.reload();
await testPage.waitForLoadState("networkidle");
await testPage.getByRole("button", { name: "Configuration Chat" }).click();
await expect(
testPage.getByTestId("config-chat-popover").getByTestId("config-chat-setup"),
).toBeVisible({ timeout: 10_000 });
const freshPopover = await openConfigChatPopover(testPage);
await expect(freshPopover.getByTestId("config-chat-setup")).toBeVisible({ timeout: 10_000 });
});
test("opens the same typed setup from the command palette", async ({ testPage }) => {
@@ -0,0 +1,71 @@
import type { Locator, Page } from "@playwright/test";
import { expect } from "@playwright/test";
export const PENDING_RESTART_FLAG = {
key: "features.office",
kind: "feature",
label: "Office mode",
description: "Enables autonomous agent office workflows and related settings.",
stability: "experimental",
risk_level: "medium",
risk_description: "Office mode is still evolving.",
effective_value: false,
default_value: false,
override_value: true,
source: "override",
env_var: "KANDEV_FEATURES_OFFICE",
env_locked: false,
restart_required: true,
requires_restart_to_apply: true,
mutable: true,
};
type Capability = { supported: boolean; mode: string; adapter?: string; reason?: string };
const CAPABILITY_URL = "**/api/v1/system/restart-capability";
const RUNTIME_FLAGS_URL = "**/api/v1/runtime-flags";
export async function stubFeatureToggles(page: Page, capability: Capability) {
await page.route(CAPABILITY_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(capability),
}),
);
// Only intercept the list GET; PATCH /runtime-flags/:key must fall through.
await page.route(RUNTIME_FLAGS_URL, async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ flags: [PENDING_RESTART_FLAG] }),
});
});
}
export async function stubCapabilityFailure(page: Page) {
await page.route(CAPABILITY_URL, (route) => route.fulfill({ status: 500, body: "{}" }));
await page.route(RUNTIME_FLAGS_URL, async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ flags: [PENDING_RESTART_FLAG] }),
});
});
}
export async function gotoFeatureToggles(page: Page): Promise<Locator> {
await page.goto("/settings/system/feature-toggles");
const settings = page.getByTestId("feature-toggles-settings");
await expect(settings).toBeVisible({ timeout: 15_000 });
return settings;
}
@@ -0,0 +1,68 @@
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import {
gotoFeatureToggles,
stubCapabilityFailure,
stubFeatureToggles,
} from "./feature-toggles-restart-helpers";
const SHOT_DIR = path.join(__dirname, "..", "..", ".restart-screenshots");
/**
* Regression guard for the Feature Toggles restart action.
*
* Both endpoints are stubbed rather than driven for real: this asserts the UI
* contract (capability + a pending flag => the action is offered), and never
* clicks Restart, which would bounce the worker's backend.
*/
test.describe("Feature Toggles restart action", () => {
test("offers an in-app restart when the backend reports a supervisor", async ({ testPage }) => {
await stubFeatureToggles(testPage, {
supported: true,
mode: "supervisor",
adapter: "supervisor",
});
const settings = await gotoFeatureToggles(testPage);
const notice = settings.getByText("Restart required");
await expect(notice).toBeVisible({ timeout: 10_000 });
// The regression: this button never rendered, whatever the backend said.
await expect(settings.getByRole("button", { name: "Restart", exact: true })).toBeVisible();
await expect(settings.getByText(/terminal or service manager/)).toHaveCount(0);
await testPage.screenshot({
path: path.join(SHOT_DIR, "restart-supported.png"),
fullPage: false,
});
});
test("falls back to manual guidance when restart is unsupported", async ({ testPage }) => {
await stubFeatureToggles(testPage, {
supported: false,
mode: "manual",
reason: "Automatic restart is not available for this launch mode.",
});
const settings = await gotoFeatureToggles(testPage);
await expect(settings.getByText("Restart required")).toBeVisible({ timeout: 10_000 });
await expect(settings.getByText(/terminal or service manager/)).toBeVisible();
await expect(settings.getByRole("button", { name: "Restart", exact: true })).toHaveCount(0);
await testPage.screenshot({
path: path.join(SHOT_DIR, "restart-unsupported.png"),
fullPage: false,
});
});
test("fails closed to manual guidance when the capability request errors", async ({
testPage,
}) => {
await stubCapabilityFailure(testPage);
const settings = await gotoFeatureToggles(testPage);
await expect(settings.getByText("Restart required")).toBeVisible({ timeout: 10_000 });
await expect(settings.getByRole("button", { name: "Restart", exact: true })).toHaveCount(0);
});
});
@@ -0,0 +1,24 @@
import { expect, test } from "../../fixtures/test-base";
import { gotoFeatureToggles, stubFeatureToggles } from "./feature-toggles-restart-helpers";
test.describe("Mobile Feature Toggles restart action", () => {
test("keeps the supported restart action reachable", async ({ testPage }) => {
await stubFeatureToggles(testPage, {
supported: true,
mode: "supervisor",
adapter: "supervisor",
});
const settings = await gotoFeatureToggles(testPage);
const restart = settings.getByRole("button", { name: "Restart", exact: true });
await expect(restart).toBeVisible();
await expect(settings.getByText(/terminal or service manager/)).toHaveCount(0);
const box = await restart.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
await testPage.evaluate(() => document.documentElement.clientWidth),
);
});
});
@@ -18,7 +18,7 @@ async function tapCancelButton(session: SessionPage) {
// A successful request can finish between toPass attempts. In that case the
// button is already gone; the assertions below still prove the idle state
// and completion-action transition rather than trying to tap a stale locator.
if (!(await button.isVisible())) return;
if (!(await button.isVisible()) || (await button.isDisabled())) return;
await button.scrollIntoViewIfNeeded();
await button.tap();
await expect
@@ -58,8 +58,6 @@ async function seedMobileCancellationWorkflow(apiClient: ApiClient, workspaceId:
}
test.describe("mobile: cancelled turn completion", () => {
test.describe.configure({ retries: 1 });
test("saves the policy by touch, reloads it, and advances after Cancel", async ({
testPage,
apiClient,
@@ -1,6 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { routeMainWebSocketWithPromptDrop } from "../../helpers/ws-drop";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
test.describe("Manual proceed to next workflow step", () => {
@@ -47,29 +46,15 @@ test.describe("Manual proceed to next workflow step", () => {
},
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
// Create task via API in Spec step — triggers auto_start_agent
await apiClient.createTask(seedData.workspaceId, "Plan Proceed Task", {
const task = await apiClient.createTask(seedData.workspaceId, "Plan Proceed Task", {
workflow_id: workflow.id,
workflow_step_id: specStep.id,
agent_profile_id: seedData.agentProfileId,
repository_ids: [seedData.repositoryId],
});
// Navigate to task session page
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardInColumn("Plan Proceed Task", specStep.id);
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
@@ -192,7 +177,7 @@ test.describe("Manual proceed to next workflow step", () => {
},
});
const workPrompt = "/slow 20s";
const workPrompt = "/slow 30s";
await apiClient.updateWorkflowStep(workStep.id, {
prompt: `${workPrompt}\n{{task_prompt}}`,
events: {
@@ -200,27 +185,14 @@ test.describe("Manual proceed to next workflow step", () => {
},
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
await apiClient.createTask(seedData.workspaceId, "Proceed Message Gap Task", {
const task = await apiClient.createTask(seedData.workspaceId, "Proceed Message Gap Task", {
workflow_id: workflow.id,
workflow_step_id: specStep.id,
agent_profile_id: seedData.agentProfileId,
repository_ids: [seedData.repositoryId],
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardInColumn("Proceed Message Gap Task", specStep.id);
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
@@ -243,15 +215,13 @@ test.describe("Manual proceed to next workflow step", () => {
timeout: 10_000,
})
.toBeGreaterThan(0);
await expect
.poll(wsDrop.recoveryResponseCount, {
message: "expected message backfill after the dropped workflow prompt notification",
timeout: 10_000,
})
.toBeGreaterThan(0);
await expect(
session.chat.locator(".chat-message-list:visible").getByText(workPrompt, { exact: false }),
).toBeVisible({ timeout: 5_000 });
).toBeVisible({ timeout: 20_000 });
expect(
wsDrop.recoveryResponseCount(),
"expected message backfill after the dropped workflow prompt notification",
).toBeGreaterThan(0);
});
/**
@@ -290,28 +260,15 @@ test.describe("Manual proceed to next workflow step", () => {
});
}
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
// Create task in Spec step
await apiClient.createTask(seedData.workspaceId, "Multi Step Task", {
const task = await apiClient.createTask(seedData.workspaceId, "Multi Step Task", {
workflow_id: workflow.id,
workflow_step_id: steps[0].id,
agent_profile_id: seedData.agentProfileId,
repository_ids: [seedData.repositoryId],
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardInColumn("Multi Step Task", steps[0].id);
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
@@ -0,0 +1,52 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RestartCapability } from "@/lib/types/system";
const mocks = vi.hoisted(() => ({
fetchRestartCapability: vi.fn(),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
fetchRestartCapability: mocks.fetchRestartCapability,
}));
import { useRestartCapability } from "./use-restart-capability";
beforeEach(() => {
mocks.fetchRestartCapability.mockReset();
});
describe("useRestartCapability", () => {
it("keeps the loading state distinct until capability detection resolves", async () => {
let resolveCapability!: (capability: RestartCapability) => void;
mocks.fetchRestartCapability.mockReturnValueOnce(
new Promise<RestartCapability>((resolve) => {
resolveCapability = resolve;
}),
);
const { result } = renderHook(() => useRestartCapability());
expect(result.current).toEqual({ status: "loading", capability: undefined });
await act(async () => {
resolveCapability({ supported: true, mode: "supervisor", adapter: "supervisor" });
});
await waitFor(() => expect(result.current.status).toBe("resolved"));
expect(result.current.capability).toEqual({
supported: true,
mode: "supervisor",
adapter: "supervisor",
});
});
it("reports capability detection failures as unavailable", async () => {
mocks.fetchRestartCapability.mockRejectedValueOnce(new Error("connection refused"));
const { result } = renderHook(() => useRestartCapability());
await waitFor(() =>
expect(result.current).toEqual({ status: "unavailable", capability: null }),
);
});
});
@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { fetchRestartCapability } from "@/lib/api/domains/system-api";
import type { RestartCapability } from "@/lib/types/system";
export type RestartCapabilityState =
| { status: "loading"; capability: undefined }
| { status: "resolved"; capability: RestartCapability }
| { status: "unavailable"; capability: null };
export function useRestartCapability(): RestartCapabilityState {
const [state, setState] = useState<RestartCapabilityState>({
status: "loading",
capability: undefined,
});
useEffect(() => {
let cancelled = false;
void fetchRestartCapability({ cache: "no-store" })
.then((capability) => {
if (!cancelled) setState({ status: "resolved", capability });
})
.catch(() => {
if (!cancelled) setState({ status: "unavailable", capability: null });
});
return () => {
cancelled = true;
};
}, []);
return state;
}
@@ -0,0 +1,132 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeFlagState } from "@/lib/types/runtime-flags";
import { renderSettingsRoute } from "./settings-routes";
// Route-level coverage. The component-level suite in
// components/settings/system/feature-toggles-settings.test.tsx cannot catch a
// route that supplies the wrong prop, which is exactly how the restart action
// was lost in #1389.
const mocks = vi.hoisted(() => ({
fetchRestartCapability: vi.fn(),
fetchRuntimeFlags: vi.fn(),
updateRuntimeFlag: vi.fn(),
fetchSystemInfo: vi.fn(),
requestRestart: vi.fn(),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
fetchRestartCapability: mocks.fetchRestartCapability,
fetchSystemInfo: mocks.fetchSystemInfo,
requestRestart: mocks.requestRestart,
}));
vi.mock("@/lib/api/domains/runtime-flags-api", () => ({
fetchRuntimeFlags: mocks.fetchRuntimeFlags,
updateRuntimeFlag: mocks.updateRuntimeFlag,
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/components/settings/settings-save-provider", () => ({
useSettingsSaveContributor: () => {},
}));
const PENDING_RESTART_FLAG: RuntimeFlagState = {
key: "features.office",
kind: "feature",
label: "Office mode",
description: "Enables autonomous agent office workflows and related settings.",
stability: "experimental",
risk_level: "medium",
risk_description: "Office mode is still evolving.",
effective_value: false,
default_value: false,
override_value: true,
source: "override",
env_var: "KANDEV_FEATURES_OFFICE",
env_locked: false,
restart_required: true,
requires_restart_to_apply: true,
mutable: true,
};
function renderFeatureTogglesRoute() {
return render(
<TooltipProvider>{renderSettingsRoute("/settings/system/feature-toggles")}</TooltipProvider>,
);
}
beforeEach(() => {
mocks.fetchRestartCapability.mockReset();
mocks.fetchRuntimeFlags.mockReset();
mocks.fetchRuntimeFlags.mockResolvedValue({ flags: [PENDING_RESTART_FLAG] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("feature toggles route restart capability", () => {
it("offers the in-app restart action when the running backend supports restart", async () => {
mocks.fetchRestartCapability.mockResolvedValue({
supported: true,
mode: "supervisor",
adapter: "supervisor",
});
renderFeatureTogglesRoute();
expect(await screen.findByRole("button", { name: "Restart" })).not.toBeNull();
expect(screen.queryByText(/terminal or service manager/)).toBeNull();
});
it("falls back to manual guidance when the running backend cannot restart", async () => {
mocks.fetchRestartCapability.mockResolvedValue({
supported: false,
mode: "manual",
reason: "Automatic restart is not available for this launch mode.",
});
renderFeatureTogglesRoute();
expect(await screen.findByText(/terminal or service manager/)).not.toBeNull();
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
it("fails closed to manual guidance when the capability request rejects", async () => {
mocks.fetchRestartCapability.mockRejectedValue(new Error("network down"));
renderFeatureTogglesRoute();
expect(await screen.findByText(/terminal or service manager/)).not.toBeNull();
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
});
it("renders the toggle cards without waiting for the capability request", async () => {
let resolveCapability: (value: unknown) => void = () => {};
mocks.fetchRestartCapability.mockReturnValue(
new Promise((resolve) => {
resolveCapability = resolve;
}),
);
renderFeatureTogglesRoute();
// Cards are reachable while the capability request is still in flight.
expect(await screen.findByText("Office mode")).not.toBeNull();
expect(screen.queryByText(/terminal or service manager/)).toBeNull();
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
resolveCapability({ supported: true, mode: "supervisor" });
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Restart" })).not.toBeNull();
});
});
});
+2 -2
View File
@@ -55,7 +55,7 @@ import { UsersTable } from "@/components/settings/system/users-table";
import { BackupsTable } from "@/components/settings/system/backups-table";
import { DatabaseStatsCard } from "@/components/settings/system/database-stats-card";
import { DiskUsageCard } from "@/components/settings/system/disk-usage-card";
import { FeatureTogglesSettings } from "@/components/settings/system/feature-toggles-settings";
import { FeatureTogglesRoute } from "@/components/settings/system/feature-toggles-route";
import { HealthIssuesCard } from "@/components/settings/system/health-issues-card";
import { LicensesList } from "@/components/settings/system/licenses-list";
import { LogViewer } from "@/components/settings/system/log-viewer";
@@ -202,7 +202,7 @@ const SETTINGS_ROUTES: Record<string, RouteRenderer> = {
titleKey="system:navFeatureToggles"
descriptionKey="system:featureTogglesPageDescription"
>
<FeatureTogglesSettings initialFlags={[]} restartCapability={null} />
<FeatureTogglesRoute />
</SystemRouteShell>
),
"/settings/system/licenses": () => (
@@ -0,0 +1,146 @@
---
spec: "../../specs/feature-toggles/spec.md"
status: done
created: 2026-08-04
---
# Plan: Restore the Feature Toggles restart action
One task, four production files. The task detail lives in the sibling
[`task-01-restore-restart-action.md`](task-01-restore-restart-action.md).
## Root cause
[`apps/web/src/settings-routes.tsx:217`](../../../apps/web/src/settings-routes.tsx)
renders the Feature Toggles page as:
```tsx
<FeatureTogglesSettings initialFlags={[]} restartCapability={null} />
```
`RestartRequiredAlert`
([`feature-toggles-settings.tsx:207`](../../../apps/web/components/settings/system/feature-toggles-settings.tsx))
computes `const supported = capability?.supported === true`, so a `null` prop is
permanently `false`. The restart `<Button>` at line 221 is never rendered and the
`system:restartManualHint` copy is always appended.
Before the SPA migration, the former Next Feature Toggles page supplied the
capability from `fetchRestartCapability`. During the migration,
`settings-routes.tsx` inlined `FeatureTogglesSettings` but stubbed the prop to
`null` instead of porting that fetch. The former page was later removed during
the Next cleanup, leaving the SPA route as the only live composition. The
`useKandevRestart` flow calls only `fetchSystemInfo` and `requestRestart`; it
does not detect capability.
Introduced by `aee1846cd` *"feat: remove nextjs production runtime (#1389)"* —
the line was born stubbed when the Next→Vite port dropped the server-side fetch.
**Conditions:** every install whose backend reports restart support, which is the
normal case — both [`launcher/supervisor.go:151`](../../../apps/backend/internal/launcher/supervisor.go)
and [`cli/src/supervisor/backend.ts:105`](../../../apps/cli/src/supervisor/backend.ts)
set `KANDEV_RESTART_ADAPTER=supervisor`. This contradicts ADR 0019, which states
the UI needs a restart path "without asking users to manually find and restart
the right process."
**Reproduction:** run any supervised `kandev`; confirm
`GET /api/v1/system/restart-capability` returns `{"supported":true,"mode":"supervisor"}`;
save any override with `requires_restart_to_apply` (for example `features.office`);
open Settings → System → Feature Toggles. The notice shows the manual hint with
no restart button. Verified on a production bundle (hashed `/assets/index-*.js`,
no `@vite/client`) served by the Go binary, not a dev server.
**Why tests missed it:**
[`feature-toggles-settings.test.tsx`](../../../apps/web/components/settings/system/feature-toggles-settings.test.tsx)
passes `restartCapability={null}` in seven places and `{supported: false}` in
one. It never passes `supported: true`, and it tests the *component*, never the
route that supplies the prop. A component-level test cannot catch this class of
bug, so the regression test must render the route.
## Scope
`settings-routes.tsx:217` is the only hardcoded `null`/`[]` prop in the settings
route table — a grep for `={null}|={[]}` returns that single line, so there are
no sibling instances to fix. `initialFlags={[]}` is harmless: the component
reloads flags itself on mount.
## Task 1 — Fetch restart capability in the SPA route
**Acceptance:**
- The Feature Toggles route resolves `restartCapability` from the running
backend through the system-domain hook and passes it to `FeatureTogglesSettings`.
- Toggle cards render immediately; they do not block on the capability request.
While capability detection is pending, the notice stays neutral. Manual
guidance appears only after an unsupported or unavailable result.
- A failed or rejected capability request resolves to `null`, preserving today's
manual-guidance behavior — fail closed, never a phantom button.
- The system-domain hook uses a `cancelled` guard so an unmounted route does not
set state.
- With `{supported: true}`, the notice renders the restart action and omits
`system:restartManualHint`. With `{supported: false}` or `null`, it renders the
manual hint and no action.
**Files:** `apps/web/src/settings-routes.tsx`,
`apps/web/components/settings/system/feature-toggles-route.tsx`,
`apps/web/components/settings/system/feature-toggles-settings.tsx`, and
`apps/web/hooks/domains/system/use-restart-capability.ts` (production changes),
`apps/web/src/settings-routes.test.ts` or a new
`apps/web/src/settings-routes.feature-toggles.test.tsx` (route-level regression
test, via the exported `renderSettingsRoute`), the hook test, and desktop/mobile
Playwright specs.
**Regression test — must fail before the change:** render the
`/settings/system/feature-toggles` route with `fetchRestartCapability` mocked to
`{supported: true, mode: "supervisor"}` and a pending-restart flag; assert the
restart action is present. Against current `main` this fails because the route
passes `null`.
**Verification:**
```bash
cd apps/web && pnpm vitest run src/settings-routes.test.ts components/settings/system/feature-toggles-settings.test.tsx && pnpm run typecheck
```
**parallelism:** sequential
**Status: done.** The fix landed as a new
`apps/web/components/settings/system/feature-toggles-route.tsx` rather than an
inline component in `settings-routes.tsx`: inlining it pushed that file to 605
counted lines against the 600-line `max-lines` limit, so the route wrapper was
extracted beside the component it wraps. `settings-routes.tsx` now imports
`FeatureTogglesRoute` and no longer imports `FeatureTogglesSettings`,
`fetchRestartCapability`, or `RestartCapability`.
Recorded results:
- Regression test failed first for the expected reason — 2 failed / 2 passed,
both `supported: true` cases failing because the route supplied `null`.
- `pnpm vitest run src/settings-routes.test.ts src/settings-routes.feature-toggles.test.tsx components/settings/system/feature-toggles-settings.test.tsx` → 3 files, 34 tests passed.
- `pnpm vitest run src/ components/settings/` → 97 files, 523 tests passed.
- `pnpm run typecheck` → clean.
- `pnpm exec eslint` on all changed files → 0 errors, 0 warnings.
- `pnpm run i18n:ratchet` → clean, guard allowlist intact (199 entries).
- Focused Feature Toggles Vitest suite (route, component, and capability hook) →
3 files, 14 tests passed.
## Out of scope
- Retiring the 44 orphaned `async` server components under `apps/web/app/**`,
including `app/settings/system/feature-toggles/page.tsx`. Each one is a silent
invitation to repeat this bug: it looks authoritative, still typechecks, and
can drift from the SPA route that actually renders its component. Auditing
which are orphaned and deleting those is a separate change with its own blast
radius; this fix must not depend on it.
- Any change to the backend restart adapter, `boot_id` polling, or ADR 0019.
- E2E coverage of the restart round-trip. A real restart under Playwright would
bounce the worker's backend; the route-level unit test is the proportionate
guard.
## Risks
- Low. One added client fetch on a settings route, already-typed API helper, and
a fail-closed fallback that reproduces today's behavior on error.
- The restart button becomes reachable for the first time since #1389. The
underlying `useKandevRestart` flow and the supervisor endpoint are unchanged
and independently exercised, but this fix is what puts them in front of users —
worth a manual restart on a supervised install before merge.
@@ -0,0 +1,53 @@
---
id: "01-restore-restart-action"
title: "Restore Feature Toggles restart action"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/feature-toggles/spec.md"
---
# Task 01: Restore Feature Toggles restart action
## Acceptance
- The SPA route table renders a wrapper that reads restart capability through a
system-domain hook.
- Capability loading is distinct from unsupported or unavailable results.
- Supported supervisors show the in-app restart action.
- Unsupported or unavailable capability results show manual guidance.
- Desktop and mobile Playwright coverage proves the supported action and mobile
touch/viewport behavior.
## Verification
Use TDD: first prove the pending capability state shows no manual guidance, then
run the focused Feature Toggles Vitest suite. Run the desktop and mobile
Playwright specs against a rebuilt production bundle when E2E verification is
available.
Recorded verification: focused route, component, and capability-hook Vitest
suite — 3 files, 14 tests passed; changed-file ESLint — clean. Playwright E2E
was not run locally.
## Files
- `apps/web/src/settings-routes.tsx`
- `apps/web/components/settings/system/feature-toggles-route.tsx`
- `apps/web/components/settings/system/feature-toggles-settings.tsx`
- `apps/web/hooks/domains/system/use-restart-capability.ts`
- `apps/web/hooks/domains/system/use-restart-capability.test.ts`
- `apps/web/src/settings-routes.feature-toggles.test.tsx`
- `apps/web/e2e/tests/settings/feature-toggles-restart.spec.ts`
- `apps/web/e2e/tests/settings/mobile-feature-toggles-restart.spec.ts`
## Dependencies
None.
## Risks
- Keep the restart action disabled until capability detection resolves.
- Preserve backend authorization; the UI must not be treated as an access
control.
+20 -4
View File
@@ -1,7 +1,7 @@
---
status: draft
created: 2026-06-14
updated: 2026-08-02
updated: 2026-08-04
owner: tbd
---
@@ -36,7 +36,11 @@ duplicating flag identity or config-binding code across many files.
- An administrator can save an installation override from Feature Toggles.
Explicit environment variables remain authoritative and lock the UI control.
- Every toggle change requires restart before its effective runtime value
changes. Unsupported supervisors show manual restart guidance.
changes. The page reads restart capability from the running backend: a
restart-capable supervisor is offered an in-app restart action that applies
the saved override, while unsupported or unavailable capability results show
manual restart guidance. Capability is a property of the running process,
never a build-time constant.
- `/api/v1/features` exposes the startup-effective feature booleans used by SSR
and `useFeature()` callers. Missing or unavailable frontend flag data fails
closed to `false`.
@@ -165,8 +169,9 @@ a saved override. A saved value that differs from the current process value is
request without mutation or side effects. Route-owned surfaces use `404`;
extensions to an existing endpoint use a stable client error defined by that
feature.
- **Restart unsupported or unsuccessful** — manual restart/recovery guidance is
shown and the running process retains its old effective values.
- **Restart unsupported, unavailable, or unsuccessful** — manual
restart/recovery guidance is shown and the running process retains its old
effective values.
- **Unknown persisted override** — the row is ignored and hidden, but retained
for downgrade compatibility. Its key cannot be assigned to a future feature.
- **Feature disabled after enabled data exists** — the data remains readable but
@@ -215,6 +220,17 @@ a saved override. A saved value that differs from the current process value is
- **GIVEN** a phone viewport, **WHEN** an administrator opens Feature Toggles and
changes an override, **THEN** the same save, reset, lock, risk, and restart
information is reachable without horizontal scrolling.
- **GIVEN** a saved override is pending restart and the running backend reports
restart supported, **WHEN** an administrator opens Feature Toggles, **THEN**
the restart notice offers an in-app restart action and does not tell the
administrator to restart from a terminal or service manager.
- **GIVEN** a saved override is pending restart and the running backend reports
restart unsupported, **WHEN** an administrator opens Feature Toggles, **THEN**
no restart action is offered, the manual restart guidance is shown, and the
reported reason is available from the notice.
- **GIVEN** a saved override is pending restart and capability detection is
unavailable, **WHEN** an administrator opens Feature Toggles, **THEN** no
restart action is offered and manual restart guidance is shown.
## Success criteria