feat(FN-1771): add dashboard memory settings UX with backend selector and capability-aware editing
- Add /api/memory/backend endpoint returning current backend, capabilities, and available backends - Create useMemoryBackendStatus hook with polling and frontend-friendly interface - Add MemorySettingsSection to SettingsModal with backend selector dropdown - Implement capability-aware UI: readonly backends disable save, fallback on unknown types - Add comprehensive tests for API endpoints and hook behavior - Update architecture docs with memory backend architecture section
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
||||
fetchAgentRunAudit,
|
||||
fetchAgentRunTimeline,
|
||||
streamChatResponse,
|
||||
fetchMemoryBackendStatus,
|
||||
type ProjectInfo,
|
||||
type ProjectHealth,
|
||||
type ActivityFeedEntry,
|
||||
@@ -3341,3 +3342,159 @@ describe("streamChatResponse", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchMemoryBackendStatus", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const mockBackendStatus = {
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("fetches memory backend status without projectId", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve(mockBackendStatus),
|
||||
text: () => Promise.resolve(JSON.stringify(mockBackendStatus)),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await fetchMemoryBackendStatus();
|
||||
|
||||
expect(result).toEqual(mockBackendStatus);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toContain("/api/memory/backend");
|
||||
});
|
||||
|
||||
it("fetches memory backend status with projectId", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve(mockBackendStatus),
|
||||
text: () => Promise.resolve(JSON.stringify(mockBackendStatus)),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await fetchMemoryBackendStatus("proj_abc");
|
||||
|
||||
expect(result).toEqual(mockBackendStatus);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toContain("/api/memory/backend");
|
||||
expect(call[0]).toContain("projectId=proj_abc");
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ error: "Server error" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ error: "Server error" })),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(fetchMemoryBackendStatus()).rejects.toThrow("Server error");
|
||||
});
|
||||
|
||||
it("handles readonly backend response", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
|
||||
const readonlyStatus = {
|
||||
currentBackend: "readonly",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: false,
|
||||
supportsAtomicWrite: false,
|
||||
hasConflictResolution: false,
|
||||
persistent: false,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
};
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve(readonlyStatus),
|
||||
text: () => Promise.resolve(JSON.stringify(readonlyStatus)),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await fetchMemoryBackendStatus();
|
||||
|
||||
expect(result.currentBackend).toBe("readonly");
|
||||
expect(result.capabilities.writable).toBe(false);
|
||||
});
|
||||
|
||||
it("handles qmd backend response", async () => {
|
||||
const { fetchMemoryBackendStatus } = await import("./api");
|
||||
|
||||
const qmdStatus = {
|
||||
currentBackend: "qmd",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: false,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
};
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve(qmdStatus),
|
||||
text: () => Promise.resolve(JSON.stringify(qmdStatus)),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await fetchMemoryBackendStatus();
|
||||
|
||||
expect(result.currentBackend).toBe("qmd");
|
||||
expect(result.capabilities.writable).toBe(true);
|
||||
expect(result.capabilities.supportsAtomicWrite).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,6 +299,37 @@ export function saveMemory(content: string, projectId?: string): Promise<{ succe
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory backend capabilities returned by the backend status API.
|
||||
*/
|
||||
export interface MemoryBackendCapabilities {
|
||||
readable: boolean;
|
||||
writable: boolean;
|
||||
supportsAtomicWrite: boolean;
|
||||
hasConflictResolution: boolean;
|
||||
persistent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory backend status response from GET /api/memory/backend
|
||||
*/
|
||||
export interface MemoryBackendStatus {
|
||||
/** The effective backend type after runtime resolution */
|
||||
currentBackend: string;
|
||||
/** Capabilities of the effective backend */
|
||||
capabilities: MemoryBackendCapabilities;
|
||||
/** List of registered backend types available */
|
||||
availableBackends: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current memory backend status and capabilities.
|
||||
* Use this to determine which backend is active and what operations it supports.
|
||||
*/
|
||||
export function fetchMemoryBackendStatus(projectId?: string): Promise<MemoryBackendStatus> {
|
||||
return api<MemoryBackendStatus>(withProjectId("/memory/backend", projectId));
|
||||
}
|
||||
|
||||
/** Fetch global (user-level) settings from ~/.pi/fusion/settings.json */
|
||||
export function fetchGlobalSettings(): Promise<GlobalSettings> {
|
||||
return api<GlobalSettings>("/settings/global");
|
||||
|
||||
@@ -21,6 +21,7 @@ const mockFetchMemory = vi.fn();
|
||||
const mockSaveMemory = vi.fn();
|
||||
const mockFetchGlobalConcurrency = vi.fn();
|
||||
const mockUpdateGlobalConcurrency = vi.fn();
|
||||
const mockFetchMemoryBackendStatus = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
||||
@@ -39,6 +40,13 @@ vi.mock("../api", () => ({
|
||||
saveMemory: (...args: unknown[]) => mockSaveMemory(...args),
|
||||
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
|
||||
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
|
||||
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
|
||||
}));
|
||||
|
||||
// Mock the hook
|
||||
const mockUseMemoryBackendStatus = vi.fn();
|
||||
vi.mock("../hooks/useMemoryBackendStatus", () => ({
|
||||
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
@@ -79,6 +87,31 @@ describe("SettingsModal", () => {
|
||||
mockSaveMemory.mockResolvedValue({ success: true });
|
||||
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentUsage: 0 });
|
||||
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentUsage: 0 });
|
||||
mockFetchMemoryBackendStatus.mockResolvedValue({
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
});
|
||||
mockUseMemoryBackendStatus.mockReturnValue({
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
// jsdom doesn't provide URL.createObjectURL — polyfill it
|
||||
if (!URL.createObjectURL) {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Globe, Folder } from "lucide-react";
|
||||
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
@@ -1640,7 +1641,29 @@ export function SettingsModal({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "memory":
|
||||
case "memory": {
|
||||
// Fetch backend status when memory section is active
|
||||
const {
|
||||
currentBackend,
|
||||
capabilities,
|
||||
availableBackends,
|
||||
loading: backendLoading,
|
||||
error: backendError,
|
||||
refresh: refreshBackendStatus,
|
||||
} = useMemoryBackendStatus({ projectId });
|
||||
|
||||
// Determine if editing is allowed
|
||||
const isMemoryEnabled = form.memoryEnabled !== false;
|
||||
const isBackendWritable = capabilities?.writable ?? true;
|
||||
const isEditingAllowed = isMemoryEnabled && isBackendWritable;
|
||||
|
||||
// Backend display names
|
||||
const backendNames: Record<string, string> = {
|
||||
file: "File (.fusion/memory.md)",
|
||||
readonly: "Read-Only",
|
||||
qmd: "QMD (Quantized Memory Distillation)",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
@@ -1660,17 +1683,71 @@ export function SettingsModal({
|
||||
<small>When enabled, agents will consult and update .fusion/memory.md with durable project learnings</small>
|
||||
</div>
|
||||
|
||||
{/* Backend type selector */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="memoryBackendType">Memory Backend</label>
|
||||
<select
|
||||
id="memoryBackendType"
|
||||
value={form.memoryBackendType || "file"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
memoryBackendType: value === "file" ? undefined : value,
|
||||
}));
|
||||
}}
|
||||
disabled={!isMemoryEnabled}
|
||||
>
|
||||
{availableBackends.map((backend) => (
|
||||
<option key={backend} value={backend}>
|
||||
{backendNames[backend] || backend}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>Choose how project memory is stored. File backend uses .fusion/memory.md. QMD enables advanced memory features.</small>
|
||||
</div>
|
||||
|
||||
{/* Backend capabilities info */}
|
||||
{backendLoading ? (
|
||||
<div className="form-group">
|
||||
<small className="settings-muted">Loading backend status...</small>
|
||||
</div>
|
||||
) : backendError ? (
|
||||
<div className="form-group">
|
||||
<small className="field-error">Failed to load backend status: {backendError}</small>
|
||||
</div>
|
||||
) : currentBackend ? (
|
||||
<div className="form-group">
|
||||
<small className="settings-muted">
|
||||
Current backend: <strong>{backendNames[currentBackend] || currentBackend}</strong>
|
||||
{!isBackendWritable && " (read-only)"}
|
||||
</small>
|
||||
{isBackendWritable && capabilities && (
|
||||
<small className="settings-muted" style={{ display: "block", marginTop: "2px" }}>
|
||||
Supports: {capabilities.readable ? "read" : ""}{capabilities.readable && capabilities.writable ? ", " : ""}{capabilities.writable ? "write" : ""}{capabilities.supportsAtomicWrite ? ", atomic writes" : ""}{capabilities.persistent ? ", persistent" : ""}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
|
||||
|
||||
<div className="form-group">
|
||||
<small>This file stores durable project learnings that agents consult during triage and execution.</small>
|
||||
</div>
|
||||
|
||||
{form.memoryEnabled === false && (
|
||||
{/* Read-only state warnings */}
|
||||
{!isMemoryEnabled && (
|
||||
<div className="settings-empty-state" style={{ marginBottom: "var(--space-md)" }}>
|
||||
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
|
||||
</div>
|
||||
)}
|
||||
{isMemoryEnabled && !isBackendWritable && (
|
||||
<div className="settings-empty-state" style={{ marginBottom: "var(--space-md)" }}>
|
||||
The selected backend ({backendNames[currentBackend || "file"] || currentBackend || "file"}) is read-only.
|
||||
You can view the file, but saving is disabled. Select a writable backend (File or QMD) to enable editing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memoryLoading ? (
|
||||
<div className="settings-empty-state">Loading memory…</div>
|
||||
@@ -1692,14 +1769,14 @@ export function SettingsModal({
|
||||
setMemoryContent(content);
|
||||
setMemoryDirty(true);
|
||||
}}
|
||||
readOnly={form.memoryEnabled === false}
|
||||
readOnly={!isEditingAllowed}
|
||||
filePath=".fusion/memory.md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memoryDirty && (
|
||||
{memoryDirty && isEditingAllowed && (
|
||||
<div className="form-group">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1710,8 +1787,14 @@ export function SettingsModal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{memoryDirty && !isEditingAllowed && (
|
||||
<div className="form-group">
|
||||
<small className="field-error">Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}</small>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "backups":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -52,6 +52,34 @@ vi.mock("../../api", () => ({
|
||||
saveMemory: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentUsage: 0 })),
|
||||
updateGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentUsage: 0 })),
|
||||
fetchMemoryBackendStatus: vi.fn(() => Promise.resolve({
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
|
||||
useMemoryBackendStatus: vi.fn(() => ({
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { fetchSettings } from "../../api";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { useMemoryBackendStatus } from "../useMemoryBackendStatus";
|
||||
import * as api from "../../api";
|
||||
|
||||
describe("useMemoryBackendStatus", () => {
|
||||
const mockFetchMemoryBackendStatus = vi.spyOn(api, "fetchMemoryBackendStatus");
|
||||
|
||||
const mockFileBackendStatus: api.MemoryBackendStatus = {
|
||||
currentBackend: "file",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: true,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
};
|
||||
|
||||
const mockReadonlyBackendStatus: api.MemoryBackendStatus = {
|
||||
currentBackend: "readonly",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: false,
|
||||
supportsAtomicWrite: false,
|
||||
hasConflictResolution: false,
|
||||
persistent: false,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
};
|
||||
|
||||
const mockQmdBackendStatus: api.MemoryBackendStatus = {
|
||||
currentBackend: "qmd",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
supportsAtomicWrite: false,
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
availableBackends: ["file", "readonly", "qmd"],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchMemoryBackendStatus.mockClear();
|
||||
});
|
||||
|
||||
it("fetches status on initial mount", async () => {
|
||||
mockFetchMemoryBackendStatus.mockResolvedValue(mockFileBackendStatus);
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.status).toBeNull();
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.status).toEqual(mockFileBackendStatus);
|
||||
expect(result.current.currentBackend).toBe("file");
|
||||
expect(result.current.capabilities).toEqual(mockFileBackendStatus.capabilities);
|
||||
expect(result.current.availableBackends).toEqual(["file", "readonly", "qmd"]);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("handles fetch errors", async () => {
|
||||
mockFetchMemoryBackendStatus.mockRejectedValue(new Error("Failed to connect"));
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to connect");
|
||||
expect(result.current.status).toBeNull();
|
||||
});
|
||||
|
||||
it("provides correct capability flags for file backend", async () => {
|
||||
mockFetchMemoryBackendStatus.mockResolvedValue(mockFileBackendStatus);
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.isReadable).toBe(true);
|
||||
expect(result.current.isWritable).toBe(true);
|
||||
expect(result.current.supportsAtomicWrite).toBe(true);
|
||||
});
|
||||
|
||||
it("provides correct capability flags for readonly backend", async () => {
|
||||
mockFetchMemoryBackendStatus.mockResolvedValue(mockReadonlyBackendStatus);
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.isReadable).toBe(true);
|
||||
expect(result.current.isWritable).toBe(false);
|
||||
expect(result.current.supportsAtomicWrite).toBe(false);
|
||||
});
|
||||
|
||||
it("provides correct capability flags for qmd backend", async () => {
|
||||
mockFetchMemoryBackendStatus.mockResolvedValue(mockQmdBackendStatus);
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.isReadable).toBe(true);
|
||||
expect(result.current.isWritable).toBe(true);
|
||||
expect(result.current.supportsAtomicWrite).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes data manually", async () => {
|
||||
mockFetchMemoryBackendStatus
|
||||
.mockResolvedValueOnce(mockFileBackendStatus)
|
||||
.mockResolvedValueOnce(mockQmdBackendStatus);
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.currentBackend).toBe("file");
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
|
||||
await waitFor(() => expect(result.current.currentBackend).toBe("qmd"));
|
||||
});
|
||||
|
||||
it("clears error on successful refresh after error", async () => {
|
||||
mockFetchMemoryBackendStatus
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockFileBackendStatus);
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBe("Network error");
|
||||
|
||||
// Manual refresh
|
||||
await result.current.refresh();
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeNull());
|
||||
expect(result.current.status).toEqual(mockFileBackendStatus);
|
||||
});
|
||||
|
||||
it("passes projectId to API", async () => {
|
||||
mockFetchMemoryBackendStatus.mockResolvedValue(mockFileBackendStatus);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMemoryBackendStatus({ projectId: "proj_abc", autoRefresh: false }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(mockFetchMemoryBackendStatus).toHaveBeenCalledWith("proj_abc");
|
||||
});
|
||||
|
||||
it("returns expected default values before first fetch", () => {
|
||||
mockFetchMemoryBackendStatus.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
expect(result.current.status).toBeNull();
|
||||
expect(result.current.currentBackend).toBeNull();
|
||||
expect(result.current.capabilities).toBeNull();
|
||||
expect(result.current.availableBackends).toEqual([]);
|
||||
expect(result.current.isReadable).toBe(false);
|
||||
expect(result.current.isWritable).toBe(false);
|
||||
expect(result.current.supportsAtomicWrite).toBe(false);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.lastUpdated).toBeNull();
|
||||
expect(typeof result.current.refresh).toBe("function");
|
||||
});
|
||||
|
||||
it("exports the correct interface", () => {
|
||||
expect(typeof useMemoryBackendStatus).toBe("function");
|
||||
});
|
||||
|
||||
it("handles null error messages", async () => {
|
||||
mockFetchMemoryBackendStatus.mockRejectedValue(new Error());
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
// Error with no message results in empty string
|
||||
expect(result.current.error).toBe("");
|
||||
});
|
||||
|
||||
it("handles non-Error rejections", async () => {
|
||||
mockFetchMemoryBackendStatus.mockRejectedValue("string error");
|
||||
|
||||
const { result } = renderHook(() => useMemoryBackendStatus({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.error).toBe("Failed to fetch memory backend status");
|
||||
});
|
||||
});
|
||||
148
packages/dashboard/app/hooks/useMemoryBackendStatus.ts
Normal file
148
packages/dashboard/app/hooks/useMemoryBackendStatus.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { fetchMemoryBackendStatus, type MemoryBackendStatus } from "../api";
|
||||
|
||||
interface UseMemoryBackendStatusOptions {
|
||||
/** Project ID for multi-project contexts */
|
||||
projectId?: string;
|
||||
/** Auto-refresh interval in ms (default: 60 seconds) */
|
||||
pollInterval?: number;
|
||||
/** Whether to auto-refresh (default: false) */
|
||||
autoRefresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching memory backend status and capabilities.
|
||||
*
|
||||
* Features:
|
||||
* - Fetches backend status on mount
|
||||
* - Optional auto-refresh polling
|
||||
* - Manual refresh capability
|
||||
* - Loading and error states
|
||||
* - Guards against stale async updates on unmount/re-render
|
||||
* - Respects project context via optional projectId
|
||||
*/
|
||||
export function useMemoryBackendStatus(options: UseMemoryBackendStatusOptions = {}) {
|
||||
const { projectId, pollInterval = 60_000, autoRefresh = false } = options;
|
||||
|
||||
const [status, setStatus] = useState<MemoryBackendStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Track if the component is still mounted to prevent stale updates
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const fetchStatus = useCallback(async (isManual = false) => {
|
||||
// Cancel any in-flight request
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
if (isManual) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchMemoryBackendStatus(projectId);
|
||||
|
||||
// Guard against stale updates when component unmounts or project changes
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
setLastUpdated(new Date());
|
||||
setLoading(false);
|
||||
} catch (err: unknown) {
|
||||
// Don't update state if the request was aborted
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard against stale updates
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch memory backend status";
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
fetchStatus(false);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Auto-refresh polling
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
pollRef.current = setInterval(() => {
|
||||
fetchStatus(false);
|
||||
}, pollInterval);
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, pollInterval, fetchStatus]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return fetchStatus(true);
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Derived convenience getters
|
||||
const currentBackend = status?.currentBackend ?? null;
|
||||
const capabilities = status?.capabilities ?? null;
|
||||
const availableBackends = status?.availableBackends ?? [];
|
||||
|
||||
const isReadable = capabilities?.readable ?? false;
|
||||
const isWritable = capabilities?.writable ?? false;
|
||||
const supportsAtomicWrite = capabilities?.supportsAtomicWrite ?? false;
|
||||
|
||||
return {
|
||||
// Raw status
|
||||
status,
|
||||
// Convenience getters
|
||||
currentBackend,
|
||||
capabilities,
|
||||
availableBackends,
|
||||
isReadable,
|
||||
isWritable,
|
||||
supportsAtomicWrite,
|
||||
// State
|
||||
loading,
|
||||
error,
|
||||
lastUpdated,
|
||||
// Actions
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user