Adds a shared single-flight cache refresh so newly enabled Grok/Cursor CLI providers show their models in pickers immediately, instead of requiring a Settings reopen. - useModelsCache now exposes a shared refreshModelsCache() that clears the SWR MODELS cache key and notifies subscribers - AuthenticationSection calls refreshModelsCache() after toggling cursor-cli/grok-cli/claude-cli/llama-cpp providers - Server-side cursor/grok model-cache lookups use a short negative-TTL so transient cold-start empty results self-heal instead of sticking - Adds regression tests covering the cache refresh flow, hook behavior, and cursor/grok cache TTL self-healing - Adds changeset (patch) documenting the fix Files changed: .../fn-7710-cli-provider-model-cache-refresh.md | 7 + ...thenticationSection.modelsCacheRefresh.test.tsx | 137 ++++++++++++++++++ .../settings/sections/AuthenticationSection.tsx | 32 +++-- .../app/hooks/__tests__/useModelsCache.test.ts | 159 ++++++++++++++++++++- packages/dashboard/app/hooks/useModelsCache.ts | 72 +++++++++- .../src/__tests__/cursor-model-cache.test.ts | 34 +++++ .../src/__tests__/grok-model-cache.test.ts | 33 +++++ packages/dashboard/src/cursor-model-cache.ts | 23 ++- packages/dashboard/src/grok-model-cache.ts | 23 ++- 9 files changed, 500 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7710 Fusion-Task-Lineage: ebac46ba-5b3e-41f2-acc4-26f9139c0f71 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { fetchModels, type ModelInfo, type ModelsResponse } from "../api";
|
|
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
|
|
|
|
interface ModelsCacheState {
|
|
models: ModelInfo[];
|
|
favoriteProviders: string[];
|
|
favoriteModels: string[];
|
|
defaultProvider: string | null;
|
|
defaultModelId: string | null;
|
|
}
|
|
|
|
export interface UseModelsCacheResult extends ModelsCacheState {
|
|
loading: boolean;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
const EMPTY_MODELS_STATE: ModelsCacheState = {
|
|
models: [],
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
defaultProvider: null,
|
|
defaultModelId: null,
|
|
};
|
|
|
|
let inflight: Promise<ModelsResponse> | null = null;
|
|
// Guards concurrent refreshModelsCache() callers so they share one forced
|
|
// fetch instead of each spawning its own request (see FNXC:ModelCatalog
|
|
// comment on refreshModelsCache below).
|
|
let refreshInflight: Promise<void> | null = null;
|
|
const listeners = new Set<(state: ModelsCacheState) => void>();
|
|
|
|
function toModelsCacheState(response: ModelsResponse | null | undefined): ModelsCacheState {
|
|
if (!response) {
|
|
return EMPTY_MODELS_STATE;
|
|
}
|
|
|
|
return {
|
|
models: response.models ?? [],
|
|
favoriteProviders: response.favoriteProviders ?? [],
|
|
favoriteModels: response.favoriteModels ?? [],
|
|
defaultProvider: response.defaultProvider ?? null,
|
|
defaultModelId: response.defaultModelId ?? null,
|
|
};
|
|
}
|
|
|
|
function readCachedModelsState(): ModelsCacheState | null {
|
|
const cached = readCache<ModelsResponse>(SWR_CACHE_KEYS.MODELS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
|
|
return cached ? toModelsCacheState(cached) : null;
|
|
}
|
|
|
|
function notifyListeners(state: ModelsCacheState): void {
|
|
for (const listener of listeners) {
|
|
listener(state);
|
|
}
|
|
}
|
|
|
|
async function fetchModelsShared(): Promise<ModelsResponse> {
|
|
if (!inflight) {
|
|
const promise = fetchModels();
|
|
inflight = promise;
|
|
// Only clear `inflight` if it still points at *this* promise — a forced
|
|
// refresh (see refreshModelsCache) may have already replaced it with a
|
|
// newer in-flight fetch, and this stale promise resolving later must not
|
|
// wipe out that newer reference out from under it. `.catch(() => {})`
|
|
// swallows the rejection on THIS bookkeeping branch only — the original
|
|
// `promise` returned below is untouched and still rejects for callers
|
|
// that await it, so error handling (try/catch in load()/refreshModelsCache)
|
|
// is unaffected; this just avoids a duplicate unhandled-rejection listener.
|
|
promise.then(
|
|
() => {
|
|
if (inflight === promise) inflight = null;
|
|
},
|
|
() => {
|
|
if (inflight === promise) inflight = null;
|
|
},
|
|
);
|
|
}
|
|
return inflight;
|
|
}
|
|
|
|
/**
|
|
* FNXC:ModelCatalog 2026-07-08-00:00:
|
|
* FN-7710: Enabling/disabling a CLI provider (Grok, Cursor, Claude CLI,
|
|
* llama.cpp) changes which rows `/api/models` returns, but every already-
|
|
* mounted `useModelsCache()` consumer (board Quick Entry, Task Detail, New
|
|
* Agent, Workflow editor, etc.) only re-fetches on its own mount — it has no
|
|
* way to know the shared catalog is now stale. Previously the CLI provider
|
|
* cards' `onToggled` handler only refreshed the Settings Authentication
|
|
* panel (`loadAuthStatus()`), so newly-enabled `grok-cli`/`cursor-cli`
|
|
* models stayed invisible until some unrelated Settings surface happened to
|
|
* call `fetchModels()` fresh. `refreshModelsCache()` is the shared,
|
|
* single-flight, never-throw entry point any non-hook caller (a settings
|
|
* card, a toggle handler) can invoke to force a fresh fetch, write through
|
|
* `SWR_CACHE_KEYS.MODELS`, and notify every mounted `useModelsCache`
|
|
* subscriber via the module-level `listeners` set — no remount required.
|
|
* Concurrent `refreshModelsCache()` callers share one forced fetch via
|
|
* `refreshInflight`. A failed refresh must never blank an existing good
|
|
* list: on error this simply leaves the current cache/listeners state
|
|
* untouched, so a transient network hiccup degrades to "keep showing the
|
|
* last good list", not an empty picker.
|
|
*/
|
|
export async function refreshModelsCache(): Promise<void> {
|
|
if (refreshInflight) {
|
|
return refreshInflight;
|
|
}
|
|
|
|
refreshInflight = (async () => {
|
|
try {
|
|
// Force a fresh fetch: drop any pre-toggle in-flight promise so a
|
|
// response requested before the provider toggle took effect is never
|
|
// mistaken for the post-toggle catalog.
|
|
inflight = null;
|
|
const response = await fetchModelsShared();
|
|
const nextState = toModelsCacheState(response);
|
|
writeCache(SWR_CACHE_KEYS.MODELS, response, { maxBytes: 500_000 });
|
|
notifyListeners(nextState);
|
|
} catch {
|
|
// Never throw, never blank an existing good list — leave cache/listeners
|
|
// state untouched on failure (see FNXC:ModelCatalog comment above).
|
|
}
|
|
})().finally(() => {
|
|
refreshInflight = null;
|
|
});
|
|
|
|
return refreshInflight;
|
|
}
|
|
|
|
export function useModelsCache(): UseModelsCacheResult {
|
|
const cachedState = readCachedModelsState();
|
|
const [state, setState] = useState<ModelsCacheState>(() => cachedState ?? EMPTY_MODELS_STATE);
|
|
const [loading, setLoading] = useState(() => cachedState === null);
|
|
const hasCachedStateRef = useRef(cachedState !== null);
|
|
|
|
useEffect(() => {
|
|
listeners.add(setState);
|
|
return () => {
|
|
listeners.delete(setState);
|
|
};
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const response = await fetchModelsShared();
|
|
const nextState = toModelsCacheState(response);
|
|
hasCachedStateRef.current = true;
|
|
writeCache(SWR_CACHE_KEYS.MODELS, response, { maxBytes: 500_000 });
|
|
notifyListeners(nextState);
|
|
} catch {
|
|
if (!hasCachedStateRef.current) {
|
|
clearCache(SWR_CACHE_KEYS.MODELS);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true);
|
|
await load();
|
|
}, [load]);
|
|
|
|
return {
|
|
...state,
|
|
loading,
|
|
refresh,
|
|
};
|
|
}
|