fix(web): restore the Feature Toggles restart action (#2236)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user