diff --git a/.changeset/fn-9160-quick-entry-quota.md b/.changeset/fn-9160-quick-entry-quota.md new file mode 100644 index 0000000000..f7070bc657 --- /dev/null +++ b/.changeset/fn-9160-quick-entry-quota.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent oversized task drafts from exhausting browser storage. +category: fix +dev: Scoped draft writes return a persistence result, cap free text at 64,000 bytes, and reclaim stale entries after quota failures. diff --git a/docs/storage.md b/docs/storage.md index 799e01a73f..aedc836724 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -322,18 +322,18 @@ High-level finding: the dashboard currently uses localStorage extensively for UX | `kb-dashboard-current-project` | `hooks/useCurrentProject.ts` | JSON `ProjectInfo` object (includes id/name/path/status/etc.) | project/identity | **Medium** | | `kb-terminal-tabs` | `hooks/useTerminalSessions.ts` | JSON array of tab objects (`id`, `sessionId`, `title`, active state, timestamp) | UI preference (operational session state) | **High** | | `fn-agent-tree-expanded` | `hooks/useAgentHierarchy.ts` | JSON string[] of expanded agent ids | UI preference | Low | -| `kb-planning-last-description` | `hooks/modalPersistence.ts` (used by `PlanningModeModal`) | free-text draft | user draft | Medium | -| `kb-subtask-last-description` | `hooks/modalPersistence.ts` (used by `SubtaskBreakdownModal`) | free-text draft | user draft | Medium | -| `kb-mission-last-goal` | `hooks/modalPersistence.ts` (used by `MissionInterviewModal`) | free-text draft | user draft | Medium | +| `kb-planning-last-description` | `hooks/modalPersistence.ts` (used by `PlanningModeModal`) | free-text draft (best-effort; 64,000-byte cap) | user draft | Medium | +| `kb-subtask-last-description` | `hooks/modalPersistence.ts` (used by `SubtaskBreakdownModal`) | free-text draft (best-effort; 64,000-byte cap) | user draft | Medium | +| `kb-mission-last-goal` | `hooks/modalPersistence.ts` (used by `MissionInterviewModal`) | free-text draft (best-effort; 64,000-byte cap) | user draft | Medium | | `kb-dashboard-view-mode` | `App.tsx` | enum string (`overview`/`project`) | UI preference | Low | | `kb-dashboard-task-view` | `App.tsx` | enum string (`board`/`list`/`agents`) | UI preference | Low | | `kb-dashboard-list-columns` | `components/ListView.tsx` | JSON array of visible list columns | UI preference | Low | | `kb-dashboard-hide-done` | `components/ListView.tsx` | boolean string (`"true"`/`"false"`) | UI preference | Low | | `kb-dashboard-list-collapsed` | `components/ListView.tsx` | JSON array of collapsed column ids | UI preference | Low | | `kb-dashboard-selected-tasks` | `components/ListView.tsx` | JSON array of selected task IDs | UI preference | **Medium** | -| `kb-quick-entry-text` | `components/QuickEntryBox.tsx` | free-text task draft | user draft | Medium | +| `kb-quick-entry-text` | `components/QuickEntryBox.tsx` | free-text task draft (best-effort; 64,000-byte cap) | user draft | Medium | | `kb-quick-entry-expanded` | `components/QuickEntryBox.tsx` (legacy cleanup via `removeItem`) | legacy bool key (no longer used) | UI preference | Low | -| `kb-inline-create-text` | `components/InlineCreateCard.tsx` | free-text task draft | user draft | Medium | +| `kb-inline-create-text` | `components/InlineCreateCard.tsx` | free-text task draft (best-effort; 64,000-byte cap) | user draft | Medium | | `fn-agent-view` | `components/AgentsView.tsx`, `components/AgentListModal.tsx` | enum string (`board`/`list`/`tree` in view; modal supports board/list) | UI preference | Medium | | `kb-usage-view-mode` | `components/UsageIndicator.tsx` | enum string (`used`/`remaining`) | UI preference | Low | | `kb-dashboard-recent-projects` | `components/ProjectOverview.tsx` | JSON array of recent project IDs | project/identity | Low | @@ -611,49 +611,54 @@ The PostgreSQL-era runtime writes `.fusion/project.json`. Startup reads this leg - **Problem:** Theme is persisted in both localStorage (`kb-dashboard-theme-mode`, `kb-dashboard-color-theme`) and backend global settings (`themeMode`, `colorTheme`), but app bootstrap uses localStorage-only theme hydration. If backend and browser cache diverge, cross-device consistency breaks. - **Recommended fix:** Make backend global settings the source of truth (or explicitly define local cache precedence + bidirectional sync strategy and conflict resolution). -2. **Project-unscoped localStorage keys in multi-project UX state** +2. **Draft persistence quota exhaustion (#3477) — resolved** + - **Severity:** Medium + - **Affected:** `kb-quick-entry-text`, `kb-inline-create-text`, `kb-planning-last-description`, `kb-subtask-last-description`, `kb-mission-last-goal` + - **Resolution:** Scoped writes are throw-safe and retry after exact-key eviction; repeated quota failures reclaim stale other-project volatile drafts and stale dashboard SWR envelopes. Free-text drafts are capped at 64,000 bytes and remain in memory when they cannot be restored, so **Clear local data** is no longer a workaround for draft-driven saturation. + +3. **Project-unscoped localStorage keys in multi-project UX state** - **Severity:** High - **Affected:** `App.tsx`, `ListView.tsx`, `QuickEntryBox.tsx`, `InlineCreateCard.tsx`, `AgentsView.tsx`, `useTerminalSessions.ts`, `useAgentHierarchy.ts`, `UsageIndicator.tsx` - **Problem:** Many keys are global (`kb-dashboard-task-view`, `kb-dashboard-list-*`, `kb-dashboard-selected-tasks`, `kb-quick-entry-text`, `kb-inline-create-text`, `kb-terminal-tabs`, etc.) and are reused across projects. This can leak preferences/drafts/selections between projects unexpectedly. - **Recommended fix:** Namespace project-specific keys with `projectId` (e.g., `kb:{projectId}:dashboard-list-columns`). Keep only true global prefs unscoped. -3. **`kb-dashboard-selected-tasks` can carry stale selections across projects** +4. **`kb-dashboard-selected-tasks` can carry stale selections across projects** - **Severity:** Medium - **Affected:** `components/ListView.tsx` - **Problem:** Selected task IDs persist globally. In multi-project setups with overlapping ID patterns, stale selections can reappear and affect bulk operations unexpectedly. - **Recommended fix:** Project-scope this key, and/or treat selection as in-memory/session-only state. -4. **Terminal session persistence stores operational identifiers in localStorage** +5. **Terminal session persistence stores operational identifiers in localStorage** - **Severity:** Medium - **Affected:** `hooks/useTerminalSessions.ts` (`kb-terminal-tabs`) - **Problem:** Session IDs and tab metadata persist client-side and are not project-scoped. This is operational state better owned by backend/session layer; stale tabs also survive cache until cleanup logic runs. - **Recommended fix:** Move terminal tab/session state to server persistence (or at minimum sessionStorage + project scoping + TTL/versioning). -5. **Current project persistence stores full `ProjectInfo` object (includes filesystem path)** +6. **Current project persistence stores full `ProjectInfo` object (includes filesystem path)** - **Severity:** Medium - **Affected:** `hooks/useCurrentProject.ts` (`kb-dashboard-current-project`) - **Problem:** Storing full project objects increases drift risk and stores more data than needed (including local path). - **Recommended fix:** Persist only stable `projectId`; resolve current object from backend project list each load. -6. **Draft persistence is local-only (device/browser-bound)** +7. **Draft persistence is local-only (device/browser-bound)** - **Severity:** Medium - **Affected:** `modalPersistence.ts`, `QuickEntryBox.tsx`, `InlineCreateCard.tsx` - **Problem:** Planning/subtask/mission/task-entry drafts are lost on storage clear or browser/device switch. - **Recommended fix:** Keep local quick-draft behavior, but add optional server-backed drafts (short TTL) for continuity. -7. **Settings scope key lists drift from interfaces** +8. **Settings scope key lists drift from interfaces** - **Severity:** Medium - **Affected:** `packages/core/src/types.ts`, `store.ts`, `routes.ts`, `SettingsModal.tsx` - **Problem:** `GLOBAL_SETTINGS_KEYS` (14) omits `setupComplete`, `favoriteProviders`, `favoriteModels`; `PROJECT_SETTINGS_KEYS` (52) omits 9 project interface keys (`strictScopeEnforcement`, `buildRetryCount`, `buildTimeoutMs`, `autoUnpause*`, `maintenanceIntervalMs`, `scripts`, `setupScript`). This creates scope-classification and patch-filter inconsistencies. - **Recommended fix:** Generate key lists from schema/interface source (or enforce parity tests) to prevent drift. -8. **`fn-agent-view` shared by two UIs with different supported modes** +9. **`fn-agent-view` shared by two UIs with different supported modes** - **Severity:** Low - **Affected:** `AgentsView.tsx`, `AgentListModal.tsx` - **Problem:** Both share the same key, but one surface supports `tree` and the modal supports only `board/list`; behavior remains valid but coupling is implicit. - **Recommended fix:** Decide intentional shared behavior and document it; otherwise split keys by surface. -9. **Workflow steps still persisted in config JSON compatibility path (known in-progress work)** +10. **Workflow steps still persisted in config JSON compatibility path (known in-progress work)** - **Severity:** Low - **Affected:** `config.settings/workflowSteps`, `db.ts` config table - **Problem (historical audit):** Workflow step storage was tied to config blob structure; **FN-1201** moved it to a dedicated table before the PostgreSQL cutover. diff --git a/packages/dashboard/app/components/InlineCreateCard.tsx b/packages/dashboard/app/components/InlineCreateCard.tsx index adf316ed79..b652ac7bbf 100644 --- a/packages/dashboard/app/components/InlineCreateCard.tsx +++ b/packages/dashboard/app/components/InlineCreateCard.tsx @@ -14,7 +14,7 @@ import { NodeHealthDot } from "./NodeHealthDot"; import { DuplicateWarningModal } from "./DuplicateWarningModal"; import { LoadingSpinner } from "./LoadingSpinner"; import { applyPresetToSelection } from "../utils/modelPresets"; -import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; +import { getScopedItem, MAX_PERSISTED_DRAFT_BYTES, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { WorkflowSelector } from "./WorkflowSelector"; import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown"; import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews"; @@ -153,6 +153,7 @@ export function InlineCreateCard({ const [duplicateMatches, setDuplicateMatches] = useState(null); const [pendingSubmit, setPendingSubmit] = useState(null); const justResetRef = useRef(false); + const draftPersistenceWarningShownRef = useRef(false); const inputRef = useRef(null); const cardRef = useRef(null); const agentPickerRef = useRef(null); @@ -162,12 +163,26 @@ export function InlineCreateCard({ setDescription(getScopedItem(STORAGE_KEY, projectId) || ""); }, [projectId]); - // Persist description to localStorage whenever it changes + /* + FNXC:QuickAddDraftPersistence 2026-08-20-00:43: + Inline Create keeps its React draft authoritative for submission. The localStorage restore mirror is capped and optional so a large paste never exhausts browser quota or interrupts the composer (Runfusion/Fusion#3477). + */ useEffect(() => { - if (typeof window !== "undefined") { - setScopedItem(STORAGE_KEY, description, projectId); + if (description.length === 0) { + removeScopedItem(STORAGE_KEY, projectId); + return; } - }, [description, projectId]); + + const persisted = setScopedItem(STORAGE_KEY, description, projectId, { + maxBytes: MAX_PERSISTED_DRAFT_BYTES, + }); + if (persisted) { + draftPersistenceWarningShownRef.current = false; + } else if (!draftPersistenceWarningShownRef.current) { + draftPersistenceWarningShownRef.current = true; + addToast(t("tasks.draftTooLargeToSave", "Draft is too large to save in this browser — it will not be restored after a reload."), "warning"); + } + }, [addToast, description, projectId, t]); // Clear agents cache when projectId changes to prevent stale agents from leaking across projects useEffect(() => { diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index 62ab7011ef..c131ee6c8b 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -11,7 +11,7 @@ import { DuplicateWarningModal } from "./DuplicateWarningModal"; import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap, Eye, EyeOff, Play } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { LoadingSpinner } from "./LoadingSpinner"; -import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; +import { getScopedItem, MAX_PERSISTED_DRAFT_BYTES, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { useNodes } from "../hooks/useNodes"; import { useComposerDictation } from "../hooks/useComposerDictation"; import { MicButton } from "./MicButton"; @@ -184,6 +184,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai const touchButtonRef = useRef(null); const startIntentRef = useRef(null); const justResetRef = useRef(false); + const draftPersistenceWarningShownRef = useRef(false); const previousProjectIdRef = useRef(projectId); const [pendingAttachments, setPendingAttachments] = useState([]); const pendingAttachmentsRef = useRef([]); @@ -502,12 +503,26 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai setDescription(getScopedItem(STORAGE_KEY, projectId) || ""); }, [projectId]); - // Persist description to localStorage whenever it changes + /* + FNXC:QuickAddDraftPersistence 2026-08-20-00:43: + Quick Add's in-memory draft remains authoritative for task creation. Browser restoration is best-effort and capped so a pasted description cannot exhaust localStorage or interrupt typing (Runfusion/Fusion#3477). + */ useEffect(() => { - if (typeof window !== "undefined") { - setScopedItem(STORAGE_KEY, description, projectId); + if (description.length === 0) { + removeScopedItem(STORAGE_KEY, projectId); + return; } - }, [description, projectId]); + + const persisted = setScopedItem(STORAGE_KEY, description, projectId, { + maxBytes: MAX_PERSISTED_DRAFT_BYTES, + }); + if (persisted) { + draftPersistenceWarningShownRef.current = false; + } else if (!draftPersistenceWarningShownRef.current) { + draftPersistenceWarningShownRef.current = true; + addToast(t("tasks.draftTooLargeToSave", "Draft is too large to save in this browser — it will not be restored after a reload."), "warning"); + } + }, [addToast, description, projectId, t]); // Clear agents cache when projectId changes to prevent stale agents from leaking across projects useEffect(() => { diff --git a/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx index 8639d4e047..24a3444f15 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.draft.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, render as rtlRender, screen, waitFor } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { ChatView } from "../ChatView"; @@ -150,6 +150,10 @@ describe("ChatView draft persistence", () => { mockFetchSettings.mockResolvedValue({} as Awaited>); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("writes direct-session drafts to localStorage while typing", async () => { await renderChatView(); @@ -160,6 +164,36 @@ describe("ChatView draft persistence", () => { }); }); + it("keeps the chat composer usable when its raw draft storage throws a quota error", async () => { + const sendMessage = vi.fn(); + const draftKey = "fusion:chat-draft:direct:session-001"; + setup({ sendMessage }); + vi.spyOn(localStorage, "setItem").mockImplementation((key) => { + if (key === draftKey) throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + + await renderChatView(); + const composer = screen.getByPlaceholderText("Type a message..."); + await expect(userEvent.type(composer, "still sends despite quota")).resolves.toBeUndefined(); + + expect(composer).toHaveValue("still sends despite quota"); + await userEvent.click(screen.getAllByTestId("chat-send-btn")[0]); + expect(sendMessage).toHaveBeenCalledWith("still sends despite quota", [], expect.anything()); + }); + + it("keeps empty-draft removal throw-safe", async () => { + const draftKey = "fusion:chat-draft:direct:session-001"; + await renderChatView(); + const composer = screen.getByPlaceholderText("Type a message..."); + await userEvent.type(composer, "temporary"); + vi.spyOn(localStorage, "removeItem").mockImplementation((key) => { + if (key === draftKey) throw new DOMException("Storage disabled", "QuotaExceededError"); + }); + + await expect(userEvent.clear(composer)).resolves.toBeUndefined(); + expect(composer).toHaveValue(""); + }); + it("restores the persisted direct-session draft when remounted", async () => { localStorage.setItem("fusion:chat-draft:direct:session-001", "saved draft"); diff --git a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx index 0a3f3f8343..fcd9f41945 100644 --- a/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx @@ -1898,6 +1898,26 @@ describe("InlineCreateCard workflow selection at create time (FN-7591)", () => { await waitFor(() => expect(props.onSubmit).toHaveBeenCalled()); expect(selectTaskWorkflow).not.toHaveBeenCalled(); }); + + it("keeps the inline composer submit-ready when its scoped draft storage throws", async () => { + const projectId = "proj-9160"; + const key = `kb:${projectId}:kb-inline-create-text`; + const onSubmit = vi.fn().mockResolvedValue({ id: "FN-9160" } as Task); + const addToast = vi.fn(); + vi.spyOn(localStorage, "setItem").mockImplementation((storageKey) => { + if (storageKey === key) throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + renderCard([], { projectId, onSubmit, addToast }); + expandCard(); + const textarea = screen.getByPlaceholderText("What needs to be done?"); + + expect(() => fireEvent.change(textarea, { target: { value: "inline task survives quota" } })).not.toThrow(); + expect(textarea).toHaveValue("inline task survives quota"); + expect(addToast).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByTestId("save-button")); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ description: "inline task survives quota" }))); + }); }); }); diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index f5fea8be2a..32d8053bc0 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -6,7 +6,7 @@ import { expectStableTyping } from "./typingStability.test-helpers"; import { TASK_PRIORITIES, type Task, type TaskPriority } from "@fusion/core"; import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../../api"; import { useNodes } from "../../hooks/useNodes"; -import { scopedKey } from "../../utils/projectStorage"; +import { MAX_PERSISTED_DRAFT_BYTES, scopedKey } from "../../utils/projectStorage"; import { getPriorityColorVar } from "../../utils/priorityIndicator"; import { loadAllAppCss } from "../../test/cssFixture"; import { readAppFile } from "../../test/cssFixture"; @@ -5460,4 +5460,38 @@ describe("QuickEntryBox", () => { }); }); + describe("quota-safe draft persistence (FN-9160)", () => { + it("evicts an over-cap Quick Add draft and warns only once", () => { + const addToast = vi.fn(); + localStorage.setItem(QUICK_ENTRY_STORAGE_KEY, "prior draft"); + renderQuickEntryBox({ addToast }); + + fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "x".repeat(MAX_PERSISTED_DRAFT_BYTES + 1) } }); + + expect(screen.getByTestId("quick-entry-input")).toHaveValue("x".repeat(MAX_PERSISTED_DRAFT_BYTES + 1)); + expect(localStorage.getItem(QUICK_ENTRY_STORAGE_KEY)).toBeNull(); + expect(addToast).toHaveBeenCalledTimes(1); + }); + + it("keeps the reported scoped Quick Add draft usable and submit-ready when storage throws", async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + const addToast = vi.fn(); + const setItem = vi.spyOn(localStorage, "setItem").mockImplementation((key) => { + if (key === QUICK_ENTRY_STORAGE_KEY) { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + } + }); + renderQuickEntryBox({ onCreate, addToast }); + const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement; + + expect(() => fireEvent.change(textarea, { target: { value: "still creates despite quota" } })).not.toThrow(); + expect(textarea).toHaveValue("still creates despite quota"); + expect(addToast).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByTestId("quick-entry-save")); + + await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ description: "still creates despite quota" }))); + expect(setItem).toHaveBeenCalledWith(QUICK_ENTRY_STORAGE_KEY, "still creates despite quota"); + }); + }); + }); diff --git a/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts b/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts index bfc9c3f086..1aa80b89f7 100644 --- a/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts +++ b/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts @@ -123,6 +123,20 @@ describe("modalPersistence", () => { }); }); + describe("bounded free-text persistence", () => { + it.each([ + ["planning", STORED_PLANNING_KEY, savePlanningDescription], + ["subtask", STORED_SUBTASK_KEY, saveSubtaskDescription], + ["mission", STORED_MISSION_KEY, saveMissionGoal], + ] as const)("does not persist an over-cap %s draft", (_name, key, save) => { + const projectId = "project-a"; + localStorage.setItem(scopedKey(key, projectId), "prior value"); + + expect(() => save("x".repeat(64_001), projectId)).not.toThrow(); + expect(localStorage.getItem(scopedKey(key, projectId))).toBeNull(); + }); + }); + describe("Planning active-session persistence", () => { it("saves, reads, and clears an active session per project", () => { savePlanningActiveSession("planning-123", "proj-123"); @@ -182,7 +196,7 @@ describe("modalPersistence", () => { expect(() => save(value, "project-a")).not.toThrow(); - expect(setItem).toHaveBeenCalledTimes(2); + expect(setItem).toHaveBeenCalledTimes(3); expect(removeItem).toHaveBeenCalledTimes(1); expect(removeItem).toHaveBeenCalledWith(planningKey); expect(localStorage.getItem("unrelated-key")).toBe("preserved"); @@ -198,7 +212,7 @@ describe("modalPersistence", () => { }); expect(() => savePlanningDescription("new description", "project-a")).not.toThrow(); - expect(setItem).toHaveBeenCalledTimes(2); + expect(setItem).toHaveBeenCalledTimes(3); expect(removeItem).toHaveBeenCalledWith(planningKey); vi.restoreAllMocks(); @@ -242,11 +256,11 @@ describe("modalPersistence", () => { expect(removeItem).toHaveBeenCalledWith(descriptionKey); expect(removeItem).toHaveBeenCalledWith(activeSessionKey); - expect(setItem.mock.calls.filter(([key]) => key === descriptionKey)).toHaveLength(2); - // The durable draft hand-off and the selected-session effect each retry their same active key once. - expect(setItem.mock.calls.filter(([key]) => key === activeSessionKey)).toHaveLength(4); - expect(setItem).toHaveBeenCalledTimes(6); - expect(localStorage.getItem("kb:project-2:kb-planning-last-description")).toBe("other project"); + expect(setItem.mock.calls.filter(([key]) => key === descriptionKey)).toHaveLength(3); + // The shared seam makes a final write after its bounded reclaim attempt for each persistence call. + expect(setItem.mock.calls.filter(([key]) => key === activeSessionKey)).toHaveLength(6); + expect(setItem).toHaveBeenCalledTimes(9); + expect(localStorage.getItem("kb:project-2:kb-planning-last-description")).toBeNull(); expect(localStorage.getItem("unrelated-key")).toBe("preserved"); expect(screen.queryByText("Quota exceeded")).toBeNull(); }); diff --git a/packages/dashboard/app/hooks/modalPersistence.ts b/packages/dashboard/app/hooks/modalPersistence.ts index 6a47b6fe60..627e753bf0 100644 --- a/packages/dashboard/app/hooks/modalPersistence.ts +++ b/packages/dashboard/app/hooks/modalPersistence.ts @@ -1,4 +1,4 @@ -import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; +import { getScopedItem, MAX_PERSISTED_DRAFT_BYTES, removeScopedItem, setScopedItem } from "../utils/projectStorage"; // Storage keys — each modal type has independent storage export const STORED_PLANNING_KEY = "kb-planning-last-description"; @@ -12,27 +12,16 @@ export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state"; /* FNXC:PlanningStorage 2026-08-06-14:56: Planning storage is optional restoration state, never a prerequisite for durable draft creation or streaming. On a write failure, evict only this exact project-scoped key, retry once, then swallow cleanup or retry failures so planning continues from React and server state. -*/ -function savePlanningItem(baseKey: string, value: string, projectId?: string): void { - try { - setScopedItem(baseKey, value, projectId); - } catch { - try { - removeScopedItem(baseKey, projectId); - } catch { - // Best-effort eviction; ignore cleanup failures so planning continues. - } - try { - setScopedItem(baseKey, value, projectId); - } catch { - // Best-effort retry; ignore storage failures so planning continues. - } - } +FNXC:PlanningStorage 2026-08-20-00:43: +FN-9160 moves that retry ladder into the shared scoped-storage seam, which also reclaims stale volatile entries after repeated quota failures. Free-text modal drafts pass its byte cap; small session identifiers and JSON view state retain their existing behavior. +*/ +function savePlanningItem(baseKey: string, value: string, projectId?: string, maxBytes?: number): void { + setScopedItem(baseKey, value, projectId, maxBytes === undefined ? undefined : { maxBytes }); } export function savePlanningDescription(description: string, projectId?: string): void { - savePlanningItem(STORED_PLANNING_KEY, description, projectId); + savePlanningItem(STORED_PLANNING_KEY, description, projectId, MAX_PERSISTED_DRAFT_BYTES); } export function getPlanningDescription(projectId?: string): string { @@ -62,7 +51,7 @@ export function clearPlanningActiveSession(projectId?: string): void { // Subtask persistence export function saveSubtaskDescription(description: string, projectId?: string): void { - setScopedItem(STORED_SUBTASK_KEY, description, projectId); + setScopedItem(STORED_SUBTASK_KEY, description, projectId, { maxBytes: MAX_PERSISTED_DRAFT_BYTES }); } export function getSubtaskDescription(projectId?: string): string { @@ -76,7 +65,7 @@ export function clearSubtaskDescription(projectId?: string): void { // Mission persistence export function saveMissionGoal(goal: string, projectId?: string): void { - setScopedItem(STORED_MISSION_KEY, goal, projectId); + setScopedItem(STORED_MISSION_KEY, goal, projectId, { maxBytes: MAX_PERSISTED_DRAFT_BYTES }); } export function getMissionGoal(projectId?: string): string { diff --git a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts index 1078731da3..e41e0a1dd1 100644 --- a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts +++ b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts @@ -1,8 +1,10 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GLOBAL_STORAGE_KEYS, + MAX_PERSISTED_DRAFT_BYTES, PROJECT_STORAGE_KEYS, + VOLATILE_DRAFT_STORAGE_KEYS, getScopedItem, removeScopedItem, scopedKey, @@ -14,6 +16,11 @@ describe("projectStorage", () => { localStorage.clear(); }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + describe("scopedKey", () => { it("returns scoped key when projectId is provided", () => { expect(scopedKey("kb-dashboard-list-columns", "proj-abc")).toBe( @@ -135,18 +142,93 @@ describe("projectStorage", () => { vi.unstubAllGlobals(); }); - it("setScopedItem is a no-op when localStorage.setItem is unavailable", () => { + it("returns false when localStorage.setItem is unavailable", () => { vi.stubGlobal("window", { localStorage: {} }); - expect(() => setScopedItem("kb-dashboard-list-columns", "value", "proj-abc")).not.toThrow(); + expect(setScopedItem("kb-dashboard-list-columns", "value", "proj-abc")).toBe(false); vi.unstubAllGlobals(); }); + it("evicts an over-cap draft instead of persisting it", () => { + const key = scopedKey("kb-quick-entry-text", "proj-abc"); + localStorage.setItem(key, "previous draft"); + + expect(setScopedItem("kb-quick-entry-text", "x".repeat(MAX_PERSISTED_DRAFT_BYTES + 1), "proj-abc", { + maxBytes: MAX_PERSISTED_DRAFT_BYTES, + })).toBe(false); + expect(localStorage.getItem(key)).toBeNull(); + }); + + it("persists a draft exactly at its byte cap", () => { + const value = "x".repeat(MAX_PERSISTED_DRAFT_BYTES); + expect(setScopedItem("kb-quick-entry-text", value, "proj-abc", { + maxBytes: MAX_PERSISTED_DRAFT_BYTES, + })).toBe(true); + expect(getScopedItem("kb-quick-entry-text", "proj-abc")).toBe(value); + }); + + it("evicts the same key and retries after an initial quota failure", () => { + const key = scopedKey("kb-quick-entry-text", "proj-abc"); + const nativeSetItem = localStorage.setItem.bind(localStorage); + const setItem = vi.spyOn(localStorage, "setItem") + .mockImplementationOnce(() => { throw new DOMException("Quota exceeded", "QuotaExceededError"); }) + .mockImplementation((nextKey, value) => nativeSetItem(nextKey, value)); + + expect(setScopedItem("kb-quick-entry-text", "draft", "proj-abc")).toBe(true); + expect(setItem).toHaveBeenCalledTimes(2); + expect(localStorage.getItem(key)).toBe("draft"); + }); + + it("reclaims other-project volatile drafts and stale SWR entries before a final retry", () => { + const nativeSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem(scopedKey("kb-quick-entry-text", "other-project"), "old draft"); + localStorage.setItem(scopedKey("kb-inline-create-text", "other-project"), "old inline draft"); + localStorage.setItem(scopedKey("kb-quick-entry-text", "keep-project"), "keep draft"); + localStorage.setItem(scopedKey("kb-dashboard-list-columns", "other-project"), "keep preference"); + localStorage.setItem("kb-dashboard-tasks-cache:old", JSON.stringify({ savedAt: 0, data: [] })); + const setItem = vi.spyOn(localStorage, "setItem") + .mockImplementationOnce(() => { throw new DOMException("Quota exceeded", "QuotaExceededError"); }) + .mockImplementationOnce(() => { throw new DOMException("Quota exceeded", "QuotaExceededError"); }) + .mockImplementation((key, value) => nativeSetItem(key, value)); + + expect(setScopedItem("kb-quick-entry-text", "fresh", "keep-project")).toBe(true); + expect(setItem).toHaveBeenCalledTimes(3); + expect(localStorage.getItem(scopedKey("kb-quick-entry-text", "other-project"))).toBeNull(); + expect(localStorage.getItem(scopedKey("kb-inline-create-text", "other-project"))).toBeNull(); + expect(localStorage.getItem(scopedKey("kb-quick-entry-text", "keep-project"))).toBe("fresh"); + expect(localStorage.getItem(scopedKey("kb-dashboard-list-columns", "other-project"))).toBe("keep preference"); + expect(localStorage.getItem("kb-dashboard-tasks-cache:old")).toBeNull(); + }); + + it("returns false instead of throwing when storage remains full", () => { + vi.spyOn(localStorage, "setItem").mockImplementation(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + + expect(() => expect(setScopedItem("kb-quick-entry-text", "draft", "proj-abc")).toBe(false)).not.toThrow(); + }); + + it("declares every volatile draft key as project-scoped storage", () => { + expect(PROJECT_STORAGE_KEYS).toEqual(expect.arrayContaining(VOLATILE_DRAFT_STORAGE_KEYS)); + }); + it("removeScopedItem is a no-op when localStorage.removeItem is unavailable", () => { vi.stubGlobal("window", { localStorage: {} }); expect(() => removeScopedItem("kb-dashboard-list-columns", "proj-abc")).not.toThrow(); vi.unstubAllGlobals(); }); + it("keeps scoped reads and cleanup throw-safe when browser storage is blocked", () => { + vi.spyOn(localStorage, "getItem").mockImplementation(() => { + throw new DOMException("Storage disabled", "SecurityError"); + }); + vi.spyOn(localStorage, "removeItem").mockImplementation(() => { + throw new DOMException("Storage disabled", "SecurityError"); + }); + + expect(getScopedItem("kb-quick-entry-text", "proj-abc")).toBeNull(); + expect(() => removeScopedItem("kb-quick-entry-text", "proj-abc")).not.toThrow(); + }); + it("has no overlap between global and project-scoped keys", () => { const globalSet = new Set(GLOBAL_STORAGE_KEYS); const overlap = PROJECT_STORAGE_KEYS.filter((key) => globalSet.has(key)); diff --git a/packages/dashboard/app/utils/projectStorage.ts b/packages/dashboard/app/utils/projectStorage.ts index 758b93ed07..824bbc3b4f 100644 --- a/packages/dashboard/app/utils/projectStorage.ts +++ b/packages/dashboard/app/utils/projectStorage.ts @@ -1,3 +1,15 @@ +import { pruneStaleCacheEntries } from "./swrCache"; + +export const MAX_PERSISTED_DRAFT_BYTES = 64_000; + +export const VOLATILE_DRAFT_STORAGE_KEYS = [ + "kb-quick-entry-text", + "kb-inline-create-text", + "kb-planning-last-description", + "kb-subtask-last-description", + "kb-mission-last-goal", +] as const; + export const GLOBAL_STORAGE_KEYS: string[] = [ "kb-dashboard-theme-mode", "kb-dashboard-color-theme", @@ -62,20 +74,110 @@ export function getScopedItem(baseKey: string, projectId?: string): string | nul return null; } - return getItem.call(window.localStorage, scopedKey(baseKey, projectId)); + try { + return getItem.call(window.localStorage, scopedKey(baseKey, projectId)); + } catch { + return null; + } } -export function setScopedItem(baseKey: string, value: string, projectId?: string): void { +function getStorage(): Storage | null { if (typeof window === "undefined") { - return; + return null; } - const setItem = window.localStorage?.setItem; - if (typeof setItem !== "function") { - return; + try { + return window.localStorage ?? null; + } catch { + return null; + } +} + +function removeStorageKey(storage: Storage, key: string): boolean { + try { + storage.removeItem(key); + return true; + } catch { + return false; + } +} + +/** + * Reclaims optional restoration state only after a scoped write hits storage quota. + * The sweep is deliberately bounded and preserves the active project's drafts. + */ +export function reclaimScopedStorageQuota(options?: { keepProjectId?: string }): number { + const storage = getStorage(); + if (!storage) { + return 0; } - setItem.call(window.localStorage, scopedKey(baseKey, projectId), value); + let removed = 0; + try { + const keys: string[] = []; + const maxEntries = Math.min(storage.length, 1_000); + for (let index = 0; index < maxEntries; index += 1) { + const key = storage.key(index); + if (key !== null) keys.push(key); + } + + for (const key of keys) { + const isOtherProjectDraft = VOLATILE_DRAFT_STORAGE_KEYS.some((draftKey) => ( + key.startsWith("kb:") + && key.endsWith(`:${draftKey}`) + && key !== scopedKey(draftKey, options?.keepProjectId) + )); + if (isOtherProjectDraft && removeStorageKey(storage, key)) { + removed += 1; + } + } + } catch { + // Storage enumeration is best-effort; stale SWR pruning can still reclaim space. + } + + try { + removed += pruneStaleCacheEntries(); + } catch { + // A blocked storage implementation must not make draft persistence throw. + } + return removed; +} + +/* +FNXC:ProjectStorage 2026-08-20-00:43: +Issue #3477 reported `kb::kb-quick-entry-text` exhausting localStorage. Draft persistence is optional restoration state, so writes must never throw or saturate the origin: capped callers skip oversized values and quota failures evict, reclaim stale volatile state, then fail closed. +*/ +export function setScopedItem( + baseKey: string, + value: string, + projectId?: string, + options?: { maxBytes?: number }, +): boolean { + const storage = getStorage(); + if (!storage || typeof storage.setItem !== "function") { + return false; + } + + const key = scopedKey(baseKey, projectId); + if (typeof options?.maxBytes === "number" && new TextEncoder().encode(value).length > options.maxBytes) { + removeStorageKey(storage, key); + return false; + } + + const tryWrite = (): boolean => { + try { + storage.setItem(key, value); + return true; + } catch { + return false; + } + }; + + if (tryWrite()) return true; + removeStorageKey(storage, key); + if (tryWrite()) return true; + reclaimScopedStorageQuota({ keepProjectId: projectId }); + return tryWrite(); } export function removeScopedItem(baseKey: string, projectId?: string): void { @@ -88,5 +190,9 @@ export function removeScopedItem(baseKey: string, projectId?: string): void { return; } - removeItem.call(window.localStorage, scopedKey(baseKey, projectId)); + try { + removeItem.call(window.localStorage, scopedKey(baseKey, projectId)); + } catch { + // Storage removal is also optional restoration cleanup. + } }