fix(web): detect backend restarts in open pages (#3346)
This commit is contained in:
@@ -1049,9 +1049,13 @@ func bootPayload(ctx context.Context, req *http.Request, p routeParams, route we
|
||||
if route.Route == webapp.RouteTaskDetail && routeData == nil && canLoadTaskDetailFallback(req, p.authSvc) {
|
||||
bootStateBuilder{p: p}.addHomeKanbanRouteState(ctx, req, initialState)
|
||||
}
|
||||
runtime := webRuntimeConfig(p.devMode, p.webTitlePrefix, req)
|
||||
if p.systemSvc != nil && p.systemSvc.Info != nil {
|
||||
runtime.BootID = p.systemSvc.Info.Info().BootID
|
||||
}
|
||||
payload := webapp.NewBootPayload(
|
||||
route,
|
||||
webRuntimeConfig(p.devMode, p.webTitlePrefix, req),
|
||||
runtime,
|
||||
initialState,
|
||||
)
|
||||
payload.RouteData = routeData
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
gateways "github.com/kandev/kandev/internal/gateway/websocket"
|
||||
"github.com/kandev/kandev/internal/quickterminal"
|
||||
quickterminalrepo "github.com/kandev/kandev/internal/quickterminal/repository"
|
||||
systemsvc "github.com/kandev/kandev/internal/system"
|
||||
systeminfo "github.com/kandev/kandev/internal/system/info"
|
||||
storagepkg "github.com/kandev/kandev/internal/system/storage"
|
||||
storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces"
|
||||
taskdto "github.com/kandev/kandev/internal/task/dto"
|
||||
@@ -1430,6 +1432,34 @@ func TestBootPayloadOmitsUnsetTitlePrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadCarriesSystemInfoBootID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
infoSvc := systeminfo.NewService("version", "commit", "build-time")
|
||||
payload := bootPayload(
|
||||
context.Background(),
|
||||
nil,
|
||||
routeParams{systemSvc: &systemsvc.Service{Info: infoSvc}},
|
||||
webapp.ClassifyRoute("/"),
|
||||
)
|
||||
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal payload: %v", err)
|
||||
}
|
||||
var decoded struct {
|
||||
Runtime struct {
|
||||
BootID string `json:"bootId"`
|
||||
} `json:"runtime"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal payload: %v", err)
|
||||
}
|
||||
if decoded.Runtime.BootID != infoSvc.Info().BootID {
|
||||
t.Fatalf("runtime.bootId = %q, want %q", decoded.Runtime.BootID, infoSvc.Info().BootID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
|
||||
harness := newBootStateTestHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
// authentication credential.
|
||||
const InterimSettingsInterlockHeader = "X-Kandev-Interim-Settings-Interlock"
|
||||
|
||||
// InterimSettingsInterlockErrorCode identifies a stale browser document
|
||||
// without requiring clients to match the human-readable error message.
|
||||
const InterimSettingsInterlockErrorCode = "interim_settings_interlock_required"
|
||||
|
||||
// NewInterimSettingsInterlockToken returns a random token for one backend
|
||||
// boot. Callers must fail closed when token generation fails.
|
||||
func NewInterimSettingsInterlockToken() (string, error) {
|
||||
@@ -33,7 +37,10 @@ func InterimSettingsInterlock(token string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if strings.TrimSpace(token) == "" || hasBearerAuthorization(c.GetHeader("Authorization")) ||
|
||||
!constantTimeEqual(c.GetHeader(InterimSettingsInterlockHeader), token) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "interim settings interlock required"})
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "interim settings interlock required",
|
||||
"error_code": InterimSettingsInterlockErrorCode,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpmw
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -40,6 +41,15 @@ func TestInterimSettingsInterlockRejectsMissingWrongBearerAndEmptyServerToken(t
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
|
||||
}
|
||||
var body struct {
|
||||
ErrorCode string `json:"error_code"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response body: %v", err)
|
||||
}
|
||||
if body.ErrorCode != InterimSettingsInterlockErrorCode {
|
||||
t.Fatalf("error_code = %q, want %q", body.ErrorCode, InterimSettingsInterlockErrorCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler was called")
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type ActivePluginPayload struct {
|
||||
type RuntimeConfig struct {
|
||||
APIPrefix string `json:"apiPrefix"`
|
||||
WebSocketPath string `json:"webSocketPath"`
|
||||
BootID string `json:"bootId,omitempty"`
|
||||
LSPAutoInstallPreferenceLanguages []string `json:"lspAutoInstallPreferenceLanguages,omitempty"`
|
||||
Debug bool `json:"debug,omitempty"`
|
||||
// NonProduction marks a dev or e2e build. Distinct from Debug (which the SPA
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/config", () => ({
|
||||
getBackendConfig: () => ({ apiBaseUrl: "http://backend.test" }),
|
||||
}));
|
||||
|
||||
const reloadMocks = vi.hoisted(() => ({
|
||||
signalBackendReloadRequired: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
|
||||
signalBackendReloadRequired: (...args: unknown[]) =>
|
||||
reloadMocks.signalBackendReloadRequired(...args),
|
||||
}));
|
||||
|
||||
import { deleteAgentProfileAction } from "./agents";
|
||||
|
||||
const bootWindow = window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown };
|
||||
|
||||
beforeEach(() => {
|
||||
bootWindow.__KANDEV_BOOT_PAYLOAD__ = {
|
||||
interimSettingsInterlockToken: "boot-token",
|
||||
};
|
||||
reloadMocks.signalBackendReloadRequired.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete bootWindow.__KANDEV_BOOT_PAYLOAD__;
|
||||
});
|
||||
|
||||
describe("deleteAgentProfileAction", () => {
|
||||
it("returns a handled result for the stale interlock response", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "interim settings interlock required",
|
||||
error_code: "interim_settings_interlock_required",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
|
||||
await expect(deleteAgentProfileAction("profile-1")).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "interim settings interlock required",
|
||||
handled: true,
|
||||
});
|
||||
expect(reloadMocks.signalBackendReloadRequired).toHaveBeenCalledWith(
|
||||
"settings_interlock_rejected",
|
||||
);
|
||||
expect(
|
||||
new Headers(fetcher.mock.calls[0]?.[1]?.headers).get("X-Kandev-Interim-Settings-Interlock"),
|
||||
).toBe("boot-token");
|
||||
});
|
||||
|
||||
it("keeps the typed conflict result for profile deletion", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
active_sessions: [{ id: "session-1" }],
|
||||
watchers: [],
|
||||
routing_tiers: [],
|
||||
automations: [],
|
||||
utility_agents: [],
|
||||
}),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(deleteAgentProfileAction("profile-1")).resolves.toMatchObject({
|
||||
status: "conflict",
|
||||
activeSessions: [{ id: "session-1" }],
|
||||
});
|
||||
expect(reloadMocks.signalBackendReloadRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { fetchJson } from "@/lib/api/client";
|
||||
import { readInterimSettingsInterlockToken } from "@/src/boot-payload";
|
||||
import { ApiError, fetchJson } from "@/lib/api/client";
|
||||
import type {
|
||||
Agent,
|
||||
AgentProfile,
|
||||
@@ -20,7 +19,6 @@ import type { AgentProfileKind } from "@/lib/types/agent-profile";
|
||||
type ProfilePermissions = Record<PermissionKey, boolean>;
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
const interimSettingsInterlockHeader = "X-Kandev-Interim-Settings-Interlock";
|
||||
|
||||
type DynamicProfilePayload = {
|
||||
version: number;
|
||||
@@ -193,53 +191,67 @@ export type DeleteProfileResult =
|
||||
automations: AutomationReference[];
|
||||
utilityAgents: UtilityAgentReference[];
|
||||
}
|
||||
| { status: "error"; message: string };
|
||||
| { status: "error"; message: string; handled?: boolean };
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
export async function deleteAgentProfileAction(
|
||||
id: string,
|
||||
force?: boolean,
|
||||
): Promise<DeleteProfileResult> {
|
||||
const url = `${apiBaseUrl}/api/v1/agent-profiles/${id}${force ? "?force=true" : ""}`;
|
||||
const token = readInterimSettingsInterlockToken();
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { [interimSettingsInterlockHeader]: token } : {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
// A 409 is active sessions, referencing watchers, routing tier mappings, or a mix.
|
||||
// Treat any non-empty list as the conflict signal — a watcher-only
|
||||
// conflict (the new self-heal path) must still pop the dialog.
|
||||
if (
|
||||
response.status === 409 &&
|
||||
(body.active_sessions ||
|
||||
body.watchers ||
|
||||
body.routing_tiers ||
|
||||
body.automations ||
|
||||
body.utility_agents)
|
||||
) {
|
||||
return {
|
||||
status: "conflict",
|
||||
activeSessions: body.active_sessions ?? [],
|
||||
watchers: body.watchers ?? [],
|
||||
routingTiers: body.routing_tiers ?? [],
|
||||
automations: body.automations ?? [],
|
||||
utilityAgents: body.utility_agents ?? [],
|
||||
};
|
||||
}
|
||||
// i18n-exempt: server-provided error text, or an HTTP status diagnostic when
|
||||
// the server sent none. The toast title around it is translated.
|
||||
try {
|
||||
await agentSettingsRequest<void>(url, { method: "DELETE" });
|
||||
return { status: "ok" };
|
||||
} catch (error) {
|
||||
return deleteAgentProfileError(error);
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteProfileErrorBody = {
|
||||
error?: string;
|
||||
active_sessions?: ActiveSessionInfo[];
|
||||
watchers?: WatcherReference[];
|
||||
routing_tiers?: RoutingTierReference[];
|
||||
automations?: AutomationReference[];
|
||||
utility_agents?: UtilityAgentReference[];
|
||||
};
|
||||
|
||||
function readDeleteProfileErrorBody(value: unknown): DeleteProfileErrorBody {
|
||||
return value && typeof value === "object" ? (value as DeleteProfileErrorBody) : {};
|
||||
}
|
||||
|
||||
function hasDeleteProfileConflict(body: DeleteProfileErrorBody): boolean {
|
||||
return Boolean(
|
||||
body.active_sessions ||
|
||||
body.watchers ||
|
||||
body.routing_tiers ||
|
||||
body.automations ||
|
||||
body.utility_agents,
|
||||
);
|
||||
}
|
||||
|
||||
function deleteAgentProfileError(error: unknown): DeleteProfileResult {
|
||||
const apiError = error instanceof ApiError ? error : undefined;
|
||||
const body = apiError ? readDeleteProfileErrorBody(apiError.body) : {};
|
||||
// A 409 is active sessions, referencing watchers, routing tier mappings, or a mix.
|
||||
// Treat any non-empty list as the conflict signal — a watcher-only
|
||||
// conflict (the new self-heal path) must still pop the dialog.
|
||||
if (apiError?.status === 409 && hasDeleteProfileConflict(body)) {
|
||||
return {
|
||||
status: "error",
|
||||
message: body?.error || `Request failed: ${response.status} ${response.statusText}`,
|
||||
status: "conflict",
|
||||
activeSessions: body.active_sessions ?? [],
|
||||
watchers: body.watchers ?? [],
|
||||
routingTiers: body.routing_tiers ?? [],
|
||||
automations: body.automations ?? [],
|
||||
utilityAgents: body.utility_agents ?? [],
|
||||
};
|
||||
}
|
||||
return { status: "ok" };
|
||||
// i18n-exempt: server-provided error text, or an HTTP status diagnostic when
|
||||
// the server sent none. The toast title around it is translated.
|
||||
return {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Request failed",
|
||||
...(apiError?.handled ? { handled: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAgentProfileMcpConfigAction(
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SettingsRedirect } from "@/src/settings-route-helpers";
|
||||
import { saveNewAgent, saveExistingAgent, isProfileDirty } from "./agent-save-helpers";
|
||||
import type { DraftProfile, DraftAgent } from "./agent-save-helpers";
|
||||
import { AgentHeader, ProfilesCard } from "./agent-setup-parts";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
|
||||
const defaultMcpConfig: NonNullable<DraftProfile["mcp_config"]> = {
|
||||
enabled: false,
|
||||
@@ -582,6 +583,7 @@ export default function AgentSetupPage() {
|
||||
if (!initialAgent) return null;
|
||||
|
||||
const handleToastError = (error: unknown) => {
|
||||
if (isHandledApiError(error)) return;
|
||||
toast({
|
||||
title: t("agents:failedToSaveAgent"),
|
||||
description: error instanceof Error ? error.message : t("agents:requestFailed"),
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
|
||||
import type { Agent } from "@/lib/types/http-agents";
|
||||
|
||||
const { updateMcpStrategy, toast } = vi.hoisted(() => ({
|
||||
updateMcpStrategy: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
// The strategy list is fetched on mount; the mount assertion below is about
|
||||
// whether the control is reachable at all, not about the option list.
|
||||
vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listMCPStrategies: vi.fn(async () => [{ key: "claude", description: "a config file" }]),
|
||||
updateCustomTUIAgentMCPStrategy: vi.fn(),
|
||||
updateCustomTUIAgentMCPStrategy: updateMcpStrategy,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/toast-provider", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useToast: () => ({ toast }),
|
||||
}));
|
||||
|
||||
const { CustomTUIMcpCard } = await import("@/components/settings/custom-tui-mcp-card");
|
||||
@@ -74,6 +85,7 @@ function renderIndexComposition(saved: Agent | undefined, name = "fuel-claude")
|
||||
|
||||
describe("custom TUI MCP control on the agents index", () => {
|
||||
afterEach(cleanup);
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders for a saved custom TUI agent", async () => {
|
||||
renderIndexComposition(tuiAgent());
|
||||
@@ -106,4 +118,20 @@ describe("custom TUI MCP control on the agents index", () => {
|
||||
renderIndexComposition(undefined);
|
||||
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not toast a handled stale-page update error", async () => {
|
||||
const staleError = new ApiError("interim settings interlock required", 403, {
|
||||
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
|
||||
});
|
||||
staleError.handled = true;
|
||||
updateMcpStrategy.mockRejectedValueOnce(staleError);
|
||||
renderIndexComposition(tuiAgent());
|
||||
|
||||
fireEvent.click(await screen.findByTestId(STRATEGY_SELECT));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /claude/ }));
|
||||
|
||||
await waitFor(() => expect(updateMcpStrategy).toHaveBeenCalledOnce());
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(staleError.message)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createAgentProfileAction,
|
||||
updateAgentProfileAction,
|
||||
} from "@/app/actions/agents";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import type { Agent, AgentProfile, AvailableAgent, CLIFlag } from "@/lib/types/http";
|
||||
import { seedDefaultCLIFlags } from "@/lib/cli-flags";
|
||||
import {
|
||||
@@ -171,6 +172,9 @@ export function CliProfileEditor({
|
||||
? await saveExistingProfile(profile.id, form)
|
||||
: await saveNewProfile(form, settingsAgents);
|
||||
onSaved(saved);
|
||||
} catch (error) {
|
||||
if (isHandledApiError(error)) return;
|
||||
throw error;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import { AppStatusDrawer } from "./app-status-drawer";
|
||||
import { connectionIssueDetails } from "./connection-status-item";
|
||||
import type { ConnectionIssueSeverity } from "@/lib/types/connection";
|
||||
import { AgentRuntimeUnavailableAlert } from "./agent-runtime-unavailable-alert";
|
||||
import { BackendReloadRequiredAlert } from "./backend-reload-required-alert";
|
||||
import { useBackendGenerationGuard } from "@/hooks/domains/system/use-backend-generation-guard";
|
||||
|
||||
type AppStatusDrawerContextValue = {
|
||||
enabled: boolean;
|
||||
@@ -111,6 +113,7 @@ function drawerTriggerVisibilityClass(connectionOnly: boolean) {
|
||||
}
|
||||
|
||||
export function AppStatusSurfaceProvider({ children }: { children: ReactNode }) {
|
||||
useBackendGenerationGuard();
|
||||
const [drawerOpen, setStatusDrawerOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
|
||||
@@ -162,6 +165,7 @@ export function AppStatusSurfaceProvider({ children }: { children: ReactNode })
|
||||
<AppStatusDrawerContext.Provider value={drawer}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<AgentRuntimeUnavailableAlert />
|
||||
<BackendReloadRequiredAlert />
|
||||
{children}
|
||||
{(useDrawerSurface ? drawerEnabled : inlineStatusBarVisible) &&
|
||||
(useDrawerSurface ? (
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BackendReloadSnapshot } from "@/lib/platform/backend-reload-coordinator";
|
||||
|
||||
const reloadState = vi.hoisted(() => ({
|
||||
snapshot: {
|
||||
reloadRequired: false,
|
||||
source: null as BackendReloadSnapshot["source"],
|
||||
ownerCount: 0,
|
||||
},
|
||||
listeners: new Set<() => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
|
||||
backendReloadCoordinator: {
|
||||
getSnapshot: () => reloadState.snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
reloadState.listeners.add(listener);
|
||||
return () => reloadState.listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { BackendReloadRequiredAlert } from "./backend-reload-required-alert";
|
||||
|
||||
function setSnapshot(snapshot: typeof reloadState.snapshot): void {
|
||||
reloadState.snapshot = snapshot;
|
||||
reloadState.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reloadState.snapshot = { reloadRequired: false, source: null, ownerCount: 0 };
|
||||
reloadState.listeners.clear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("BackendReloadRequiredAlert", () => {
|
||||
it("shows one persistent reload action after a proven restart", () => {
|
||||
act(() => {
|
||||
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
|
||||
});
|
||||
|
||||
render(<BackendReloadRequiredAlert />);
|
||||
|
||||
expect(screen.getByTestId("backend-reload-required-alert")).toBeTruthy();
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.getByText("Reload required")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Kandev restarted. Reload this page to continue. Reloading discards unsaved changes.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Reload page" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the global alert hidden while an intentional restart owns recovery", () => {
|
||||
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 1 });
|
||||
|
||||
render(<BackendReloadRequiredAlert />);
|
||||
expect(screen.queryByTestId("backend-reload-required-alert")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
|
||||
});
|
||||
expect(screen.getByTestId("backend-reload-required-alert")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reloads the current document only after the user activates the action", () => {
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
|
||||
|
||||
render(<BackendReloadRequiredAlert />);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reload page" }));
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses a touch-sized full-width action in the phone layout", () => {
|
||||
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
|
||||
|
||||
render(<BackendReloadRequiredAlert />);
|
||||
|
||||
const action = screen.getByRole("button", { name: "Reload page" });
|
||||
expect(action.className).toContain("h-11");
|
||||
expect(action.className).toContain("w-full");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { backendReloadCoordinator } from "@/lib/platform/backend-reload-coordinator";
|
||||
|
||||
export function BackendReloadRequiredAlert() {
|
||||
const { t } = useTranslation();
|
||||
const snapshot = useSyncExternalStore(
|
||||
backendReloadCoordinator.subscribe,
|
||||
backendReloadCoordinator.getSnapshot,
|
||||
backendReloadCoordinator.getSnapshot,
|
||||
);
|
||||
|
||||
if (!snapshot.reloadRequired || snapshot.ownerCount > 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 w-full shrink-0 px-3 pt-3 sm:px-4"
|
||||
data-testid="backend-reload-required-alert"
|
||||
>
|
||||
<Alert role="alert" variant="destructive" className="min-w-0 gap-x-2 px-3 py-3 sm:px-4">
|
||||
<IconAlertTriangle className="mt-0.5 size-4" aria-hidden="true" />
|
||||
<AlertTitle className="min-w-0 break-words text-sm">
|
||||
{t("system:backendReloadRequiredTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="col-start-2 flex min-w-0 flex-col gap-3 text-sm sm:flex-row sm:items-start sm:justify-between">
|
||||
<p className="min-w-0 break-words">{t("system:backendReloadRequiredBody")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-11 w-full shrink-0 cursor-pointer sm:w-auto"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t("system:backendReloadRequiredAction")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OnboardingDialog } from "./onboarding-dialog";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
listAvailableAgents: vi.fn(),
|
||||
listWorkflowTemplates: vi.fn(),
|
||||
}));
|
||||
|
||||
const actionMocks = vi.hoisted(() => ({
|
||||
listAgentsAction: vi.fn(),
|
||||
updateAgentProfileAction: vi.fn(),
|
||||
}));
|
||||
|
||||
const staleSave = vi.hoisted(() => new Error("stale settings save"));
|
||||
|
||||
const coordinatorState = vi.hoisted(() => ({
|
||||
snapshot: {
|
||||
reloadRequired: false,
|
||||
source: null as "boot_id_changed" | "settings_interlock_rejected" | null,
|
||||
ownerCount: 0,
|
||||
},
|
||||
listeners: new Set<() => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => apiMocks);
|
||||
vi.mock("@/app/actions/agents", () => actionMocks);
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
isHandledApiError: (error: unknown) => error === staleSave,
|
||||
}));
|
||||
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
|
||||
backendReloadCoordinator: {
|
||||
getSnapshot: () => coordinatorState.snapshot,
|
||||
subscribe: (listener: () => void) => {
|
||||
coordinatorState.listeners.add(listener);
|
||||
return () => coordinatorState.listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@kandev/ui/dialog", () => {
|
||||
const Content = ({ children }: { children: ReactNode }) => <div>{children}</div>;
|
||||
return {
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? <div data-testid="onboarding-dialog">{children}</div> : null,
|
||||
DialogContent: Content,
|
||||
DialogHeader: Content,
|
||||
DialogTitle: Content,
|
||||
DialogFooter: Content,
|
||||
DialogDescription: Content,
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/onboarding/step-agents", () => ({
|
||||
StepAgents: ({
|
||||
agentSettings,
|
||||
onUpdateSetting,
|
||||
}: {
|
||||
agentSettings: Record<string, unknown>;
|
||||
onUpdateSetting: (agentName: string, formPatch: { model: string }) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!agentSettings["test-agent"]}
|
||||
onClick={() => onUpdateSetting("test-agent", { model: "changed-model" })}
|
||||
>
|
||||
Make agent dirty
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const availableAgent = {
|
||||
name: "test-agent",
|
||||
display_name: "Test Agent",
|
||||
model_config: {
|
||||
default_model: "default-model",
|
||||
current_mode_id: "default-mode",
|
||||
status: "ok",
|
||||
available_models: [],
|
||||
},
|
||||
permission_settings: {},
|
||||
};
|
||||
|
||||
const savedAgent = {
|
||||
name: "test-agent",
|
||||
profiles: [
|
||||
{
|
||||
id: "profile-1",
|
||||
name: "Default",
|
||||
model: "default-model",
|
||||
mode: "default-mode",
|
||||
allowIndexing: false,
|
||||
autoApprove: false,
|
||||
cliPassthrough: false,
|
||||
cliFlags: [],
|
||||
commandPrefix: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function signalReloadRequired() {
|
||||
coordinatorState.snapshot = {
|
||||
reloadRequired: true,
|
||||
source: "settings_interlock_rejected",
|
||||
ownerCount: 0,
|
||||
};
|
||||
coordinatorState.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
coordinatorState.snapshot = { reloadRequired: false, source: null, ownerCount: 0 };
|
||||
coordinatorState.listeners.clear();
|
||||
apiMocks.listAvailableAgents.mockResolvedValue({ agents: [availableAgent], tools: [] });
|
||||
apiMocks.listWorkflowTemplates.mockResolvedValue({ templates: [] });
|
||||
actionMocks.listAgentsAction.mockResolvedValue({ agents: [savedAgent] });
|
||||
actionMocks.updateAgentProfileAction.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("OnboardingDialog backend restart recovery", () => {
|
||||
it("keeps the wizard on the agent step after a handled stale save", async () => {
|
||||
actionMocks.updateAgentProfileAction.mockRejectedValue(staleSave);
|
||||
const onComplete = vi.fn();
|
||||
|
||||
render(<OnboardingDialog open onComplete={onComplete} />);
|
||||
const dirtyButton = (await screen.findByRole("button", {
|
||||
name: "Make agent dirty",
|
||||
})) as HTMLButtonElement;
|
||||
await waitFor(() => expect(dirtyButton.disabled).toBe(false));
|
||||
fireEvent.click(dirtyButton);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
|
||||
await waitFor(() => expect(actionMocks.updateAgentProfileAction).toHaveBeenCalledOnce());
|
||||
expect(screen.getByText("AI Agents")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Next" })).toBeTruthy();
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks get started and yields the modal when the final save is stale", async () => {
|
||||
let saveCount = 0;
|
||||
actionMocks.updateAgentProfileAction.mockImplementation(async () => {
|
||||
saveCount += 1;
|
||||
if (saveCount === 2) {
|
||||
signalReloadRequired();
|
||||
throw staleSave;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const onComplete = vi.fn();
|
||||
|
||||
render(<OnboardingDialog open onComplete={onComplete} />);
|
||||
const dirtyButton = (await screen.findByRole("button", {
|
||||
name: "Make agent dirty",
|
||||
})) as HTMLButtonElement;
|
||||
await waitFor(() => expect(dirtyButton.disabled).toBe(false));
|
||||
fireEvent.click(dirtyButton);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await waitFor(() => expect(screen.getByText("Executors")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await waitFor(() => expect(screen.getByText("Agentic Workflows")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await waitFor(() => expect(screen.getByText("Command Panel")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Get Started" }));
|
||||
|
||||
await waitFor(() => expect(actionMocks.updateAgentProfileAction).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.queryByTestId("onboarding-dialog")).toBeNull());
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,6 +32,8 @@ import { type ProfileFormData } from "@/components/settings/profile-form-fields"
|
||||
import { permissionsToProfilePatch, profilePermissionValues } from "@/lib/agent-permissions";
|
||||
import { listAvailableAgents, listWorkflowTemplates } from "@/lib/api";
|
||||
import { listAgentsAction, updateAgentProfileAction } from "@/app/actions/agents";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import { backendReloadCoordinator } from "@/lib/platform/backend-reload-coordinator";
|
||||
import { StepAgents, type AgentSetting } from "@/components/onboarding/step-agents";
|
||||
import type { AvailableAgent, ToolStatus, WorkflowTemplate, AgentProfile } from "@/lib/types/http";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -261,6 +263,11 @@ function OnboardingFooter({ step, onSkip, onBack, onNext, onGetStarted }: Onboar
|
||||
|
||||
export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const reloadRequired = useSyncExternalStore(
|
||||
backendReloadCoordinator.subscribe,
|
||||
backendReloadCoordinator.getSnapshot,
|
||||
backendReloadCoordinator.getSnapshot,
|
||||
).reloadRequired;
|
||||
const [step, setStep] = useState(0);
|
||||
const {
|
||||
availableAgents,
|
||||
@@ -272,20 +279,26 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
loadingTemplates,
|
||||
} = useOnboardingResources(open);
|
||||
|
||||
const saveAgentSettings = useCallback(async () => {
|
||||
await Promise.all(
|
||||
Object.values(agentSettings)
|
||||
.filter((s) => s.dirty)
|
||||
.map((s) =>
|
||||
updateAgentProfileAction(s.profileId, {
|
||||
model: s.formData.model,
|
||||
...permissionsToProfilePatch(s.formData),
|
||||
cli_passthrough: s.formData.cli_passthrough,
|
||||
cli_flags: s.formData.cli_flags,
|
||||
command_prefix: s.formData.command_prefix,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const saveAgentSettings = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
await Promise.all(
|
||||
Object.values(agentSettings)
|
||||
.filter((s) => s.dirty)
|
||||
.map((s) =>
|
||||
updateAgentProfileAction(s.profileId, {
|
||||
model: s.formData.model,
|
||||
...permissionsToProfilePatch(s.formData),
|
||||
cli_passthrough: s.formData.cli_passthrough,
|
||||
cli_flags: s.formData.cli_flags,
|
||||
command_prefix: s.formData.command_prefix,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isHandledApiError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}, [agentSettings]);
|
||||
|
||||
const handleSkip = () => {
|
||||
@@ -293,14 +306,14 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
setStep(0);
|
||||
};
|
||||
const handleNext = async () => {
|
||||
if (step === 0) await saveAgentSettings();
|
||||
if (step === 0 && !(await saveAgentSettings())) return;
|
||||
if (step < TOTAL_STEPS - 1) setStep(step + 1);
|
||||
};
|
||||
const handleBack = () => {
|
||||
if (step > 0) setStep(step - 1);
|
||||
};
|
||||
const handleGetStarted = async () => {
|
||||
await saveAgentSettings();
|
||||
if (!(await saveAgentSettings())) return;
|
||||
onComplete();
|
||||
setStep(0);
|
||||
};
|
||||
@@ -316,7 +329,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<Dialog open={open && !reloadRequired} onOpenChange={() => {}}>
|
||||
<DialogContent className="sm:max-w-3xl" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-2xl">{t(STEP_TITLE_KEYS[step])}</DialogTitle>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
|
||||
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -69,4 +70,22 @@ describe("AddTUIAgentDialog", () => {
|
||||
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes without exposing a handled stale-page error", async () => {
|
||||
const staleError = new ApiError("interim settings interlock required", 403, {
|
||||
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
|
||||
});
|
||||
staleError.handled = true;
|
||||
const onSubmit = vi.fn().mockRejectedValue(staleError);
|
||||
const onOpenChange = vi.fn();
|
||||
render(<AddTUIAgentDialog open onOpenChange={onOpenChange} onSubmit={onSubmit} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
expect(screen.queryByText(staleError.message)).toBeNull();
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -78,6 +79,11 @@ function useDialogHandlers({
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
if (isHandledApiError(err)) {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : translate("agents:failedToCreateAgent"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { applyProfileDuplicated } from "@/hooks/domains/settings/use-profile-dup
|
||||
import { errorMessage } from "@/components/settings/agent-profile-page-state";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
|
||||
import { getBackendReloadSnapshot } from "@/lib/platform/backend-reload-coordinator";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import type { Agent, AgentProfile } from "@/lib/types/http";
|
||||
|
||||
/**
|
||||
@@ -71,6 +73,7 @@ export function useProfileDuplicateAction({
|
||||
// it reports canLeave=false and we abort before posting.
|
||||
const saved = await saveAll();
|
||||
if (!saved.canLeave) {
|
||||
if (getBackendReloadSnapshot().reloadRequired) return;
|
||||
// Surface whichever contributor is blocking the save (MCP error or
|
||||
// an invalid profile draft); when the coordinator gives no reason
|
||||
// (another save in flight, or newer changes appeared during the
|
||||
@@ -106,6 +109,7 @@ export function useProfileDuplicateAction({
|
||||
router.push(`/settings/agents/${encodeURIComponent(agent.name)}/profiles/${created.id}`),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isHandledApiError(error)) return;
|
||||
toast({
|
||||
title: translate("agents:failedToDuplicateProfile"),
|
||||
description: errorMessage(error),
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
toAgentProfileOption,
|
||||
type AgentProfileOption,
|
||||
} from "@/lib/state/slices/settings/types";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { ApiError, isHandledApiError } from "@/lib/api/client";
|
||||
import type { Agent, AgentProfile, PermissionSetting } from "@/lib/types/http";
|
||||
|
||||
export type SaveStatus = "idle" | "loading" | "success" | "error";
|
||||
@@ -164,11 +164,13 @@ export function useProfileSave({
|
||||
);
|
||||
}
|
||||
setSaveStatus("error");
|
||||
toast({
|
||||
title: translate("agents:failedToSaveProfile"),
|
||||
description: errorMessage(error),
|
||||
variant: "error",
|
||||
});
|
||||
if (!isHandledApiError(error)) {
|
||||
toast({
|
||||
title: translate("agents:failedToSaveProfile"),
|
||||
description: errorMessage(error),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -222,7 +224,7 @@ export function useProfileDelete(
|
||||
automations: result.automations,
|
||||
utilityAgents: result.utilityAgents,
|
||||
});
|
||||
} else {
|
||||
} else if (!result.handled) {
|
||||
toast({
|
||||
title: translate("agents:failedToDeleteProfile"),
|
||||
description: result.message,
|
||||
@@ -244,7 +246,7 @@ export function useProfileDelete(
|
||||
automations: result.automations,
|
||||
utilityAgents: result.utilityAgents,
|
||||
});
|
||||
} else if (result.status === "error") {
|
||||
} else if (result.status === "error" && !result.handled) {
|
||||
toast({
|
||||
title: translate("agents:failedToDeleteProfile"),
|
||||
description: result.message,
|
||||
|
||||
@@ -63,6 +63,7 @@ import { useAgentProfileSettings } from "@/app/settings/agents/[agentId]/profile
|
||||
import { agentProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { DynamicAgentProfileEditor } from "@/components/settings/dynamic-agent-profile-editor";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
|
||||
type ProfileEditorProps = {
|
||||
agent: Agent;
|
||||
@@ -472,13 +473,14 @@ function ProfileEditor({
|
||||
passthroughConfig={passthroughConfig}
|
||||
secrets={secrets}
|
||||
initialMcpConfig={initialMcpConfig}
|
||||
onToastError={(error) =>
|
||||
onToastError={(error) => {
|
||||
if (isHandledApiError(error)) return;
|
||||
toast({
|
||||
title: t("agents:failedToSaveMcpConfig"),
|
||||
description: errorMessage(error),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
onModelConfigResolutionPendingChange={setModelConfigResolutionPending}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
|
||||
import { enqueueAgentInstall } from "./agent-install-catalog";
|
||||
|
||||
const { installAgent } = vi.hoisted(() => ({
|
||||
installAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
installAgent,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function handledStaleError() {
|
||||
const error = new ApiError("interim settings interlock required", 403, {
|
||||
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
|
||||
});
|
||||
error.handled = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
describe("enqueueAgentInstall", () => {
|
||||
it("does not create a local failed job for a handled stale-page error", async () => {
|
||||
installAgent.mockRejectedValueOnce(handledStaleError());
|
||||
const upsertInstallJob = vi.fn();
|
||||
|
||||
await enqueueAgentInstall("claude-acp", upsertInstallJob);
|
||||
|
||||
expect(upsertInstallJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"
|
||||
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
||||
import { installAgent, listAgentDiscovery, listAvailableAgents, listInstallJobs } from "@/lib/api";
|
||||
import type { InstallJob } from "@/lib/api";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
|
||||
|
||||
@@ -129,6 +130,27 @@ function ToolInstallCard({
|
||||
);
|
||||
}
|
||||
|
||||
export async function enqueueAgentInstall(
|
||||
name: string,
|
||||
upsertInstallJob: (job: InstallJob) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const job = await installAgent(name);
|
||||
// The WS event will normally arrive first, but seed the store in case the
|
||||
// WS round-trip is slower than the HTTP response.
|
||||
upsertInstallJob(job);
|
||||
} catch (err) {
|
||||
if (isHandledApiError(err)) return;
|
||||
upsertInstallJob({
|
||||
job_id: `local-error-${name}`,
|
||||
agent_name: name,
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
started_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install state is held in the store (driven by WS events
|
||||
* agent.install.{started,output,finished}). This hook:
|
||||
@@ -173,22 +195,7 @@ function useInstallAgent(onSuccess: () => Promise<void>) {
|
||||
}, [Object.values(installJobs).filter((j) => j.status === "succeeded").length]);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (name: string) => {
|
||||
try {
|
||||
const job = await installAgent(name);
|
||||
// The WS event will normally arrive first, but seed the store in case
|
||||
// the WS round-trip is slower than the HTTP response.
|
||||
upsertInstallJob(job);
|
||||
} catch (err) {
|
||||
upsertInstallJob({
|
||||
job_id: `local-error-${name}`,
|
||||
agent_name: name,
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
started_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
(name: string) => enqueueAgentInstall(name, upsertInstallJob),
|
||||
[upsertInstallJob],
|
||||
);
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ type ProfileRowDeleteConfirmationProps = {
|
||||
onConfirm: () => void | Promise<void>;
|
||||
placement: "inline" | "popover";
|
||||
};
|
||||
type ProfileRowDeleteConfirmationBaseProps = Omit<ProfileRowDeleteConfirmationProps, "placement">;
|
||||
|
||||
function ProfileRowDeleteConfirmation({
|
||||
open,
|
||||
@@ -204,6 +205,81 @@ function ProfileRowDeleteConfirmation({
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileRowCardProps = {
|
||||
profile: AgentProfile;
|
||||
href: string;
|
||||
confirmOpen: boolean;
|
||||
isFinePointer: boolean;
|
||||
isFullDesktop: boolean;
|
||||
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
|
||||
onDuplicate: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
confirmationProps: ProfileRowDeleteConfirmationBaseProps;
|
||||
};
|
||||
|
||||
function ProfileRowCard({
|
||||
profile,
|
||||
href,
|
||||
confirmOpen,
|
||||
isFinePointer,
|
||||
isFullDesktop,
|
||||
deleteAnchorRef,
|
||||
onDuplicate,
|
||||
onConfirmDelete,
|
||||
confirmationProps,
|
||||
}: ProfileRowCardProps) {
|
||||
return (
|
||||
<Card
|
||||
// Same surface treatment as the workspace section tiles.
|
||||
className="relative gap-0 border-border/70 bg-background/50 py-1.5 transition-colors hover:border-foreground/30 hover:bg-muted/50"
|
||||
data-testid="agent-profile-row"
|
||||
>
|
||||
{/* Whole-card link as an overlay — the action buttons sit above it (z-10). */}
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={profile.name}
|
||||
className="absolute inset-0"
|
||||
data-testid="agent-profile-row-link"
|
||||
/>
|
||||
<CardContent className="flex items-center justify-between gap-2 px-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<RecordDot />
|
||||
<span className="truncate text-sm font-medium">{profile.name}</span>
|
||||
{profile.enabled === false && <DisabledBadge />}
|
||||
</div>
|
||||
{(profile.model || profile.mode) && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 pl-3.5">
|
||||
{profile.model && <Badge variant="outline">{profile.model}</Badge>}
|
||||
{profile.mode && <Badge variant="secondary">{profile.mode}</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
||||
{!(confirmOpen && !isFinePointer) &&
|
||||
(isFullDesktop ? (
|
||||
<ProfileRowInlineActions
|
||||
profile={profile}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
/>
|
||||
) : (
|
||||
<ProfileRowActions
|
||||
profile={profile}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
onDuplicate={onDuplicate}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProfileRowDeleteConfirmation {...confirmationProps} placement="inline" />
|
||||
</CardContent>
|
||||
<ProfileRowDeleteConfirmation {...confirmationProps} placement="popover" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** One saved profile as a fully clickable row — shared by the Agents index and the agent page. */
|
||||
export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentProfile }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -249,6 +325,10 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
|
||||
router.push(href);
|
||||
return;
|
||||
}
|
||||
if (result.handled) {
|
||||
closeDeleteConfirmation();
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: t("agents:cannotDeleteAgentProfile"),
|
||||
description: result.message,
|
||||
@@ -265,53 +345,16 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
|
||||
onConfirm: () => void handleDelete(),
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
// Same surface treatment as the workspace section tiles.
|
||||
className="relative gap-0 border-border/70 bg-background/50 py-1.5 transition-colors hover:border-foreground/30 hover:bg-muted/50"
|
||||
data-testid="agent-profile-row"
|
||||
>
|
||||
{/* Whole-card link as an overlay — the action buttons sit above it (z-10). */}
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={profile.name}
|
||||
className="absolute inset-0"
|
||||
data-testid="agent-profile-row-link"
|
||||
/>
|
||||
<CardContent className="flex items-center justify-between gap-2 px-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<RecordDot />
|
||||
<span className="truncate text-sm font-medium">{profile.name}</span>
|
||||
{profile.enabled === false && <DisabledBadge />}
|
||||
</div>
|
||||
{(profile.model || profile.mode) && (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 pl-3.5">
|
||||
{profile.model && <Badge variant="outline">{profile.model}</Badge>}
|
||||
{profile.mode && <Badge variant="secondary">{profile.mode}</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
||||
{!(confirmOpen && !isFinePointer) &&
|
||||
(isFullDesktop ? (
|
||||
<ProfileRowInlineActions
|
||||
profile={profile}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
onDuplicate={() => void handleDuplicate(agent, profile)}
|
||||
onConfirmDelete={() => setConfirmOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<ProfileRowActions
|
||||
profile={profile}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
onDuplicate={() => void handleDuplicate(agent, profile)}
|
||||
onConfirmDelete={() => setConfirmOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ProfileRowDeleteConfirmation {...confirmationProps} placement="inline" />
|
||||
</CardContent>
|
||||
<ProfileRowDeleteConfirmation {...confirmationProps} placement="popover" />
|
||||
</Card>
|
||||
<ProfileRowCard
|
||||
profile={profile}
|
||||
href={href}
|
||||
confirmOpen={confirmOpen}
|
||||
isFinePointer={isFinePointer}
|
||||
isFullDesktop={isFullDesktop}
|
||||
deleteAnchorRef={deleteAnchorRef}
|
||||
onDuplicate={() => void handleDuplicate(agent, profile)}
|
||||
onConfirmDelete={() => setConfirmOpen(true)}
|
||||
confirmationProps={confirmationProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { MCPStrategySelect, useMCPStrategies } from "@/components/settings/mcp-strategy-select";
|
||||
import { updateCustomTUIAgentMCPStrategy } from "@/lib/api/domains/settings-api";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import type { Agent } from "@/lib/types/http-agents";
|
||||
|
||||
type CustomTUIMcpCardProps = {
|
||||
@@ -54,6 +55,7 @@ export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) {
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isHandledApiError(error)) return;
|
||||
toast({
|
||||
title: t("agents:failedToSaveAgent"),
|
||||
description: error instanceof Error ? error.message : t("agents:requestFailed"),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useToast } from "@/components/toast-provider";
|
||||
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
|
||||
import { isDynamicErrorPolicyValid } from "@/components/settings/dynamic-agent-policy-editor";
|
||||
import { updateAgentProfileAction } from "@/app/actions/agents";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import { useFeature } from "@/hooks/domains/features/use-feature";
|
||||
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
import type { Agent, AgentProfile } from "@/lib/types/http";
|
||||
@@ -204,6 +205,7 @@ export function useDynamicAgentProfileEditorState({
|
||||
);
|
||||
toast({ title: t("agents:dynamicProfileSaved") });
|
||||
} catch (error) {
|
||||
if (isHandledApiError(error)) return;
|
||||
toast({
|
||||
title: t("agents:failedToSaveProfile"),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
|
||||
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
|
||||
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -121,6 +122,35 @@ describe("useAgentUpdateDialogState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAgentUpdateDialogState handled failures", () => {
|
||||
it("closes without exposing a handled stale-page approval error", async () => {
|
||||
const staleError = new ApiError("interim settings interlock required", 403, {
|
||||
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
|
||||
});
|
||||
staleError.handled = true;
|
||||
const onUpdate = vi.fn().mockRejectedValue(staleError);
|
||||
const { result } = renderHook(() =>
|
||||
useAgentUpdateDialogState({
|
||||
agentName: AGENT_NAME,
|
||||
onPreview: vi.fn().mockResolvedValue(FIRST_PREVIEW),
|
||||
onUpdate,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.handleOpenChange(true));
|
||||
await waitFor(() => expect(result.current.preview).toEqual(FIRST_PREVIEW));
|
||||
await act(async () => {
|
||||
await result.current.approve();
|
||||
});
|
||||
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.approveError).toBeNull();
|
||||
expect(result.current.activeJob).toBeUndefined();
|
||||
expect(result.current.starting).toBe(false);
|
||||
expect(onUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAgentUpdateDialogState target selection", () => {
|
||||
it("keeps the current preview while a selected target is loading", async () => {
|
||||
const selectedTargetPreview = deferred<AgentUpdatePreview>();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
@@ -57,6 +58,20 @@ function resetDialogState({
|
||||
setActiveJobID(null);
|
||||
}
|
||||
|
||||
function handleApprovalError(
|
||||
error: unknown,
|
||||
requestID: number,
|
||||
previewRequestID: { current: number },
|
||||
setApproveError: (value: string | null) => void,
|
||||
onHandledError: () => void,
|
||||
) {
|
||||
if (isHandledApiError(error)) {
|
||||
onHandledError();
|
||||
return;
|
||||
}
|
||||
if (requestID === previewRequestID.current) setApproveError(errorMessage(error));
|
||||
}
|
||||
|
||||
async function approveRuntimeUpdate({
|
||||
requestID,
|
||||
previewRequestID,
|
||||
@@ -68,6 +83,7 @@ async function approveRuntimeUpdate({
|
||||
setStarting,
|
||||
setApproveError,
|
||||
setActiveJobID,
|
||||
onHandledError,
|
||||
}: {
|
||||
requestID: number;
|
||||
previewRequestID: { current: number };
|
||||
@@ -79,6 +95,7 @@ async function approveRuntimeUpdate({
|
||||
setStarting: (value: boolean) => void;
|
||||
setApproveError: (value: string | null) => void;
|
||||
setActiveJobID: (value: string | null) => void;
|
||||
onHandledError: () => void;
|
||||
}) {
|
||||
const targetVersion = selectedUseDefault
|
||||
? preview?.default_version || preview?.target_version || ""
|
||||
@@ -92,12 +109,17 @@ async function approveRuntimeUpdate({
|
||||
: await onUpdate(agentName, targetVersion);
|
||||
if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id);
|
||||
} catch (error) {
|
||||
if (requestID === previewRequestID.current) setApproveError(errorMessage(error));
|
||||
handleApprovalError(error, requestID, previewRequestID, setApproveError, onHandledError);
|
||||
} finally {
|
||||
if (requestID === previewRequestID.current) setStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeHandledDialog(setOpen: (value: boolean) => void, reset: () => void) {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}
|
||||
|
||||
function handleDialogOpenChange(
|
||||
nextOpen: boolean,
|
||||
setOpen: (value: boolean) => void,
|
||||
@@ -196,6 +218,8 @@ export function useAgentUpdateDialogState({
|
||||
setSelectedUseDefault,
|
||||
);
|
||||
|
||||
const closeOnHandledError = useCallback(() => closeHandledDialog(setOpen, reset), [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void loadPreview();
|
||||
}, [loadPreview, open]);
|
||||
@@ -215,8 +239,9 @@ export function useAgentUpdateDialogState({
|
||||
setStarting,
|
||||
setApproveError,
|
||||
setActiveJobID,
|
||||
onHandledError: closeOnHandledError,
|
||||
}),
|
||||
[agentName, onUpdate, preview, selectedTarget, selectedUseDefault],
|
||||
[agentName, closeOnHandledError, onUpdate, preview, selectedTarget, selectedUseDefault],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
|
||||
test.describe("Backend restart page recovery", () => {
|
||||
test("shows one reload alert on the current non-settings route", async ({
|
||||
testPage,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await testPage.goto("/stats");
|
||||
await expect(testPage.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(testPage.getByTestId("backend-reload-required-alert")).toHaveCount(0);
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const systemInfoRecovered = waitForHttp(testPage, "GET", /^\/api\/v1\/system\/info$/, {
|
||||
predicate: (response) => response.ok(),
|
||||
});
|
||||
await backend.restart();
|
||||
await systemInfoRecovered;
|
||||
|
||||
const alert = testPage.getByTestId("backend-reload-required-alert");
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toContainText("Reload required");
|
||||
await expect(alert).toContainText("Kandev restarted");
|
||||
await expect(testPage.getByRole("button", { name: "Reload page" })).toHaveCount(1);
|
||||
await expect(testPage).toHaveURL(/\/stats$/);
|
||||
await prCapture.screenshot("backend-restart-page-recovery-desktop", {
|
||||
caption: "Persistent desktop backend restart recovery alert",
|
||||
});
|
||||
|
||||
await alert.getByRole("button", { name: "Reload page" }).click();
|
||||
await expect(testPage.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(testPage.getByTestId("backend-reload-required-alert")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(testPage).toHaveURL(/\/stats$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
|
||||
test.describe("Mobile backend restart page recovery", () => {
|
||||
test("keeps the recovery action reachable without horizontal overflow", async ({
|
||||
testPage,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await testPage.goto("/stats");
|
||||
await expect(testPage.getByTestId("app-shell")).toBeVisible();
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const systemInfoRecovered = waitForHttp(testPage, "GET", /^\/api\/v1\/system\/info$/, {
|
||||
predicate: (response) => response.ok(),
|
||||
});
|
||||
await backend.restart();
|
||||
await systemInfoRecovered;
|
||||
|
||||
const alert = testPage.getByTestId("backend-reload-required-alert");
|
||||
await expect(alert).toBeVisible();
|
||||
const action = alert.getByRole("button", { name: "Reload page" });
|
||||
const actionBox = await action.boundingBox();
|
||||
expect(actionBox, "backend restart recovery action has no rendered hitbox").not.toBeNull();
|
||||
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
const main = testPage.locator("main").first();
|
||||
const alertBox = await alert.boundingBox();
|
||||
const mainBox = await main.boundingBox();
|
||||
expect(alertBox).not.toBeNull();
|
||||
expect(mainBox).not.toBeNull();
|
||||
if (alertBox && mainBox) {
|
||||
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(mainBox.y + 1);
|
||||
}
|
||||
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile backend restart recovery");
|
||||
await prCapture.screenshot("mobile-backend-restart-page-recovery", {
|
||||
caption: "Persistent mobile backend restart recovery alert",
|
||||
});
|
||||
|
||||
await action.click();
|
||||
await expect(testPage.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(testPage.getByTestId("backend-reload-required-alert")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile backend restart recovery reload");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,30 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { create, type StoreApi } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import type { Agent, AgentProfile } from "@/lib/types/http";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
import { createSettingsSlice } from "@/lib/state/slices/settings/settings-slice";
|
||||
import type { SettingsSlice } from "@/lib/state/slices/settings/types";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import { registerAgentsHandlers } from "@/lib/ws/handlers/agents";
|
||||
import { applyProfileDuplicated } from "./use-profile-duplicate";
|
||||
import { applyProfileDuplicated, useProfileDuplicate } from "./use-profile-duplicate";
|
||||
|
||||
vi.mock("@/app/actions/agents", () => ({ duplicateAgentProfileAction: vi.fn() }));
|
||||
vi.mock("@/components/state-provider", () => ({ useAppStoreApi: vi.fn() }));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
duplicateAgentProfileAction: vi.fn(),
|
||||
setState: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/actions/agents", () => ({
|
||||
duplicateAgentProfileAction: (...args: unknown[]) => mocks.duplicateAgentProfileAction(...args),
|
||||
}));
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStoreApi: () => ({ setState: mocks.setState }),
|
||||
}));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/i18n", () => ({ t: (key: string) => key }));
|
||||
|
||||
const COPY_NAME = "Default Copy";
|
||||
const OLD_REVISION = "2026-08-11T21:00:00Z";
|
||||
@@ -214,6 +227,23 @@ describe("applyProfileDuplicated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useProfileDuplicate", () => {
|
||||
it("does not toast a stale interlock error already handled by the coordinator", async () => {
|
||||
const error = new ApiError("stale document", 403, {
|
||||
error_code: "interim_settings_interlock_required",
|
||||
});
|
||||
error.handled = true;
|
||||
mocks.duplicateAgentProfileAction.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useProfileDuplicate());
|
||||
await act(async () => {
|
||||
await result.current(agent("a1"), profile("p1", "a1"));
|
||||
});
|
||||
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileDuplicated capability_status sync", () => {
|
||||
it("picks up a fresh agent.available.updated capability instead of reverting to the stale settingsAgents value", () => {
|
||||
// Regression for the desync where agent.available.updated only refreshed
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { duplicateAgentProfileAction } from "@/app/actions/agents";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
@@ -110,6 +111,7 @@ export function useProfileDuplicate() {
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isHandledApiError(error)) return;
|
||||
toast({
|
||||
title: translate("agents:failedToDuplicateProfile"),
|
||||
description: error instanceof Error ? error.message : translate("agents:requestFailed"),
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import type { Agent, AgentProfile } from "@/lib/types/http";
|
||||
import { applyEnabledProfileUpdate } from "./use-profile-enabled-toggle";
|
||||
import { applyEnabledProfileUpdate, useProfileEnabledToggle } from "./use-profile-enabled-toggle";
|
||||
|
||||
vi.mock("@/app/actions/agents", () => ({ updateAgentProfileAction: vi.fn() }));
|
||||
vi.mock("@/components/state-provider", () => ({ useAppStoreApi: vi.fn() }));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
updateAgentProfileAction: vi.fn(),
|
||||
setState: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/actions/agents", () => ({
|
||||
updateAgentProfileAction: (...args: unknown[]) => mocks.updateAgentProfileAction(...args),
|
||||
}));
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStoreApi: () => ({ setState: mocks.setState }),
|
||||
}));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/i18n", () => ({ t: (key: string) => key }));
|
||||
|
||||
function profile(id: string, agentId: string, enabled: boolean): AgentProfile {
|
||||
return {
|
||||
@@ -67,3 +80,22 @@ describe("applyEnabledProfileUpdate", () => {
|
||||
).toEqual([false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useProfileEnabledToggle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("does not toast a stale interlock error already handled by the coordinator", async () => {
|
||||
const error = new ApiError("stale document", 403, {
|
||||
error_code: "interim_settings_interlock_required",
|
||||
});
|
||||
error.handled = true;
|
||||
mocks.updateAgentProfileAction.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useProfileEnabledToggle());
|
||||
await act(async () => {
|
||||
await result.current(profile("p1", "a1", true), false);
|
||||
});
|
||||
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { updateAgentProfileAction } from "@/app/actions/agents";
|
||||
import { isHandledApiError } from "@/lib/api/client";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
@@ -58,6 +59,7 @@ export function useProfileEnabledToggle() {
|
||||
const updated = await updateAgentProfileAction(profile.id, { enabled });
|
||||
storeApi.setState((state) => applyEnabledProfileUpdate(state, profile, updated));
|
||||
} catch (error) {
|
||||
if (isHandledApiError(error)) return;
|
||||
toast({
|
||||
title: translate("agents:failedToUpdateProfile"),
|
||||
description: error instanceof Error ? error.message : translate("agents:requestFailed"),
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { StrictMode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionStatus } from "@/lib/types/connection";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: { connection: { status: "connected" as ConnectionStatus } },
|
||||
fetchSystemInfo: vi.fn(),
|
||||
signalBackendReloadRequired: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
|
||||
}));
|
||||
vi.mock("@/lib/api/domains/system-api", () => ({
|
||||
fetchSystemInfo: (...args: unknown[]) => mocks.fetchSystemInfo(...args),
|
||||
}));
|
||||
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
|
||||
signalBackendReloadRequired: (...args: unknown[]) => mocks.signalBackendReloadRequired(...args),
|
||||
}));
|
||||
|
||||
import { useBackendGenerationGuard } from "./use-backend-generation-guard";
|
||||
|
||||
function setBootId(bootId: unknown): void {
|
||||
(window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__ = {
|
||||
runtime: { bootId },
|
||||
};
|
||||
}
|
||||
|
||||
function setConnectionStatus(status: ConnectionStatus): void {
|
||||
mocks.state.connection.status = status;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setBootId("boot-1");
|
||||
setConnectionStatus("connected");
|
||||
mocks.fetchSystemInfo.mockReset();
|
||||
mocks.signalBackendReloadRequired.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__;
|
||||
});
|
||||
|
||||
describe("useBackendGenerationGuard", () => {
|
||||
it("checks the live boot id after the initial connection", async () => {
|
||||
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-1" });
|
||||
|
||||
renderHook(() => useBackendGenerationGuard());
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledWith({ cache: "no-store" }));
|
||||
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks the live boot id once when StrictMode replays effects", async () => {
|
||||
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-2" });
|
||||
|
||||
renderHook(() => useBackendGenerationGuard(), { wrapper: StrictMode });
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.signalBackendReloadRequired).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not signal a deferred request after the final StrictMode unmount", async () => {
|
||||
const request = deferred<{ boot_id: string }>();
|
||||
mocks.fetchSystemInfo.mockReturnValue(request.promise);
|
||||
|
||||
const hook = renderHook(() => useBackendGenerationGuard(), { wrapper: StrictMode });
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
|
||||
hook.unmount();
|
||||
|
||||
await act(async () => {
|
||||
request.resolve({ boot_id: "boot-2" });
|
||||
await request.promise;
|
||||
});
|
||||
|
||||
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals a changed boot id after a reconnect", async () => {
|
||||
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-2" });
|
||||
const hook = renderHook(() => useBackendGenerationGuard());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.signalBackendReloadRequired).toHaveBeenCalledWith("boot_id_changed"),
|
||||
);
|
||||
expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1);
|
||||
|
||||
setConnectionStatus("reconnecting");
|
||||
hook.rerender();
|
||||
setConnectionStatus("connected");
|
||||
hook.rerender();
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.signalBackendReloadRequired).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores missing, equal, and failed identities", async () => {
|
||||
mocks.fetchSystemInfo
|
||||
.mockResolvedValueOnce({ boot_id: "boot-1" })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error("backend unavailable"));
|
||||
const hook = renderHook(() => useBackendGenerationGuard());
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
|
||||
for (const status of ["reconnecting", "connected", "reconnecting", "connected"] as const) {
|
||||
setConnectionStatus(status);
|
||||
hook.rerender();
|
||||
if (status === "connected") {
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalled());
|
||||
}
|
||||
}
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(3));
|
||||
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores an older identity response after a newer connection", async () => {
|
||||
const first = deferred<{ boot_id: string }>();
|
||||
const second = deferred<{ boot_id: string }>();
|
||||
mocks.fetchSystemInfo.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
const hook = renderHook(() => useBackendGenerationGuard());
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
|
||||
setConnectionStatus("reconnecting");
|
||||
hook.rerender();
|
||||
setConnectionStatus("connected");
|
||||
hook.rerender();
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(2));
|
||||
|
||||
await act(async () => {
|
||||
second.resolve({ boot_id: "boot-1" });
|
||||
await second.promise;
|
||||
first.resolve({ boot_id: "boot-2" });
|
||||
await first.promise;
|
||||
});
|
||||
|
||||
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not compare when the document has no boot id", async () => {
|
||||
setBootId(42);
|
||||
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-2" });
|
||||
|
||||
renderHook(() => useBackendGenerationGuard());
|
||||
|
||||
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { fetchSystemInfo } from "@/lib/api/domains/system-api";
|
||||
import { signalBackendReloadRequired } from "@/lib/platform/backend-reload-coordinator";
|
||||
import { readBootPayload } from "@/src/boot-payload";
|
||||
|
||||
/**
|
||||
* Confirms that a recovered WebSocket belongs to the backend generation that
|
||||
* rendered this document. A failed or incomplete check is deliberately
|
||||
* inconclusive, so it cannot interrupt a usable page without proof.
|
||||
*/
|
||||
export function useBackendGenerationGuard(): void {
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
const pageBootIdRef = useRef(readBootPayload().runtime?.bootId);
|
||||
const previousStatusRef = useRef<typeof connectionStatus | null>(null);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const cleanup = () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
|
||||
if (connectionStatus !== "connected") {
|
||||
previousStatusRef.current = connectionStatus;
|
||||
requestSequenceRef.current += 1;
|
||||
return cleanup;
|
||||
}
|
||||
if (previousStatusRef.current === "connected") return cleanup;
|
||||
previousStatusRef.current = "connected";
|
||||
const requestSequence = ++requestSequenceRef.current;
|
||||
|
||||
void fetchSystemInfo({ cache: "no-store" })
|
||||
.then((info) => {
|
||||
if (!mountedRef.current || requestSequence !== requestSequenceRef.current) return;
|
||||
const pageBootId = pageBootIdRef.current?.trim();
|
||||
const liveBootId = info.boot_id?.trim();
|
||||
if (!pageBootId || !liveBootId || pageBootId === liveBootId) return;
|
||||
signalBackendReloadRequired("boot_id_changed");
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed request does not prove that the backend generation changed.
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [connectionStatus]);
|
||||
}
|
||||
@@ -4,18 +4,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchSystemInfo: vi.fn(),
|
||||
requestRestart: vi.fn(),
|
||||
registerBackendReloadOwner: vi.fn(),
|
||||
releaseBackendReloadOwner: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/system-api", () => ({
|
||||
fetchSystemInfo: mocks.fetchSystemInfo,
|
||||
requestRestart: mocks.requestRestart,
|
||||
}));
|
||||
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
|
||||
registerBackendReloadOwner: () => {
|
||||
mocks.registerBackendReloadOwner();
|
||||
return mocks.releaseBackendReloadOwner;
|
||||
},
|
||||
}));
|
||||
|
||||
import { useKandevRestart } from "./use-kandev-restart";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fetchSystemInfo.mockReset();
|
||||
mocks.requestRestart.mockReset();
|
||||
mocks.registerBackendReloadOwner.mockReset();
|
||||
mocks.releaseBackendReloadOwner.mockReset();
|
||||
});
|
||||
|
||||
describe("useKandevRestart", () => {
|
||||
@@ -33,6 +43,7 @@ describe("useKandevRestart", () => {
|
||||
});
|
||||
|
||||
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.registerBackendReloadOwner).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(result.current.phase).toBe("done"));
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -69,6 +80,7 @@ describe("useKandevRestart", () => {
|
||||
);
|
||||
expect(result.current.phase).toBe("error");
|
||||
expect(result.current.errorMessage).toBe("unsupported launch mode");
|
||||
expect(mocks.releaseBackendReloadOwner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores duplicate starts while a restart is already active", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchSystemInfo, requestRestart } from "@/lib/api/domains/system-api";
|
||||
import { registerBackendReloadOwner } from "@/lib/platform/backend-reload-coordinator";
|
||||
// The module-level `t` (not the hook) so these resolve when the callback fires
|
||||
// and stay out of the callbacks' dependency arrays. This file has no JSX, so
|
||||
// `mode: "jsx-only"` can never see the strings below.
|
||||
@@ -32,15 +33,30 @@ export function useKandevRestart({
|
||||
const [previousBootID, setPreviousBootID] = useState<string | null>(null);
|
||||
const activeRef = useRef(false);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const ownerReleaseRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fail = useCallback((message: string) => {
|
||||
activeRef.current = false;
|
||||
setErrorMessage(message);
|
||||
setPhase("error");
|
||||
const claimReloadOwnership = useCallback(() => {
|
||||
if (!ownerReleaseRef.current) ownerReleaseRef.current = registerBackendReloadOwner();
|
||||
}, []);
|
||||
|
||||
const releaseReloadOwnership = useCallback(() => {
|
||||
ownerReleaseRef.current?.();
|
||||
ownerReleaseRef.current = null;
|
||||
}, []);
|
||||
|
||||
const fail = useCallback(
|
||||
(message: string) => {
|
||||
activeRef.current = false;
|
||||
releaseReloadOwnership();
|
||||
setErrorMessage(message);
|
||||
setPhase("error");
|
||||
},
|
||||
[releaseReloadOwnership],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (activeRef.current) return;
|
||||
claimReloadOwnership();
|
||||
activeRef.current = true;
|
||||
setErrorMessage(null);
|
||||
setPhase("starting");
|
||||
@@ -53,15 +69,18 @@ export function useKandevRestart({
|
||||
} catch (e) {
|
||||
fail(e instanceof Error ? e.message : t("system:restartStartFailed"));
|
||||
}
|
||||
}, [fail]);
|
||||
}, [claimReloadOwnership, fail]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
activeRef.current = false;
|
||||
releaseReloadOwnership();
|
||||
setPhase("idle");
|
||||
setErrorMessage(null);
|
||||
setPreviousBootID(null);
|
||||
startedAtRef.current = null;
|
||||
}, []);
|
||||
}, [releaseReloadOwnership]);
|
||||
|
||||
useEffect(() => releaseReloadOwnership, [releaseReloadOwnership]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "restarting") return;
|
||||
|
||||
@@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({
|
||||
applyUpdate: vi.fn(),
|
||||
fetchSystemInfo: vi.fn(),
|
||||
fetchSystemJob: vi.fn(),
|
||||
registerBackendReloadOwner: vi.fn(),
|
||||
releaseBackendReloadOwner: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/system-api", () => ({
|
||||
@@ -12,6 +14,12 @@ vi.mock("@/lib/api/domains/system-api", () => ({
|
||||
fetchSystemInfo: mocks.fetchSystemInfo,
|
||||
fetchSystemJob: mocks.fetchSystemJob,
|
||||
}));
|
||||
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
|
||||
registerBackendReloadOwner: () => {
|
||||
mocks.registerBackendReloadOwner();
|
||||
return mocks.releaseBackendReloadOwner;
|
||||
},
|
||||
}));
|
||||
|
||||
import { useSelfUpdate } from "./use-self-update";
|
||||
|
||||
@@ -29,6 +37,8 @@ beforeEach(() => {
|
||||
mocks.applyUpdate.mockReset();
|
||||
mocks.fetchSystemInfo.mockReset();
|
||||
mocks.fetchSystemJob.mockReset();
|
||||
mocks.registerBackendReloadOwner.mockReset();
|
||||
mocks.releaseBackendReloadOwner.mockReset();
|
||||
mocks.fetchSystemJob.mockResolvedValue({ state: "succeeded" });
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -51,6 +61,7 @@ function registerUpdateLifecycleTests() {
|
||||
expect(result.current.phase).toBe("installing");
|
||||
expect(result.current.isUpdating).toBe(true);
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) as string).target).toBe("v1.0.1");
|
||||
expect(mocks.registerBackendReloadOwner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("finishes when the version flips to the target", async () => {
|
||||
@@ -99,6 +110,7 @@ function registerUpdateLifecycleTests() {
|
||||
expect(result.current.phase).toBe("error");
|
||||
expect(result.current.errorMessage).toBe("rate limited");
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(mocks.releaseBackendReloadOwner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("errors when the launch job reports failure", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { applyUpdate, fetchSystemInfo, fetchSystemJob } from "@/lib/api/domains/system-api";
|
||||
import { registerBackendReloadOwner } from "@/lib/platform/backend-reload-coordinator";
|
||||
// Module-level `t`: resolved at call time, no JSX in this file for lint to see.
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
@@ -79,6 +80,39 @@ export type SelfUpdateController = {
|
||||
dismiss: () => void;
|
||||
};
|
||||
|
||||
function useSelfUpdateReloadOwnership(
|
||||
phase: SelfUpdatePhase,
|
||||
claimReloadOwnership: () => void,
|
||||
releaseReloadOwnership: () => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (phase === "idle" || phase === "error") releaseReloadOwnership();
|
||||
else claimReloadOwnership();
|
||||
}, [claimReloadOwnership, phase, releaseReloadOwnership]);
|
||||
|
||||
useEffect(() => releaseReloadOwnership, [releaseReloadOwnership]);
|
||||
}
|
||||
|
||||
function useResumeSelfUpdate(
|
||||
hydrated: { current: boolean },
|
||||
setPhase: (phase: SelfUpdatePhase) => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (hydrated.current) return;
|
||||
hydrated.current = true;
|
||||
const persisted = readPersisted();
|
||||
if (!persisted) return;
|
||||
if (Date.now() - persisted.startedAt > MAX_DURATION_MS) {
|
||||
clearPersisted();
|
||||
return;
|
||||
}
|
||||
// Resume an in-progress update after a page reload. Promoting out of "idle"
|
||||
// here keeps the first client render matching the server's.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPhase("installing");
|
||||
}, [hydrated, setPhase]);
|
||||
}
|
||||
|
||||
export function useSelfUpdate({
|
||||
latestVersion,
|
||||
onComplete,
|
||||
@@ -94,15 +128,30 @@ export function useSelfUpdate({
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const hydrated = useRef(false);
|
||||
const completionFired = useRef(false);
|
||||
const ownerReleaseRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fail = useCallback((message: string) => {
|
||||
setErrorMessage(message);
|
||||
setPhase("error");
|
||||
clearPersisted();
|
||||
const claimReloadOwnership = useCallback(() => {
|
||||
if (!ownerReleaseRef.current) ownerReleaseRef.current = registerBackendReloadOwner();
|
||||
}, []);
|
||||
|
||||
const releaseReloadOwnership = useCallback(() => {
|
||||
ownerReleaseRef.current?.();
|
||||
ownerReleaseRef.current = null;
|
||||
}, []);
|
||||
|
||||
const fail = useCallback(
|
||||
(message: string) => {
|
||||
releaseReloadOwnership();
|
||||
setErrorMessage(message);
|
||||
setPhase("error");
|
||||
clearPersisted();
|
||||
},
|
||||
[releaseReloadOwnership],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!latestVersion) return;
|
||||
claimReloadOwnership();
|
||||
completionFired.current = false;
|
||||
setErrorMessage(null);
|
||||
setTargetVersion(latestVersion);
|
||||
@@ -115,33 +164,22 @@ export function useSelfUpdate({
|
||||
} catch (e) {
|
||||
fail(e instanceof Error ? e.message : t("system:selfUpdateStartFailed"));
|
||||
}
|
||||
}, [latestVersion, fail]);
|
||||
}, [claimReloadOwnership, latestVersion, fail]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
releaseReloadOwnership();
|
||||
completionFired.current = false;
|
||||
setPhase("idle");
|
||||
setErrorMessage(null);
|
||||
setJobId(null);
|
||||
clearPersisted();
|
||||
}, []);
|
||||
}, [releaseReloadOwnership]);
|
||||
|
||||
useSelfUpdateReloadOwnership(phase, claimReloadOwnership, releaseReloadOwnership);
|
||||
|
||||
// Resume an in-progress update after a page reload (the restart window can
|
||||
// outlive a manual refresh). Runs once; the version poll below confirms done.
|
||||
useEffect(() => {
|
||||
if (hydrated.current) return;
|
||||
hydrated.current = true;
|
||||
const persisted = readPersisted();
|
||||
if (!persisted) return;
|
||||
if (Date.now() - persisted.startedAt > MAX_DURATION_MS) {
|
||||
clearPersisted();
|
||||
return;
|
||||
}
|
||||
// Resume an in-progress update after a page reload. Promoting out of "idle"
|
||||
// here (rather than in the initial state) keeps the first client render
|
||||
// matching the server's; the version poll below then confirms completion.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPhase("installing");
|
||||
}, []);
|
||||
useResumeSelfUpdate(hydrated, setPhase);
|
||||
|
||||
// Drive the flow off the live version. While installing/restarting, poll
|
||||
// /system/info: success at the target version → done; an unreachable backend
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, fetchBlob, fetchJson, setOnUnauthorized } from "./client";
|
||||
|
||||
const reloadMocks = vi.hoisted(() => ({
|
||||
signalBackendReloadRequired: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
|
||||
signalBackendReloadRequired: (...args: unknown[]) =>
|
||||
reloadMocks.signalBackendReloadRequired(...args),
|
||||
}));
|
||||
|
||||
const interlockToken = "replayable-per-boot-value";
|
||||
const BACKEND_URL = "http://backend.test";
|
||||
|
||||
@@ -113,6 +122,7 @@ describe("fetchJson", () => {
|
||||
describe("ApiError response classification", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
reloadMocks.signalBackendReloadRequired.mockReset();
|
||||
});
|
||||
|
||||
it("preserves machine-readable error codes from the backend", async () => {
|
||||
@@ -133,6 +143,54 @@ describe("ApiError response classification", () => {
|
||||
errorCode: "branch_policy_stale",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles only the stale settings interlock response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "interim settings interlock required",
|
||||
error_code: "interim_settings_interlock_required",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchJson("/api/v1/agent-profiles/p1", { baseUrl: BACKEND_URL }),
|
||||
).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 403,
|
||||
errorCode: "interim_settings_interlock_required",
|
||||
handled: true,
|
||||
});
|
||||
expect(reloadMocks.signalBackendReloadRequired).toHaveBeenCalledWith(
|
||||
"settings_interlock_rejected",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves other forbidden responses unhandled", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "forbidden" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchJson("/api/v1/agent-profiles/p1", { baseUrl: BACKEND_URL }),
|
||||
).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 403,
|
||||
handled: false,
|
||||
});
|
||||
expect(reloadMocks.signalBackendReloadRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBlob", () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { signalBackendReloadRequired } from "@/lib/platform/backend-reload-coordinator";
|
||||
import { readInterimSettingsInterlockToken } from "@/src/boot-payload";
|
||||
|
||||
const interimSettingsInterlockHeader = "X-Kandev-Interim-Settings-Interlock";
|
||||
export const INTERIM_SETTINGS_INTERLOCK_ERROR_CODE = "interim_settings_interlock_required";
|
||||
|
||||
export type ApiRequestOptions = {
|
||||
baseUrl?: string;
|
||||
@@ -19,6 +21,7 @@ export class ApiError extends Error {
|
||||
readonly body: unknown;
|
||||
readonly errorCode?: string;
|
||||
readonly retryAfterSeconds?: number;
|
||||
handled = false;
|
||||
constructor(message: string, status: number, body: unknown, retryAfterSeconds?: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
@@ -70,12 +73,21 @@ async function throwFromResponse(response: Response): Promise<never> {
|
||||
if (typeof errVal === "string") message = errVal;
|
||||
}
|
||||
const retryAfter = Number(response.headers.get("Retry-After"));
|
||||
throw new ApiError(
|
||||
const error = new ApiError(
|
||||
message,
|
||||
response.status,
|
||||
body,
|
||||
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
|
||||
);
|
||||
if (error.errorCode === INTERIM_SETTINGS_INTERLOCK_ERROR_CODE) {
|
||||
signalBackendReloadRequired("settings_interlock_rejected");
|
||||
error.handled = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function isHandledApiError(error: unknown): error is ApiError & { handled: true } {
|
||||
return error instanceof ApiError && error.handled;
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(pathOrUrl: string, options?: ApiRequestOptions): Promise<T> {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createBackendReloadCoordinator } from "./backend-reload-coordinator";
|
||||
|
||||
describe("backend reload coordinator", () => {
|
||||
it("latches the first signal and reports it once", () => {
|
||||
const report = vi.fn();
|
||||
const coordinator = createBackendReloadCoordinator({ reporter: report });
|
||||
const listener = vi.fn();
|
||||
coordinator.subscribe(listener);
|
||||
|
||||
expect(coordinator.getSnapshot()).toEqual({
|
||||
reloadRequired: false,
|
||||
source: null,
|
||||
ownerCount: 0,
|
||||
});
|
||||
expect(coordinator.signal("boot_id_changed")).toBe(true);
|
||||
expect(coordinator.signal("settings_interlock_rejected")).toBe(false);
|
||||
|
||||
expect(coordinator.getSnapshot()).toEqual({
|
||||
reloadRequired: true,
|
||||
source: "boot_id_changed",
|
||||
ownerCount: 0,
|
||||
});
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
expect(report).toHaveBeenCalledWith("boot_id_changed");
|
||||
});
|
||||
|
||||
it("reports a latched signal when the reporter is installed later", () => {
|
||||
const coordinator = createBackendReloadCoordinator();
|
||||
const report = vi.fn();
|
||||
|
||||
coordinator.signal("settings_interlock_rejected");
|
||||
coordinator.setDiagnosticReporter(report);
|
||||
coordinator.setDiagnosticReporter(report);
|
||||
|
||||
expect(report).toHaveBeenCalledOnce();
|
||||
expect(report).toHaveBeenCalledWith("settings_interlock_rejected");
|
||||
});
|
||||
|
||||
it("tracks idempotent intentional restart owners", () => {
|
||||
const coordinator = createBackendReloadCoordinator();
|
||||
const firstRelease = coordinator.registerOwner();
|
||||
const secondRelease = coordinator.registerOwner();
|
||||
|
||||
expect(coordinator.getSnapshot().ownerCount).toBe(2);
|
||||
firstRelease();
|
||||
firstRelease();
|
||||
expect(coordinator.getSnapshot().ownerCount).toBe(1);
|
||||
secondRelease();
|
||||
expect(coordinator.getSnapshot().ownerCount).toBe(0);
|
||||
secondRelease();
|
||||
expect(coordinator.getSnapshot().ownerCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
export type BackendReloadSignal = "boot_id_changed" | "settings_interlock_rejected";
|
||||
|
||||
export type BackendReloadSnapshot = {
|
||||
reloadRequired: boolean;
|
||||
source: BackendReloadSignal | null;
|
||||
ownerCount: number;
|
||||
};
|
||||
|
||||
export type BackendReloadDiagnosticReporter = (source: BackendReloadSignal) => void;
|
||||
|
||||
export type BackendReloadCoordinator = {
|
||||
getSnapshot: () => BackendReloadSnapshot;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
signal: (source: BackendReloadSignal) => boolean;
|
||||
registerOwner: () => () => void;
|
||||
setDiagnosticReporter: (reporter: BackendReloadDiagnosticReporter | null) => void;
|
||||
};
|
||||
|
||||
const INITIAL_SNAPSHOT: BackendReloadSnapshot = {
|
||||
reloadRequired: false,
|
||||
source: null,
|
||||
ownerCount: 0,
|
||||
};
|
||||
|
||||
export function createBackendReloadCoordinator(
|
||||
options: {
|
||||
reporter?: BackendReloadDiagnosticReporter;
|
||||
} = {},
|
||||
): BackendReloadCoordinator {
|
||||
let snapshot = INITIAL_SNAPSHOT;
|
||||
let reporter = options.reporter ?? null;
|
||||
let reportSent = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const report = (source: BackendReloadSignal) => {
|
||||
if (reportSent || !reporter) return;
|
||||
reportSent = true;
|
||||
try {
|
||||
reporter(source);
|
||||
} catch {
|
||||
// Diagnostics must never change the recovery state.
|
||||
}
|
||||
};
|
||||
|
||||
const signal = (source: BackendReloadSignal): boolean => {
|
||||
if (snapshot.reloadRequired) return false;
|
||||
snapshot = { reloadRequired: true, source, ownerCount: snapshot.ownerCount };
|
||||
notify();
|
||||
report(source);
|
||||
return true;
|
||||
};
|
||||
|
||||
const registerOwner = (): (() => void) => {
|
||||
let released = false;
|
||||
snapshot = { ...snapshot, ownerCount: snapshot.ownerCount + 1 };
|
||||
notify();
|
||||
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
snapshot = { ...snapshot, ownerCount: Math.max(0, snapshot.ownerCount - 1) };
|
||||
notify();
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
signal,
|
||||
registerOwner,
|
||||
setDiagnosticReporter: (nextReporter) => {
|
||||
reporter = nextReporter;
|
||||
if (snapshot.reloadRequired && snapshot.source) report(snapshot.source);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const backendReloadCoordinator = createBackendReloadCoordinator();
|
||||
|
||||
export const getBackendReloadSnapshot = () => backendReloadCoordinator.getSnapshot();
|
||||
|
||||
export const subscribeToBackendReload = (listener: () => void) =>
|
||||
backendReloadCoordinator.subscribe(listener);
|
||||
|
||||
export const signalBackendReloadRequired = (source: BackendReloadSignal) =>
|
||||
backendReloadCoordinator.signal(source);
|
||||
|
||||
export const registerBackendReloadOwner = () => backendReloadCoordinator.registerOwner();
|
||||
|
||||
export const setBackendReloadDiagnosticReporter = (
|
||||
reporter: BackendReloadDiagnosticReporter | null,
|
||||
) => backendReloadCoordinator.setDiagnosticReporter(reporter);
|
||||
@@ -85,6 +85,26 @@ describe("readBootPayload", () => {
|
||||
expect(readBootPayload(win).runtime?.titlePrefix).toBe("TEST");
|
||||
});
|
||||
|
||||
it("keeps the backend boot identity from the runtime block", () => {
|
||||
const win = {
|
||||
__KANDEV_BOOT_PAYLOAD__: { runtime: { bootId: "boot-123" } },
|
||||
} as unknown as Window;
|
||||
|
||||
expect(readBootPayload(win)).toMatchObject({ runtime: { bootId: "boot-123" } });
|
||||
});
|
||||
|
||||
it("ignores missing or malformed backend boot identities", () => {
|
||||
const missing = {
|
||||
__KANDEV_BOOT_PAYLOAD__: { runtime: {} },
|
||||
} as unknown as Window;
|
||||
const malformed = {
|
||||
__KANDEV_BOOT_PAYLOAD__: { runtime: { bootId: 123 } },
|
||||
} as unknown as Window;
|
||||
|
||||
expect(readBootPayload(missing).runtime?.bootId).toBeUndefined();
|
||||
expect(readBootPayload(malformed).runtime?.bootId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the title prefix undefined when the runtime block omits it", () => {
|
||||
const win = {
|
||||
__KANDEV_BOOT_PAYLOAD__: { runtime: { apiPrefix: "/api/v1" } },
|
||||
|
||||
@@ -23,6 +23,7 @@ export type BootRoute = {
|
||||
export type BootRuntime = {
|
||||
apiPrefix?: string;
|
||||
webSocketPath?: string;
|
||||
bootId?: string;
|
||||
lspAutoInstallPreferenceLanguages?: string[];
|
||||
debug?: boolean;
|
||||
/**
|
||||
@@ -169,6 +170,7 @@ function readRuntime(value: Record<string, unknown>): BootRuntime {
|
||||
return {
|
||||
apiPrefix: readString(value.apiPrefix),
|
||||
webSocketPath: readString(value.webSocketPath),
|
||||
bootId: readNonEmptyString(value.bootId),
|
||||
lspAutoInstallPreferenceLanguages: readStringArray(value.lspAutoInstallPreferenceLanguages),
|
||||
debug: value.debug === true ? true : undefined,
|
||||
nonProduction: value.nonProduction === true ? true : undefined,
|
||||
|
||||
@@ -209,6 +209,9 @@
|
||||
"navLogs": "Logs",
|
||||
"navUpdates": "Updates",
|
||||
"navUsers": "Users",
|
||||
"backendReloadRequiredTitle": "Reload required",
|
||||
"backendReloadRequiredBody": "Kandev restarted. Reload this page to continue. Reloading discards unsaved changes.",
|
||||
"backendReloadRequiredAction": "Reload page",
|
||||
"reloadPage": "Reload page",
|
||||
"restartAction": "Restart",
|
||||
"agentRuntimeUnavailableTitle": "Local agent runtime stopped",
|
||||
|
||||
@@ -209,6 +209,9 @@
|
||||
"navLogs": "Ĺōĝś",
|
||||
"navUpdates": "Ũƥďàţēś",
|
||||
"navUsers": "Ũśēŕś",
|
||||
"backendReloadRequiredTitle": "Ŕēĺōàď ŕēqũĩŕēď",
|
||||
"backendReloadRequiredBody": "Ķàńďēv ŕēśţàŕţēď. Ŕēĺōàď ţĥĩś ƥàĝē ţō ćōńţĩńũē. Ŕēĺōàďĩńĝ ďĩśćàŕďś ũńśàvēď ćĥàńĝēś.",
|
||||
"backendReloadRequiredAction": "Ŕēĺōàď ƥàĝē",
|
||||
"reloadPage": "Ŕēĺōàď ƥàĝē",
|
||||
"restartAction": "Ŕēśţàŕţ",
|
||||
"agentRuntimeUnavailableTitle": "Ĺōćàĺ àĝēńţ ŕũńţĩḿē śţōƥƥēď",
|
||||
|
||||
@@ -204,6 +204,9 @@
|
||||
"navLogs": "Registos",
|
||||
"navUpdates": "Atualizações",
|
||||
"navUsers": "Utilizadores",
|
||||
"backendReloadRequiredTitle": "É necessário recarregar",
|
||||
"backendReloadRequiredBody": "O Kandev foi reiniciado. Recarregue esta página para continuar. O recarregamento elimina as alterações não guardadas.",
|
||||
"backendReloadRequiredAction": "Recarregar página",
|
||||
"reloadPage": "Recarregar a página",
|
||||
"restartAction": "Reiniciar",
|
||||
"agentRuntimeUnavailableTitle": "O runtime local do agente parou",
|
||||
|
||||
@@ -206,6 +206,9 @@
|
||||
"navLogs": "日志",
|
||||
"navUpdates": "更新",
|
||||
"navUsers": "用户",
|
||||
"backendReloadRequiredTitle": "需要重新加载",
|
||||
"backendReloadRequiredBody": "Kandev 已重启。请重新加载此页面以继续。重新加载会丢弃未保存的更改。",
|
||||
"backendReloadRequiredAction": "重新加载页面",
|
||||
"reloadPage": "重新加载页面",
|
||||
"restartAction": "重启",
|
||||
"agentRuntimeUnavailableTitle": "本地智能体运行时已停止",
|
||||
|
||||
@@ -206,6 +206,9 @@
|
||||
"navLogs": "日誌",
|
||||
"navUpdates": "更新",
|
||||
"navUsers": "用戶",
|
||||
"backendReloadRequiredTitle": "需要重新載入",
|
||||
"backendReloadRequiredBody": "Kandev 已重啓。請重新載入此頁面以繼續。重新載入會丟棄未儲存的更改。",
|
||||
"backendReloadRequiredAction": "重新載入頁面",
|
||||
"reloadPage": "重新載入頁面",
|
||||
"restartAction": "重啓",
|
||||
"agentRuntimeUnavailableTitle": "本地代理程式執行時已停止",
|
||||
|
||||
@@ -206,6 +206,9 @@
|
||||
"navLogs": "日誌",
|
||||
"navUpdates": "更新",
|
||||
"navUsers": "使用者",
|
||||
"backendReloadRequiredTitle": "需要重新載入",
|
||||
"backendReloadRequiredBody": "Kandev 已重啟。請重新載入此頁面以繼續。重新載入會丟棄未儲存的更改。",
|
||||
"backendReloadRequiredAction": "重新載入頁面",
|
||||
"reloadPage": "重新載入頁面",
|
||||
"restartAction": "重啟",
|
||||
"agentRuntimeUnavailableTitle": "本地代理程式執行時已停止",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { StrictMode, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@/app/globals.css";
|
||||
import { setOnUnauthorized } from "@/lib/api/client";
|
||||
import { scheduleFrontendErrorReport } from "@/lib/api/domains/frontend-error-log-api";
|
||||
import { setBackendReloadDiagnosticReporter } from "@/lib/platform/backend-reload-coordinator";
|
||||
import { useAppStoreApi, StateProvider } from "@/components/state-provider";
|
||||
import { PluginBootBridge } from "@/lib/plugins/plugin-boot-bridge";
|
||||
import { preloadLocale } from "@/lib/i18n";
|
||||
@@ -20,6 +22,9 @@ import { applyTitlePrefix } from "@/lib/browser/document-title";
|
||||
installVitePreloadRecovery();
|
||||
installBfcacheRestoreReload();
|
||||
markRenderingEngine(document.documentElement);
|
||||
setBackendReloadDiagnosticReporter((source) => {
|
||||
scheduleFrontendErrorReport({ source: "toast-provider", title: source });
|
||||
});
|
||||
|
||||
const AUTH_ROUTE_PATHS = new Set(["/login", "/setup", "/invite"]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPA Failure Containment and Deployment Recovery
|
||||
|
||||
**Status:** accepted
|
||||
**Status:** accepted (amended 2026-09-03)
|
||||
**Date:** 2026-07-27
|
||||
**Area:** frontend
|
||||
|
||||
@@ -16,6 +16,10 @@ boundary. Settings and Office are lazy routes rendered with a null Suspense
|
||||
fallback. An uncaught render or lazy-import error can empty the React root, and
|
||||
a pending import can look like the same blank failure.
|
||||
|
||||
An application document can also remain open across a backend restart. The
|
||||
document then has old boot data and an old settings interlock token. Waiting for
|
||||
the next protected mutation exposes an internal recovery condition too late.
|
||||
|
||||
Two render-determinism defects compound this behavior:
|
||||
|
||||
- the Settings SPA adapter creates new thenables during render and passes them
|
||||
@@ -76,6 +80,29 @@ insufficient because it leaves the old frontend module graph alive.
|
||||
|
||||
Desktop-native update behavior is unchanged.
|
||||
|
||||
### Backend-generation recovery
|
||||
|
||||
Each application document receives the backend `boot_id` in its boot payload.
|
||||
After every successful WebSocket connection, the application shell requests
|
||||
`/system/info` without cache and compares the live ID with the document ID.
|
||||
|
||||
- A different ID proves that the backend process changed. The current document
|
||||
enters a one-way reload-required state.
|
||||
- A disconnect, failed request, missing ID, or equal ID does not prove a
|
||||
restart. It does not enter reload-required state.
|
||||
- Every authenticated application route presents the same persistent in-flow
|
||||
alert. The alert is not a toast and does not expire or allow dismissal.
|
||||
- The user must select **Reload page**. Kandev does not reload automatically
|
||||
because the current route can contain unsaved work.
|
||||
- A typed stale-settings-interlock response enters the same state when a
|
||||
protected mutation races with proactive detection.
|
||||
- Intentional restart and self-update flows coordinate with this state. One
|
||||
flow owns the reload action at a time, so the UI does not show duplicate
|
||||
reload-required surfaces.
|
||||
|
||||
The reload keeps the current browser location. The new document receives the
|
||||
new process identity and leaves reload-required state.
|
||||
|
||||
### Render determinism
|
||||
|
||||
- SPA route adapters pass synchronous parsed identifiers to client components.
|
||||
@@ -96,6 +123,10 @@ Desktop-native update behavior is unchanged.
|
||||
stops reloading and exposes an actionable fallback.
|
||||
- Successful service updates replace the old HTML and JavaScript graph as soon
|
||||
as the new backend is authoritative.
|
||||
- An open application document identifies a changed backend process after its
|
||||
WebSocket reconnects. It gives the user a clear recovery action before the
|
||||
next mutation.
|
||||
- Temporary network failures do not claim that Kandev restarted.
|
||||
- Settings route adapters and Zustand selectors have explicit identity
|
||||
contracts that are enforceable with focused tests.
|
||||
- Error boundaries do not catch event-handler or arbitrary asynchronous
|
||||
@@ -121,6 +152,27 @@ retry is not reliable.
|
||||
Rejected. A missing or corrupt asset could create an infinite cross-document
|
||||
reload loop.
|
||||
|
||||
### Reload automatically after every backend restart
|
||||
|
||||
Rejected. The open route can contain unsaved work. A persistent warning lets
|
||||
the user copy that work before a full reload.
|
||||
|
||||
### Treat every WebSocket reconnect as a backend restart
|
||||
|
||||
Rejected. Temporary network failures also reconnect the WebSocket. A changed
|
||||
`boot_id` is the required proof.
|
||||
|
||||
### Poll process identity on a fixed timer
|
||||
|
||||
Rejected. A backend restart already breaks and reconnects the shared WebSocket.
|
||||
Checking identity after connection recovery gives prompt detection without
|
||||
continuous background requests.
|
||||
|
||||
### Handle only the settings interlock rejection
|
||||
|
||||
Rejected. That response appears only after a protected settings mutation. It
|
||||
does not protect other routes from continued use with old boot and route data.
|
||||
|
||||
### Retain multiple generations of embedded assets
|
||||
|
||||
Deferred. It increases packaging and asset-lifecycle complexity and does not
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
created: 2026-09-03
|
||||
status: completed
|
||||
requirements:
|
||||
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/backend-restart-page-recovery.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Backend Restart Page Recovery
|
||||
|
||||
## Overview
|
||||
|
||||
Publish the backend process identity in each application boot payload. Detect a
|
||||
changed process after WebSocket connection recovery. Then show one shared reload
|
||||
alert across every authenticated application route.
|
||||
|
||||
Keep the typed agent settings interlock response as a race fallback. Coordinate
|
||||
the new global state with existing restart and self-update flows.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add the current `boot_id` to HTML and `/api/v1/app-state` boot payloads.
|
||||
- Check system identity after each successful WebSocket connection.
|
||||
- Latch one document-local reload-required state after a proven change.
|
||||
- Show one persistent in-flow alert on all authenticated application routes.
|
||||
- Add a stable stale-interlock error code and route it to the same state.
|
||||
- Coordinate intentional restart and self-update reload ownership.
|
||||
- Add localized desktop, tablet, and phone behavior.
|
||||
- Add unit, integration, and real-restart E2E coverage.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Automatic reload or unsaved-draft persistence.
|
||||
- Timer-based identity polling.
|
||||
- Changes to WebSocket retry policy or the public health endpoint.
|
||||
- Changes to the interim settings interlock threat model.
|
||||
- Alerts on login or setup screens outside the application shell.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Boot identity
|
||||
|
||||
- Add optional `bootId` to the backend and frontend boot runtime shapes.
|
||||
- Source it from the existing process-scoped `info.Service`.
|
||||
- Include it in both server-rendered HTML and `/api/v1/app-state` responses.
|
||||
- Extend payload and parser tests before implementation.
|
||||
|
||||
### Detection and fallback
|
||||
|
||||
- Add a document-local reload coordinator with a one-way state transition.
|
||||
- Observe shared WebSocket connection transitions in the application shell.
|
||||
- On each `connected` transition, fetch system information with `no-store`.
|
||||
- Compare the response with the original boot ID. Ignore failures, missing IDs,
|
||||
equal IDs, and responses from older connection checks.
|
||||
- Add `interim_settings_interlock_required` to interlock rejections.
|
||||
- Make the shared API client signal the coordinator for that exact code.
|
||||
- Suppress only the local error that the coordinator handled.
|
||||
|
||||
### Global recovery UI
|
||||
|
||||
- Render the alert in `AppStatusSurfaceProvider` above route content.
|
||||
- Use a persistent `role="alert"` surface with one explicit reload action.
|
||||
- Keep the current route usable so the user can copy unsaved work.
|
||||
- Register intentional restart ownership in `useKandevRestart` and
|
||||
`useSelfUpdate`. Preserve their current progress and error surfaces.
|
||||
- Add all copy to the six locale catalogs. Generate the Traditional Chinese
|
||||
catalogs with `pnpm run i18n:zh-hant`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **AC 001.1:** Backend payload tests and frontend parser tests cover valid,
|
||||
missing, and malformed boot IDs.
|
||||
- **AC 001.2 through 001.4:** Guard tests cover initial connection, reconnect,
|
||||
failed requests, same IDs, changed IDs, and stale responses.
|
||||
- **AC 001.5 through 001.8:** Coordinator and alert tests cover the one-way
|
||||
latch, client-side navigation, deduplication, explicit reload, and no
|
||||
automatic reload.
|
||||
- **AC 001.9 and 001.11:** Middleware and API client tests cover the stable code,
|
||||
unrelated HTTP 403 responses, and local-error suppression.
|
||||
- **AC 001.10:** Restart and self-update hook tests cover reload ownership and
|
||||
the single-surface rule.
|
||||
- **AC 001.12:** Component and browser tests cover keyboard use, phone touch
|
||||
size, overflow, and navigation clearance.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- `apps/web/e2e/tests/layout/backend-restart-page-recovery.spec.ts` opens a
|
||||
non-settings route in the `chromium` project. It restarts the real backend,
|
||||
waits for WebSocket recovery, and checks the alert before any user action.
|
||||
It selects **Reload page** and verifies the fresh document.
|
||||
- `apps/web/e2e/tests/layout/mobile-backend-restart-page-recovery.spec.ts`
|
||||
repeats the flow in the `mobile-chrome` project. It also checks the action
|
||||
height, the single scroll owner, horizontal containment, and navigation
|
||||
clearance.
|
||||
|
||||
The E2E tests seed through API fixtures. They do not simulate a process change
|
||||
with request interception.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [completed] [Task 01: Publish page boot identity](task-01-publish-page-boot-identity.md)
|
||||
- [completed] [Task 02: Detect changed backend generation](task-02-detect-backend-generation.md)
|
||||
- [completed] [Task 03: Show the global reload alert](task-03-show-global-reload-alert.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Implementation and verification passed:
|
||||
|
||||
- `make -C apps/backend test`: all backend packages passed.
|
||||
- Focused backend interlock tests: 154 tests passed.
|
||||
- Focused frontend recovery, agent-settings, and lifecycle suites: 81 tests
|
||||
passed across 12 files.
|
||||
- `pnpm run lint`, `pnpm run typecheck`, and `pnpm run i18n:check`: passed.
|
||||
- `python3 scripts/lint-spec-files.py --all` and `git diff --check`: passed.
|
||||
- `make build-backend build-web`: passed.
|
||||
- Managed desktop and mobile real-backend restart E2E tests: one test passed
|
||||
in each project, including explicit reload and mobile layout checks.
|
||||
|
||||
## Risks
|
||||
|
||||
- A network reconnect can look like a restart. The guard must require a changed
|
||||
boot ID.
|
||||
- An older identity response can arrive after a newer connection. The guard
|
||||
must reject stale response sequences.
|
||||
- Intentional restart dialogs can duplicate the global alert. Reload ownership
|
||||
must have one active surface and a global fallback.
|
||||
- A broad HTTP 403 match can hide authorization errors. The API client must
|
||||
require the stable interlock code.
|
||||
- A reload discards draft data. The product must warn the user and wait for an
|
||||
explicit action.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: "01-publish-page-boot-identity"
|
||||
title: "Publish page boot identity"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.1
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/backend-restart-page-recovery.md
|
||||
---
|
||||
|
||||
# Task 01: Publish Page Boot Identity
|
||||
|
||||
## Summary
|
||||
|
||||
Add the existing backend process identity to the application boot payload. Parse
|
||||
it as the original document identity in the frontend.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add `bootId` to backend and frontend runtime payload shapes.
|
||||
- Source the value from the existing `info.Service` instance.
|
||||
- Cover HTML boot and `/api/v1/app-state` boot paths.
|
||||
- Accept a missing field for compatibility with partial fixtures.
|
||||
- Add tests with a TDD red phase.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Identity comparison and WebSocket observation.
|
||||
- The visible reload alert.
|
||||
- Changes to `GET /health` or boot ID generation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Each boot path returns the same process ID as `/api/v1/system/info`.
|
||||
- The frontend parser keeps a valid non-empty ID.
|
||||
- Missing or malformed values do not break application boot.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make -C apps/backend test
|
||||
cd apps && pnpm --filter @kandev/web test -- --run src/boot-payload.test.ts
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/webapp/payload.go`
|
||||
- `apps/backend/internal/backendapp/helpers.go`
|
||||
- `apps/backend/internal/backendapp/helpers_test.go`
|
||||
- `apps/web/src/boot-payload.ts`
|
||||
- `apps/web/src/boot-payload.test.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- HTML boot and API fallback payloads must use the same service instance.
|
||||
- A required frontend field breaks older fixtures and partial boot tests.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Acceptance criterion 001.1.
|
||||
- System-design section `Identity contract`.
|
||||
- Existing `info.Service.Info().BootID` process identity.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the process-scoped boot ID in the backend runtime payload and
|
||||
frontend parser. Both the HTML and `/api/v1/app-state` paths use the shared
|
||||
payload builder, while missing or malformed IDs remain optional.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- `make -C apps/backend test`: all backend packages passed.
|
||||
- `pnpm --filter @kandev/web test -- --run src/boot-payload.test.ts`: passed.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
id: "02-detect-backend-generation"
|
||||
title: "Detect changed backend generation"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-publish-page-boot-identity"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.2
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.3
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.4
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.9
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.11
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/backend-restart-page-recovery.md
|
||||
---
|
||||
|
||||
# Task 02: Detect Changed Backend Generation
|
||||
|
||||
## Summary
|
||||
|
||||
Create the one-way reload coordinator. Signal it after a changed boot ID or an
|
||||
exact stale-interlock response.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add the document-local coordinator and subscription contract.
|
||||
- Check `/api/v1/system/info` after each successful WebSocket connection.
|
||||
- Ignore failed, missing, equal, and out-of-order identity results.
|
||||
- Add the stable stale-interlock response code.
|
||||
- Signal the coordinator from the shared API client for that exact code.
|
||||
- Keep profile-delete HTTP 409 behavior through the shared response parser.
|
||||
- Add backend and frontend tests with a TDD red phase.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The visible reload alert and locale copy.
|
||||
- Timer-based polling or WebSocket retry changes.
|
||||
- Changes to interlock token validation or route scope.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A successful connection check latches reload-required only for a changed ID.
|
||||
- Repeated or late signals cannot clear the state or create duplicate reports.
|
||||
- The stale-interlock code enters the same state and marks that error handled.
|
||||
- Other HTTP 403 and connection failures keep their current behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
go test ./apps/backend/internal/common/httpmw ./apps/backend/internal/agent/settings/handlers
|
||||
cd apps && pnpm --filter @kandev/web test -- --run lib/platform/backend-reload-coordinator.test.ts hooks/domains/system/use-backend-generation-guard.test.ts lib/api/client.test.ts app/actions/agents.test.ts
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/common/httpmw/interim_settings_interlock.go`
|
||||
- `apps/backend/internal/common/httpmw/interim_settings_interlock_test.go`
|
||||
- `apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go`
|
||||
- `apps/web/lib/platform/backend-reload-coordinator.ts`
|
||||
- `apps/web/lib/platform/backend-reload-coordinator.test.ts`
|
||||
- `apps/web/hooks/domains/system/use-backend-generation-guard.ts`
|
||||
- `apps/web/hooks/domains/system/use-backend-generation-guard.test.ts`
|
||||
- `apps/web/lib/api/client.ts`
|
||||
- `apps/web/lib/api/client.test.ts`
|
||||
- `apps/web/app/actions/agents.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 supplies the document boot ID.
|
||||
|
||||
## Risks
|
||||
|
||||
- The connection state can change while an identity request is pending.
|
||||
- The API client must not classify all HTTP 403 responses as stale documents.
|
||||
- The profile delete action has a typed conflict result that must not change.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Acceptance criteria 001.2, 001.3, 001.4, 001.9, and 001.11.
|
||||
- System-design sections `Detection flow`, `Stale-interlock fallback`, and
|
||||
`Recovery coordinator`.
|
||||
- Existing shared connection state and `fetchSystemInfo` API.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the document-local coordinator, connection recovery guard, typed
|
||||
interlock code, shared API classification, and profile-delete response
|
||||
handling. Protected agent-settings presenters suppress only errors marked as
|
||||
handled by the coordinator.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- `go test ./internal/common/httpmw ./internal/agent/settings/handlers`: 154
|
||||
tests passed.
|
||||
- Focused coordinator, generation-guard, API-client, action, and agent-settings
|
||||
suites: passed.
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
id: "03-show-global-reload-alert"
|
||||
title: "Show the global reload alert"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "02-detect-backend-generation"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
|
||||
acceptance_criteria:
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.5
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.6
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.7
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.8
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.9
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.10
|
||||
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.12
|
||||
system_design:
|
||||
- ../../specs/platform/system-design/backend-restart-page-recovery.md
|
||||
---
|
||||
|
||||
# Task 03: Show the Global Reload Alert
|
||||
|
||||
## Summary
|
||||
|
||||
Render one persistent reload alert above all application route content. Connect
|
||||
intentional restart owners, suppress duplicate settings errors, and prove the
|
||||
flow on desktop and phone.
|
||||
|
||||
## In scope
|
||||
|
||||
- Mount the generation guard and alert in the shared status surface provider.
|
||||
- Add the explicit current-document reload action.
|
||||
- Preserve route use until the user chooses to reload.
|
||||
- Register reload ownership in generic restart and self-update hooks.
|
||||
- Suppress local agent settings errors for a handled stale-interlock response.
|
||||
- Add all six locale values.
|
||||
- Add component, hook, and real-restart E2E tests with TDD.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Automatic reload and draft persistence.
|
||||
- Alerts outside the authenticated application shell.
|
||||
- A new toast, modal, route, drawer, or mobile-only workflow.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every authenticated route shows the same non-dismissible in-flow alert after
|
||||
a proven restart.
|
||||
- Client-side navigation and repeated signals keep one alert.
|
||||
- Intentional restart flows expose one reload-required surface.
|
||||
- The action reloads only after user activation and keeps the current location.
|
||||
- Phone layout provides a 44px action without overflow or covered navigation.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run components/app-status-bar/backend-reload-required-alert.test.tsx hooks/domains/system/use-kandev-restart.test.ts hooks/domains/system/use-self-update.test.ts
|
||||
cd apps/web && pnpm run i18n:check && pnpm run typecheck
|
||||
make build-backend build-web
|
||||
cd apps/web && pnpm e2e:run --project=chromium tests/layout/backend-restart-page-recovery.spec.ts
|
||||
cd apps/web && pnpm e2e:run --project=mobile-chrome tests/layout/mobile-backend-restart-page-recovery.spec.ts
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/app-status-bar/backend-reload-required-alert.tsx`
|
||||
- `apps/web/components/app-status-bar/backend-reload-required-alert.test.tsx`
|
||||
- `apps/web/components/app-status-bar/app-status-surface-provider.tsx`
|
||||
- `apps/web/hooks/domains/system/use-kandev-restart.ts`
|
||||
- `apps/web/hooks/domains/system/use-kandev-restart.test.ts`
|
||||
- `apps/web/hooks/domains/system/use-self-update.ts`
|
||||
- `apps/web/hooks/domains/system/use-self-update.test.ts`
|
||||
- current agent settings error presenters
|
||||
- `apps/web/src/locales/*/system.json`
|
||||
- `apps/web/e2e/tests/layout/backend-restart-page-recovery.spec.ts`
|
||||
- `apps/web/e2e/tests/layout/mobile-backend-restart-page-recovery.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 02 supplies the coordinator and restart signals.
|
||||
|
||||
## Risks
|
||||
|
||||
- A modal restart flow can hide a second in-flow action. Ownership must keep one
|
||||
reachable recovery surface.
|
||||
- Agent settings use several local error presenters. Each protected mutation
|
||||
needs the exact handled-error predicate.
|
||||
- The alert must stay above route data without creating another scroll owner.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Acceptance criteria 001.5 through 001.10 and 001.12.
|
||||
- System-design sections `Recovery coordinator`, `Alert surface`, and
|
||||
`Responsive and accessible behavior`.
|
||||
- Existing agent runtime alert and restart progress patterns.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the shared alert, explicit current-location reload action,
|
||||
intentional restart and self-update ownership, localized copy, and desktop and
|
||||
mobile real-restart coverage.
|
||||
|
||||
Verification passed:
|
||||
|
||||
- Alert and restart/self-update hook suites: 24 tests passed.
|
||||
- `pnpm run lint`, `pnpm run typecheck`, and `pnpm run i18n:check`: passed.
|
||||
- `make build-backend build-web`: passed.
|
||||
- Managed `chromium` and `mobile-chrome` restart E2E suites: one test passed in
|
||||
each project.
|
||||
@@ -38,6 +38,7 @@ localization, feature toggles, health, and shared session recovery services.
|
||||
- [Agentctl instance stop idempotency](requirements/agentctl-instance-stop.md)
|
||||
- [Agent Runtime Availability](requirements/agent-runtime-availability.md)
|
||||
- [Background Work Liveness](requirements/background-work-liveness.md)
|
||||
- [Backend restart page recovery](requirements/backend-restart-page-recovery.md)
|
||||
- [Bounded Task Status Delivery](requirements/bounded-task-status-delivery.md)
|
||||
- [Viewport-bounded Session Delivery](requirements/viewport-bounded-session-delivery.md)
|
||||
- [Shared Cron Loop Safety When Office Is Disabled](requirements/cron-office-disabled-safety.md)
|
||||
@@ -76,6 +77,7 @@ localization, feature toggles, health, and shared session recovery services.
|
||||
- [Agent process exit and stderr drain](system-design/agent-process-exit-drain.md)
|
||||
- [Agentctl instance stop idempotency](system-design/agentctl-instance-stop.md)
|
||||
- [Background Work Liveness](system-design/background-work-liveness.md)
|
||||
- [Backend restart page recovery](system-design/backend-restart-page-recovery.md)
|
||||
- [Browser console retention](system-design/browser-console-retention.md)
|
||||
- [Bounded Task Status Delivery](system-design/bounded-task-status-delivery.md)
|
||||
- [Viewport-bounded Session Delivery](system-design/viewport-bounded-session-delivery.md)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
status: active
|
||||
system: platform
|
||||
created: 2026-09-03
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Backend Restart Page Recovery Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
An open Kandev document can outlive the backend process that created it. The
|
||||
document then contains old boot data, cached route data, and an old agent
|
||||
settings interlock token. A later action can fail with an internal error, or it
|
||||
can use data that no longer matches the running backend.
|
||||
|
||||
Kandev must detect a confirmed backend restart before the next user action. It
|
||||
must show one clear reload alert on every authenticated application route.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Backend generation:** One backend process lifetime, identified by its
|
||||
`boot_id`.
|
||||
- **Page generation:** The backend generation in the document boot payload.
|
||||
- **Reload-required state:** A document state that starts after proof that the
|
||||
backend generation changed. It ends only when the browser loads a new
|
||||
document.
|
||||
- **Application route:** An authenticated route inside the shared application
|
||||
shell. Login, setup, and other routes outside that shell are not application
|
||||
routes.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001: Detect and recover an old application document
|
||||
|
||||
**Intent:** Tell users to reload as soon as Kandev proves that an open document
|
||||
belongs to an earlier backend process.
|
||||
|
||||
**User story:** As a Kandev user, I want an immediate reload alert after the
|
||||
backend restarts. This alert stops me from using an old application document.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.1:** Each application
|
||||
document shall receive the current backend `boot_id` in its boot payload.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.2:** After each successful
|
||||
WebSocket connection, the document shall request current system information
|
||||
without using a cached response.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.3:** If the returned
|
||||
`boot_id` differs from the page generation, Kandev shall enter the
|
||||
reload-required state without another user action.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.4:** Kandev shall not enter
|
||||
the reload-required state because of a disconnect, a failed identity request,
|
||||
or a response with the same `boot_id`.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.5:** Every application route
|
||||
shall show one persistent, non-dismissible, in-flow reload alert while the
|
||||
document is in the reload-required state. Client-side navigation shall not
|
||||
hide or duplicate it.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.6:** The alert shall state
|
||||
that Kandev restarted and that the user must reload the page to continue. It
|
||||
shall warn that the reload discards unsaved changes.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.7:** The alert shall include
|
||||
a **Reload page** action. Kandev shall not reload automatically.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.8:** The reload action shall
|
||||
reload the current browser location. The new document shall use the current
|
||||
backend generation and clear the reload-required state.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.9:** A protected agent
|
||||
settings rejection with the stable stale-interlock code shall also enter the
|
||||
same reload-required state. This fallback shall suppress the internal error
|
||||
and duplicate operation errors.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.10:** An intentional restart
|
||||
or self-update flow shall use the same recovery state. Kandev shall show only
|
||||
one reload-required surface when that flow already owns the reload action.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.11:** Other authorization,
|
||||
connectivity, and settings errors shall keep their current behavior.
|
||||
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.12:** Desktop, tablet, and
|
||||
phone layouts shall provide the same recovery outcome. The action shall
|
||||
support keyboard use and a touch target of at least 44px on phone layouts.
|
||||
The alert shall not cause horizontal overflow or cover navigation.
|
||||
|
||||
## User-visible contract
|
||||
|
||||
The English alert uses this copy:
|
||||
|
||||
- Title: **Reload required**
|
||||
- Body: **Kandev restarted. Reload this page to continue. Reloading discards
|
||||
unsaved changes.**
|
||||
- Action: **Reload page**
|
||||
|
||||
All visible copy is localized. Diagnostic details and the internal interlock
|
||||
message do not appear in this alert.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** an application document from the current backend, **WHEN** its
|
||||
WebSocket reconnects to the same process, **THEN** no reload alert appears.
|
||||
- **GIVEN** an open application document, **WHEN** the backend restarts and the
|
||||
WebSocket reconnects, **THEN** the reload alert appears before another user
|
||||
action.
|
||||
- **GIVEN** a system information request fails, **WHEN** Kandev has no process
|
||||
identity proof, **THEN** only the existing connectivity behavior applies.
|
||||
- **GIVEN** the reload alert is visible, **WHEN** the user changes application
|
||||
routes, **THEN** the same alert remains visible.
|
||||
- **GIVEN** a profile save races with restart detection, **WHEN** the backend
|
||||
returns the stale-interlock code, **THEN** the same reload alert appears. The
|
||||
internal message stays hidden.
|
||||
- **GIVEN** the reload alert is visible, **WHEN** the user selects **Reload
|
||||
page**, **THEN** the browser reloads the current location. The new document
|
||||
does not contain the alert.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Automatic reload after a backend restart.
|
||||
- Persistence or restoration of unsaved form data.
|
||||
- A restart alert based only on a network failure or WebSocket disconnect.
|
||||
- Changes to the security claims, token transport, or route scope of the
|
||||
interim settings interlock.
|
||||
- Backend process history or restart metrics.
|
||||
|
||||
## Related requirements
|
||||
|
||||
- [Agent Runtime Availability](agent-runtime-availability.md)
|
||||
- [Session subscription recovery](session-subscription-recovery.md)
|
||||
- [Semantic Notifications](notifications.md)
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
status: current
|
||||
system: platform
|
||||
requirements:
|
||||
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
|
||||
created: 2026-09-03
|
||||
updated: 2026-09-03
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Backend Restart Page Recovery System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design detects when an authenticated application document belongs to an
|
||||
earlier backend process. It then presents one explicit reload path across all
|
||||
application routes.
|
||||
|
||||
The backend owns process identity. The shared application shell owns detection
|
||||
and recovery state. Agent settings keep a typed stale-interlock fallback for the
|
||||
race before proactive detection completes.
|
||||
|
||||
This design does not change WebSocket retry policy, the public health endpoint,
|
||||
or the interim settings interlock security model.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001` | [Identity contract](#identity-contract), [Detection flow](#detection-flow), [Recovery coordinator](#recovery-coordinator), [Alert surface](#alert-surface), [Testing strategy](#testing-strategy) |
|
||||
|
||||
## Identity contract
|
||||
|
||||
`info.Service` already creates one random `boot_id` for each backend process.
|
||||
It returns that value from `GET /api/v1/system/info`.
|
||||
|
||||
The backend adds the same value to `BootPayload.Runtime` as `bootId`. The HTML
|
||||
boot payload and the `/api/v1/app-state` fallback use the same source. The
|
||||
frontend parser accepts the field as optional for compatibility with an older
|
||||
payload or a partial test fixture.
|
||||
|
||||
The boot ID is process identity, not a credential. It contains no interlock
|
||||
token, session identifier, or user data. This change does not add fields to
|
||||
`GET /health`.
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- `webapp.RuntimeConfig` transports the document's initial `bootId`.
|
||||
- `fetchSystemInfo({ cache: "no-store" })` obtains the current backend identity.
|
||||
- `BackendGenerationGuard` observes successful WebSocket connections from the
|
||||
shared application state. It compares the live ID with the boot payload ID.
|
||||
- `backend-reload-coordinator` is a small external store. It latches the
|
||||
reload-required state, deduplicates signals, and coordinates intentional
|
||||
restart owners.
|
||||
- `BackendReloadRequiredAlert` renders the global in-flow recovery surface.
|
||||
- The shared API client recognizes the stable interlock error code and signals
|
||||
the same coordinator.
|
||||
- Agent settings presenters suppress a local error only when the coordinator
|
||||
handled that exact stale-interlock error.
|
||||
|
||||
The guard and alert mount in `AppStatusSurfaceProvider`, above route content.
|
||||
They remain independent of the optional application status bar. This location
|
||||
also keeps the alert visible when route data is empty or stale.
|
||||
|
||||
## Detection flow
|
||||
|
||||
1. The backend creates one `boot_id` and includes it in the document boot
|
||||
payload.
|
||||
2. The application shell starts its normal WebSocket connection.
|
||||
3. Each transition to `connected` starts one no-cache system information
|
||||
request. A sequence guard ignores an older response after a later connection.
|
||||
4. If the request fails or returns no ID, the guard records no restart. The
|
||||
existing connectivity UI remains responsible for network state.
|
||||
5. If the live ID equals the document boot ID, the guard records no restart.
|
||||
6. If the IDs differ, the coordinator permanently latches reload-required for
|
||||
the current document.
|
||||
7. The alert becomes visible without waiting for a mutation or route change.
|
||||
|
||||
The comparison always uses the original document boot ID. It does not replace
|
||||
the baseline after reconnect. This rule prevents a stale document from becoming
|
||||
current without a full reload.
|
||||
|
||||
If the boot payload has no valid ID, proactive comparison is unavailable. The
|
||||
typed stale-interlock response remains the recovery fallback for protected
|
||||
agent settings.
|
||||
|
||||
## Stale-interlock fallback
|
||||
|
||||
The middleware response keeps HTTP 403 and adds this stable field:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "interim settings interlock required",
|
||||
"error_code": "interim_settings_interlock_required"
|
||||
}
|
||||
```
|
||||
|
||||
The frontend matches only `error_code`. It does not match the English `error`
|
||||
value or all HTTP 403 responses.
|
||||
|
||||
The shared API client signals the coordinator after it parses the response and
|
||||
before it rejects the caller promise. It marks that `ApiError` as handled.
|
||||
Agent settings presenters use the marker to prevent an internal or duplicate
|
||||
error message. Agent profile deletion joins the shared response parser while it
|
||||
keeps its typed HTTP 409 conflict result.
|
||||
|
||||
## Recovery coordinator
|
||||
|
||||
The coordinator has document-local state with these values:
|
||||
|
||||
- `current`: no changed backend generation is proven.
|
||||
- `reload-required`: a changed generation or stale-interlock response is proven.
|
||||
|
||||
The transition to `reload-required` is one-way in the current document. Repeated
|
||||
identity mismatches and interlock errors do not create new alerts or reports.
|
||||
|
||||
An intentional restart flow can register itself as the active reload owner
|
||||
before it sends the restart request. The global alert stays hidden while that
|
||||
flow presents its own blocking progress or reload surface. When the owner
|
||||
unmounts without reloading, the global alert becomes the fallback. A self-update
|
||||
keeps ownership through its existing controlled document reload.
|
||||
|
||||
The generic `useKandevRestart` flow and the self-update flow use this ownership
|
||||
contract. They do not maintain a second backend-generation decision. Their
|
||||
existing progress, error, and completion behavior stays intact.
|
||||
|
||||
## Alert surface
|
||||
|
||||
When no intentional restart owner is active, `BackendReloadRequiredAlert`
|
||||
renders at the top of the route-content column. It is a persistent
|
||||
`role="alert"` region, not a toast or modal.
|
||||
|
||||
The alert is non-dismissible. It does not block the current route, so a user can
|
||||
copy unsaved work before reloading. Client-side route changes keep the same
|
||||
coordinator and alert instance.
|
||||
|
||||
The **Reload page** action calls `window.location.reload()`. This preserves the
|
||||
current path, query, and fragment. The next document receives the new boot ID
|
||||
and starts in `current` state.
|
||||
|
||||
The alert and the existing connectivity warning can appear together. They
|
||||
report different facts. Connectivity reports reachability. The reload alert
|
||||
reports a proven process-generation change.
|
||||
|
||||
## Persistence and failure handling
|
||||
|
||||
The coordinator does not use local storage, session storage, or backend
|
||||
persistence. The current document is the scope of the stale state.
|
||||
|
||||
A failed system information request cannot prove a restart. It does not show a
|
||||
reload alert. The next successful WebSocket connection starts another check.
|
||||
|
||||
If the coordinator is not installed during early application startup, the
|
||||
stale-interlock `ApiError` uses localized reload guidance at its caller. The raw
|
||||
backend error does not become user-visible.
|
||||
|
||||
## Security
|
||||
|
||||
The system information request uses the existing authenticated endpoint and
|
||||
credentials. The boot ID is safe browser-facing process metadata.
|
||||
|
||||
The interlock keeps its current constant-time comparison, bearer rejection,
|
||||
route scope, fail-closed behavior, and token transport. The new error code does
|
||||
not expose the token.
|
||||
|
||||
## Observability
|
||||
|
||||
The coordinator sends one frontend diagnostic report when a document first
|
||||
enters reload-required state. The report includes the signal source and no
|
||||
token value. Valid sources are `boot_id_changed` and
|
||||
`settings_interlock_rejected`.
|
||||
|
||||
Backend logs keep the current HTTP request and restart evidence. This design
|
||||
does not add a metric or durable event.
|
||||
|
||||
## Responsive and accessible behavior
|
||||
|
||||
Desktop and tablet show the alert in flow at the top of the route-content
|
||||
column. Phone uses the same alert. Its text and action stack vertically.
|
||||
|
||||
The phone action fills the available width and has a minimum height of 44px. At
|
||||
larger breakpoints, the action can use the compact button size. The alert stays
|
||||
inside the existing scroll layout and does not cover bottom navigation or safe
|
||||
areas.
|
||||
|
||||
The alert has a textual title, explanation, and button. It uses `role="alert"`
|
||||
and does not rely on color. The button supports normal keyboard focus and
|
||||
activation.
|
||||
|
||||
## Internationalization
|
||||
|
||||
The title, explanation, warning, and action exist in `en`, `pseudo`, `pt-pt`,
|
||||
`zh-cn`, `zh-hk`, and `zh-tw`. The copy uses no Unicode em dash.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Backend boot payload tests assert that HTML and `/api/v1/app-state` expose the
|
||||
same `bootId` as `/api/v1/system/info`. Existing system information tests keep
|
||||
the within-process stability guarantee.
|
||||
|
||||
Frontend boot parser tests cover valid, missing, and malformed boot IDs. Guard
|
||||
tests cover initial connection, reconnect, out-of-order responses, same-ID
|
||||
responses, changed IDs, failed requests, and unmount cleanup.
|
||||
|
||||
Coordinator and alert tests cover the one-way latch, deduplication, route
|
||||
survival, explicit reload, intentional restart ownership, localized copy, and
|
||||
the no-coordinator fallback.
|
||||
|
||||
Backend and API client tests cover the stable stale-interlock error code. They
|
||||
also cover unrelated HTTP 403 responses and profile-delete HTTP 409 conflicts.
|
||||
|
||||
Desktop Playwright coverage restarts a real test backend while a non-settings
|
||||
route is open. It checks that the alert appears after reconnect and before
|
||||
another user action. It then selects the reload action and checks that the new
|
||||
document has no alert.
|
||||
|
||||
Mobile Playwright coverage repeats the real restart flow. It also checks the
|
||||
44px action height, one scroll owner, horizontal containment, and navigation
|
||||
clearance.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [SPA failure containment and deployment recovery](../../../decisions/2026-07-27-spa-failure-containment-and-deployment-recovery.md)
|
||||
- [Settings route save coordinator](../../../decisions/0046-settings-route-save-coordinator.md)
|
||||
- [Go-served single-page application](../../../decisions/0021-go-served-single-page-application.md)
|
||||
Reference in New Issue
Block a user