diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 6d0432f0b8..e48c4a1ae7 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -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 diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 9bd77f86c1..9e5bb94b76 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -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() diff --git a/apps/backend/internal/common/httpmw/interim_settings_interlock.go b/apps/backend/internal/common/httpmw/interim_settings_interlock.go index dd147a8a00..f9ccc2cf06 100644 --- a/apps/backend/internal/common/httpmw/interim_settings_interlock.go +++ b/apps/backend/internal/common/httpmw/interim_settings_interlock.go @@ -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() diff --git a/apps/backend/internal/common/httpmw/interim_settings_interlock_test.go b/apps/backend/internal/common/httpmw/interim_settings_interlock_test.go index 705f67727d..236282d88f 100644 --- a/apps/backend/internal/common/httpmw/interim_settings_interlock_test.go +++ b/apps/backend/internal/common/httpmw/interim_settings_interlock_test.go @@ -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") } diff --git a/apps/backend/internal/webapp/payload.go b/apps/backend/internal/webapp/payload.go index 369e4f03a5..f85eabd0fc 100644 --- a/apps/backend/internal/webapp/payload.go +++ b/apps/backend/internal/webapp/payload.go @@ -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 diff --git a/apps/web/app/actions/agents.test.ts b/apps/web/app/actions/agents.test.ts new file mode 100644 index 0000000000..2e929d1735 --- /dev/null +++ b/apps/web/app/actions/agents.test.ts @@ -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(); + }); +}); diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 5b97721ce4..c2b4c9cb82 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -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; 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 { 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(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( diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index bf755cf2f1..7c4650d564 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -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 = { 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"), diff --git a/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx b/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx index 9a70e36cd5..3ffed47a6c 100644 --- a/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx +++ b/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx @@ -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>()), listMCPStrategies: vi.fn(async () => [{ key: "claude", description: "a config file" }]), - updateCustomTUIAgentMCPStrategy: vi.fn(), + updateCustomTUIAgentMCPStrategy: updateMcpStrategy, +})); + +vi.mock("@/components/toast-provider", async (importOriginal) => ({ + ...(await importOriginal>()), + 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(); + }); }); diff --git a/apps/web/components/agent/cli-profile-editor.tsx b/apps/web/components/agent/cli-profile-editor.tsx index 83e07243b2..1c8e5b988f 100644 --- a/apps/web/components/agent/cli-profile-editor.tsx +++ b/apps/web/components/agent/cli-profile-editor.tsx @@ -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); } diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.tsx index 0190d91d80..f77270b4ef 100644 --- a/apps/web/components/app-status-bar/app-status-surface-provider.tsx +++ b/apps/web/components/app-status-bar/app-status-surface-provider.tsx @@ -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 })
+ {children} {(useDrawerSurface ? drawerEnabled : inlineStatusBarVisible) && (useDrawerSurface ? ( diff --git a/apps/web/components/app-status-bar/backend-reload-required-alert.test.tsx b/apps/web/components/app-status-bar/backend-reload-required-alert.test.tsx new file mode 100644 index 0000000000..4e4dba2c93 --- /dev/null +++ b/apps/web/components/app-status-bar/backend-reload-required-alert.test.tsx @@ -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(); + + 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(); + 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(); + 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(); + + const action = screen.getByRole("button", { name: "Reload page" }); + expect(action.className).toContain("h-11"); + expect(action.className).toContain("w-full"); + }); +}); diff --git a/apps/web/components/app-status-bar/backend-reload-required-alert.tsx b/apps/web/components/app-status-bar/backend-reload-required-alert.tsx new file mode 100644 index 0000000000..45b7f36440 --- /dev/null +++ b/apps/web/components/app-status-bar/backend-reload-required-alert.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/apps/web/components/onboarding-dialog.test.tsx b/apps/web/components/onboarding-dialog.test.tsx new file mode 100644 index 0000000000..aad94d3744 --- /dev/null +++ b/apps/web/components/onboarding-dialog.test.tsx @@ -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 }) =>
{children}
; + return { + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: Content, + DialogHeader: Content, + DialogTitle: Content, + DialogFooter: Content, + DialogDescription: Content, + }; +}); +vi.mock("@/components/onboarding/step-agents", () => ({ + StepAgents: ({ + agentSettings, + onUpdateSetting, + }: { + agentSettings: Record; + onUpdateSetting: (agentName: string, formPatch: { model: string }) => void; + }) => ( + + ), +})); + +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(); + 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(); + 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(); + }); +}); diff --git a/apps/web/components/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index 7a7236c182..cd0770bf71 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -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 => { + 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 ( - {}}> + {}}> {t(STEP_TITLE_KEYS[step])} diff --git a/apps/web/components/settings/add-tui-agent-dialog.test.tsx b/apps/web/components/settings/add-tui-agent-dialog.test.tsx index 5b0396a05c..f2ddf4e8ea 100644 --- a/apps/web/components/settings/add-tui-agent-dialog.test.tsx +++ b/apps/web/components/settings/add-tui-agent-dialog.test.tsx @@ -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(); + + 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(); + }); }); diff --git a/apps/web/components/settings/add-tui-agent-dialog.tsx b/apps/web/components/settings/add-tui-agent-dialog.tsx index 0d69a7a4a1..dd2f1c1e1e 100644 --- a/apps/web/components/settings/add-tui-agent-dialog.tsx +++ b/apps/web/components/settings/add-tui-agent-dialog.tsx @@ -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); diff --git a/apps/web/components/settings/agent-profile-duplicate-action.ts b/apps/web/components/settings/agent-profile-duplicate-action.ts index e1a8d07878..4972218074 100644 --- a/apps/web/components/settings/agent-profile-duplicate-action.ts +++ b/apps/web/components/settings/agent-profile-duplicate-action.ts @@ -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), diff --git a/apps/web/components/settings/agent-profile-page-state.ts b/apps/web/components/settings/agent-profile-page-state.ts index 5cc87f605c..6836804595 100644 --- a/apps/web/components/settings/agent-profile-page-state.ts +++ b/apps/web/components/settings/agent-profile-page-state.ts @@ -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, diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 77fcda8bef..b409bddf80 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -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} /> diff --git a/apps/web/components/settings/agents/agent-install-catalog.test.ts b/apps/web/components/settings/agents/agent-install-catalog.test.ts new file mode 100644 index 0000000000..b988420cee --- /dev/null +++ b/apps/web/components/settings/agents/agent-install-catalog.test.ts @@ -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>()), + 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(); + }); +}); diff --git a/apps/web/components/settings/agents/agent-install-catalog.tsx b/apps/web/components/settings/agents/agent-install-catalog.tsx index 7fd0415efb..35c0b6d576 100644 --- a/apps/web/components/settings/agents/agent-install-catalog.tsx +++ b/apps/web/components/settings/agents/agent-install-catalog.tsx @@ -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 { + 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) { }, [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], ); diff --git a/apps/web/components/settings/agents/agent-profiles-section.tsx b/apps/web/components/settings/agents/agent-profiles-section.tsx index f1f8347842..87082d9139 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.tsx @@ -174,6 +174,7 @@ type ProfileRowDeleteConfirmationProps = { onConfirm: () => void | Promise; placement: "inline" | "popover"; }; +type ProfileRowDeleteConfirmationBaseProps = Omit; function ProfileRowDeleteConfirmation({ open, @@ -204,6 +205,81 @@ function ProfileRowDeleteConfirmation({ ); } +type ProfileRowCardProps = { + profile: AgentProfile; + href: string; + confirmOpen: boolean; + isFinePointer: boolean; + isFullDesktop: boolean; + deleteAnchorRef: RefObject; + onDuplicate: () => void; + onConfirmDelete: () => void; + confirmationProps: ProfileRowDeleteConfirmationBaseProps; +}; + +function ProfileRowCard({ + profile, + href, + confirmOpen, + isFinePointer, + isFullDesktop, + deleteAnchorRef, + onDuplicate, + onConfirmDelete, + confirmationProps, +}: ProfileRowCardProps) { + return ( + + {/* Whole-card link as an overlay — the action buttons sit above it (z-10). */} + + +
+
+ + {profile.name} + {profile.enabled === false && } +
+ {(profile.model || profile.mode) && ( +
+ {profile.model && {profile.model}} + {profile.mode && {profile.mode}} +
+ )} +
+
+ {!(confirmOpen && !isFinePointer) && + (isFullDesktop ? ( + + ) : ( + + ))} +
+ +
+ +
+ ); +} + /** 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 ( - - {/* Whole-card link as an overlay — the action buttons sit above it (z-10). */} - - -
-
- - {profile.name} - {profile.enabled === false && } -
- {(profile.model || profile.mode) && ( -
- {profile.model && {profile.model}} - {profile.mode && {profile.mode}} -
- )} -
-
- {!(confirmOpen && !isFinePointer) && - (isFullDesktop ? ( - void handleDuplicate(agent, profile)} - onConfirmDelete={() => setConfirmOpen(true)} - /> - ) : ( - void handleDuplicate(agent, profile)} - onConfirmDelete={() => setConfirmOpen(true)} - /> - ))} -
- -
- -
+ void handleDuplicate(agent, profile)} + onConfirmDelete={() => setConfirmOpen(true)} + confirmationProps={confirmationProps} + /> ); } diff --git a/apps/web/components/settings/custom-tui-mcp-card.tsx b/apps/web/components/settings/custom-tui-mcp-card.tsx index 526829c5fe..bd610879fa 100644 --- a/apps/web/components/settings/custom-tui-mcp-card.tsx +++ b/apps/web/components/settings/custom-tui-mcp-card.tsx @@ -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"), diff --git a/apps/web/components/settings/dynamic-agent-profile-editor-state.ts b/apps/web/components/settings/dynamic-agent-profile-editor-state.ts index 70dabb5134..a5ea3ac4a5 100644 --- a/apps/web/components/settings/dynamic-agent-profile-editor-state.ts +++ b/apps/web/components/settings/dynamic-agent-profile-editor-state.ts @@ -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, diff --git a/apps/web/components/settings/use-agent-update-dialog-state.test.ts b/apps/web/components/settings/use-agent-update-dialog-state.test.ts index 42359370a9..df9f16232c 100644 --- a/apps/web/components/settings/use-agent-update-dialog-state.test.ts +++ b/apps/web/components/settings/use-agent-update-dialog-state.test.ts @@ -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() { @@ -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(); diff --git a/apps/web/components/settings/use-agent-update-dialog-state.ts b/apps/web/components/settings/use-agent-update-dialog-state.ts index 65399b04a3..bf570772f4 100644 --- a/apps/web/components/settings/use-agent-update-dialog-state.ts +++ b/apps/web/components/settings/use-agent-update-dialog-state.ts @@ -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 { diff --git a/apps/web/e2e/tests/layout/backend-restart-page-recovery.spec.ts b/apps/web/e2e/tests/layout/backend-restart-page-recovery.spec.ts new file mode 100644 index 0000000000..b3decccc17 --- /dev/null +++ b/apps/web/e2e/tests/layout/backend-restart-page-recovery.spec.ts @@ -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$/); + }); +}); diff --git a/apps/web/e2e/tests/layout/mobile-backend-restart-page-recovery.spec.ts b/apps/web/e2e/tests/layout/mobile-backend-restart-page-recovery.spec.ts new file mode 100644 index 0000000000..347a6cbe3f --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-backend-restart-page-recovery.spec.ts @@ -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"); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts b/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts index ba9f3b11cd..1da1872e2a 100644 --- a/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts +++ b/apps/web/hooks/domains/settings/use-profile-duplicate.test.ts @@ -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 diff --git a/apps/web/hooks/domains/settings/use-profile-duplicate.ts b/apps/web/hooks/domains/settings/use-profile-duplicate.ts index 86f10546b8..c2cd0b1ceb 100644 --- a/apps/web/hooks/domains/settings/use-profile-duplicate.ts +++ b/apps/web/hooks/domains/settings/use-profile-duplicate.ts @@ -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"), diff --git a/apps/web/hooks/domains/settings/use-profile-enabled-toggle.test.ts b/apps/web/hooks/domains/settings/use-profile-enabled-toggle.test.ts index ee6ceb7f21..e1ec882d2b 100644 --- a/apps/web/hooks/domains/settings/use-profile-enabled-toggle.test.ts +++ b/apps/web/hooks/domains/settings/use-profile-enabled-toggle.test.ts @@ -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(); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-profile-enabled-toggle.ts b/apps/web/hooks/domains/settings/use-profile-enabled-toggle.ts index 41f88bf334..3882f39502 100644 --- a/apps/web/hooks/domains/settings/use-profile-enabled-toggle.ts +++ b/apps/web/hooks/domains/settings/use-profile-enabled-toggle.ts @@ -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"), diff --git a/apps/web/hooks/domains/system/use-backend-generation-guard.test.ts b/apps/web/hooks/domains/system/use-backend-generation-guard.test.ts new file mode 100644 index 0000000000..e805df179a --- /dev/null +++ b/apps/web/hooks/domains/system/use-backend-generation-guard.test.ts @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + }); +}); diff --git a/apps/web/hooks/domains/system/use-backend-generation-guard.ts b/apps/web/hooks/domains/system/use-backend-generation-guard.ts new file mode 100644 index 0000000000..04f6a05c69 --- /dev/null +++ b/apps/web/hooks/domains/system/use-backend-generation-guard.ts @@ -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(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]); +} diff --git a/apps/web/hooks/domains/system/use-kandev-restart.test.ts b/apps/web/hooks/domains/system/use-kandev-restart.test.ts index 2b72a0d75c..db966629f6 100644 --- a/apps/web/hooks/domains/system/use-kandev-restart.test.ts +++ b/apps/web/hooks/domains/system/use-kandev-restart.test.ts @@ -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 () => { diff --git a/apps/web/hooks/domains/system/use-kandev-restart.ts b/apps/web/hooks/domains/system/use-kandev-restart.ts index 3f9a549b04..e3a3f2f89a 100644 --- a/apps/web/hooks/domains/system/use-kandev-restart.ts +++ b/apps/web/hooks/domains/system/use-kandev-restart.ts @@ -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(null); const activeRef = useRef(false); const startedAtRef = useRef(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; diff --git a/apps/web/hooks/domains/system/use-self-update.test.ts b/apps/web/hooks/domains/system/use-self-update.test.ts index f87b8d0995..a357ee8965 100644 --- a/apps/web/hooks/domains/system/use-self-update.test.ts +++ b/apps/web/hooks/domains/system/use-self-update.test.ts @@ -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 () => { diff --git a/apps/web/hooks/domains/system/use-self-update.ts b/apps/web/hooks/domains/system/use-self-update.ts index 943593b5e6..eb9124653c 100644 --- a/apps/web/hooks/domains/system/use-self-update.ts +++ b/apps/web/hooks/domains/system/use-self-update.ts @@ -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(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 diff --git a/apps/web/lib/api/client.test.ts b/apps/web/lib/api/client.test.ts index 91e7065dad..fddb6f3b95 100644 --- a/apps/web/lib/api/client.test.ts +++ b/apps/web/lib/api/client.test.ts @@ -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", () => { diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts index c62a8843a1..bc98d3d194 100644 --- a/apps/web/lib/api/client.ts +++ b/apps/web/lib/api/client.ts @@ -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 { 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(pathOrUrl: string, options?: ApiRequestOptions): Promise { diff --git a/apps/web/lib/platform/backend-reload-coordinator.test.ts b/apps/web/lib/platform/backend-reload-coordinator.test.ts new file mode 100644 index 0000000000..0862354628 --- /dev/null +++ b/apps/web/lib/platform/backend-reload-coordinator.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/platform/backend-reload-coordinator.ts b/apps/web/lib/platform/backend-reload-coordinator.ts new file mode 100644 index 0000000000..f9551379ce --- /dev/null +++ b/apps/web/lib/platform/backend-reload-coordinator.ts @@ -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); diff --git a/apps/web/src/boot-payload.test.ts b/apps/web/src/boot-payload.test.ts index e7bdeceb05..77a9fd0661 100644 --- a/apps/web/src/boot-payload.test.ts +++ b/apps/web/src/boot-payload.test.ts @@ -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" } }, diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index c0de6b0b03..b2153c1348 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -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): 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, diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index aafb0bc014..797fd7416a 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -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", diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index becfc35c23..4d5ea293f6 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -209,6 +209,9 @@ "navLogs": "Ĺōĝś", "navUpdates": "Ũƥďàţēś", "navUsers": "Ũśēŕś", + "backendReloadRequiredTitle": "Ŕēĺōàď ŕēqũĩŕēď", + "backendReloadRequiredBody": "Ķàńďēv ŕēśţàŕţēď. Ŕēĺōàď ţĥĩś ƥàĝē ţō ćōńţĩńũē. Ŕēĺōàďĩńĝ ďĩśćàŕďś ũńśàvēď ćĥàńĝēś.", + "backendReloadRequiredAction": "Ŕēĺōàď ƥàĝē", "reloadPage": "Ŕēĺōàď ƥàĝē", "restartAction": "Ŕēśţàŕţ", "agentRuntimeUnavailableTitle": "Ĺōćàĺ àĝēńţ ŕũńţĩḿē śţōƥƥēď", diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index b7ff315629..980680eee3 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -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", diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json index bc1de2c606..e2c3fa9943 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -206,6 +206,9 @@ "navLogs": "日志", "navUpdates": "更新", "navUsers": "用户", + "backendReloadRequiredTitle": "需要重新加载", + "backendReloadRequiredBody": "Kandev 已重启。请重新加载此页面以继续。重新加载会丢弃未保存的更改。", + "backendReloadRequiredAction": "重新加载页面", "reloadPage": "重新加载页面", "restartAction": "重启", "agentRuntimeUnavailableTitle": "本地智能体运行时已停止", diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index e23fead512..b93af84ff1 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -206,6 +206,9 @@ "navLogs": "日誌", "navUpdates": "更新", "navUsers": "用戶", + "backendReloadRequiredTitle": "需要重新載入", + "backendReloadRequiredBody": "Kandev 已重啓。請重新載入此頁面以繼續。重新載入會丟棄未儲存的更改。", + "backendReloadRequiredAction": "重新載入頁面", "reloadPage": "重新載入頁面", "restartAction": "重啓", "agentRuntimeUnavailableTitle": "本地代理程式執行時已停止", diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index cc76f398dc..387a5e8941 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -206,6 +206,9 @@ "navLogs": "日誌", "navUpdates": "更新", "navUsers": "使用者", + "backendReloadRequiredTitle": "需要重新載入", + "backendReloadRequiredBody": "Kandev 已重啟。請重新載入此頁面以繼續。重新載入會丟棄未儲存的更改。", + "backendReloadRequiredAction": "重新載入頁面", "reloadPage": "重新載入頁面", "restartAction": "重啟", "agentRuntimeUnavailableTitle": "本地代理程式執行時已停止", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c495ad23ad..d5c5092791 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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"]); diff --git a/docs/decisions/2026-07-27-spa-failure-containment-and-deployment-recovery.md b/docs/decisions/2026-07-27-spa-failure-containment-and-deployment-recovery.md index 027033ea16..a25b1e132b 100644 --- a/docs/decisions/2026-07-27-spa-failure-containment-and-deployment-recovery.md +++ b/docs/decisions/2026-07-27-spa-failure-containment-and-deployment-recovery.md @@ -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 diff --git a/docs/plans/backend-restart-page-recovery/plan.md b/docs/plans/backend-restart-page-recovery/plan.md new file mode 100644 index 0000000000..2dac2da196 --- /dev/null +++ b/docs/plans/backend-restart-page-recovery/plan.md @@ -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. diff --git a/docs/plans/backend-restart-page-recovery/task-01-publish-page-boot-identity.md b/docs/plans/backend-restart-page-recovery/task-01-publish-page-boot-identity.md new file mode 100644 index 0000000000..ae48b1140d --- /dev/null +++ b/docs/plans/backend-restart-page-recovery/task-01-publish-page-boot-identity.md @@ -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. diff --git a/docs/plans/backend-restart-page-recovery/task-02-detect-backend-generation.md b/docs/plans/backend-restart-page-recovery/task-02-detect-backend-generation.md new file mode 100644 index 0000000000..baa3bdfe49 --- /dev/null +++ b/docs/plans/backend-restart-page-recovery/task-02-detect-backend-generation.md @@ -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. diff --git a/docs/plans/backend-restart-page-recovery/task-03-show-global-reload-alert.md b/docs/plans/backend-restart-page-recovery/task-03-show-global-reload-alert.md new file mode 100644 index 0000000000..d9ee0dc2e5 --- /dev/null +++ b/docs/plans/backend-restart-page-recovery/task-03-show-global-reload-alert.md @@ -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. diff --git a/docs/specs/platform/README.md b/docs/specs/platform/README.md index 1d31eec4f1..cc1da68032 100644 --- a/docs/specs/platform/README.md +++ b/docs/specs/platform/README.md @@ -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) diff --git a/docs/specs/platform/requirements/backend-restart-page-recovery.md b/docs/specs/platform/requirements/backend-restart-page-recovery.md new file mode 100644 index 0000000000..4526204647 --- /dev/null +++ b/docs/specs/platform/requirements/backend-restart-page-recovery.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) diff --git a/docs/specs/platform/system-design/backend-restart-page-recovery.md b/docs/specs/platform/system-design/backend-restart-page-recovery.md new file mode 100644 index 0000000000..1b5d3b9ed1 --- /dev/null +++ b/docs/specs/platform/system-design/backend-restart-page-recovery.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)