fix: reject instance-served responses that outlive their instance
MCP status and skills were cleared on a switch but their in-flight requests were not, so a response for the previous instance could still write itself over the new one's — the same race the quota store already guards. Both now carry a generation. Usage also claimed an instance as loaded before it had answered, so a load that failed on a cold or briefly unreachable instance was never attempted again, and the previous instance's display mode and provider selection survived a switch — which decided what the new instance was even asked for.
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
import type { McpStatus } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { McpStatusMap } from './useMcpStore';
|
||||||
|
|
||||||
|
type Deferred<T> = { promise: Promise<T>; resolve: (value: T) => void };
|
||||||
|
const deferred = <T>(): Deferred<T> => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((res) => { resolve = res; });
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpStatusResult = Awaited<ReturnType<ReturnType<typeof opencodeModule.opencodeClient.getApiClient>['mcp']['status']>>;
|
||||||
|
let mcpStatusResponse: Deferred<McpStatusResult> = deferred();
|
||||||
|
const opencodeModule = await import('@/lib/opencode/client');
|
||||||
|
// Derived from the real client rather than spread from it: the client is a
|
||||||
|
// class instance, so a spread drops every prototype method the other modules
|
||||||
|
// loaded in this process call at import time.
|
||||||
|
// SAFETY: `Object.create` returns `any`; the object delegates to the real
|
||||||
|
// client for everything the two overrides below do not define.
|
||||||
|
const opencodeClientStub = Object.create(opencodeModule.opencodeClient) as typeof opencodeModule.opencodeClient;
|
||||||
|
// The SDK client is derived the same way, so only `mcp.status` is replaced and
|
||||||
|
// every other endpoint keeps its real implementation and type.
|
||||||
|
type McpApiClient = ReturnType<typeof opencodeModule.opencodeClient.getApiClient>;
|
||||||
|
const realApiClient = opencodeModule.opencodeClient.getApiClient();
|
||||||
|
const mcpApiStub: McpApiClient = Object.create(realApiClient, {
|
||||||
|
mcp: { value: { ...realApiClient.mcp, status: () => mcpStatusResponse.promise } },
|
||||||
|
});
|
||||||
|
opencodeClientStub.getApiClient = () => mcpApiStub;
|
||||||
|
opencodeClientStub.getScopedApiClient = () => mcpApiStub;
|
||||||
|
mock.module('@/lib/opencode/client', () => ({ ...opencodeModule, opencodeClient: opencodeClientStub }));
|
||||||
|
|
||||||
|
let skillsResponse: Deferred<Response> = deferred();
|
||||||
|
const runtimeFetchModule = await import('@/lib/runtime-fetch');
|
||||||
|
mock.module('@/lib/runtime-fetch', () => ({
|
||||||
|
...runtimeFetchModule,
|
||||||
|
runtimeFetch: () => skillsResponse.promise,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useMcpStore } = await import('./useMcpStore');
|
||||||
|
const { useSkillsStore } = await import('./useSkillsStore');
|
||||||
|
|
||||||
|
const mcpStatusResult = (data: McpStatusMap): McpStatusResult => ({
|
||||||
|
data,
|
||||||
|
request: new Request('http://localhost/mcp'),
|
||||||
|
response: new Response(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectedServer = (name: string): McpStatusMap => ({
|
||||||
|
// SAFETY: the store only reads `status` off each entry; the SDK type carries
|
||||||
|
// fields no consumer in this test path touches.
|
||||||
|
[name]: { status: 'connected' } as McpStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('instance-scoped stores reject responses from the previous instance', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mcpStatusResponse = deferred();
|
||||||
|
skillsResponse = deferred();
|
||||||
|
useMcpStore.getState().resetForRuntimeSwitch();
|
||||||
|
useSkillsStore.getState().resetForRuntimeSwitch();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an MCP status in flight during a switch does not land in the new instance', async () => {
|
||||||
|
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
||||||
|
|
||||||
|
useMcpStore.getState().resetForRuntimeSwitch();
|
||||||
|
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('from-instance-a')));
|
||||||
|
await refresh;
|
||||||
|
|
||||||
|
expect(useMcpStore.getState().getStatusForDirectory('/repo')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an MCP status that arrives with no switch is stored', async () => {
|
||||||
|
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
||||||
|
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('server-a')));
|
||||||
|
await refresh;
|
||||||
|
|
||||||
|
expect(Object.keys(useMcpStore.getState().getStatusForDirectory('/repo'))).toEqual(['server-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a skills load in flight during a switch does not land in the new instance', async () => {
|
||||||
|
const load = useSkillsStore.getState().loadSkills('/repo');
|
||||||
|
|
||||||
|
useSkillsStore.getState().resetForRuntimeSwitch();
|
||||||
|
skillsResponse.resolve(new Response(
|
||||||
|
JSON.stringify({ skills: [{ name: 'from-instance-a', path: '/repo/.agents/skills/a/SKILL.md' }] }),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
));
|
||||||
|
await load;
|
||||||
|
|
||||||
|
expect(useSkillsStore.getState().skillsByDirectory['/repo']).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,6 +54,10 @@ type RefreshOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureFreshInFlight = new Map<string, Promise<void>>();
|
const ensureFreshInFlight = new Map<string, Promise<void>>();
|
||||||
|
// Bumped on every runtime switch. Status is keyed by directory alone and two
|
||||||
|
// instances can hold the same project path, so a request already in flight for
|
||||||
|
// the previous instance would otherwise write its servers over the new one's.
|
||||||
|
let mcpGeneration = 0;
|
||||||
|
|
||||||
type TestConnectionResult = {
|
type TestConnectionResult = {
|
||||||
status?: McpStatus;
|
status?: McpStatus;
|
||||||
@@ -108,6 +112,7 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
refreshedAtKeys: {},
|
refreshedAtKeys: {},
|
||||||
|
|
||||||
resetForRuntimeSwitch: () => {
|
resetForRuntimeSwitch: () => {
|
||||||
|
mcpGeneration += 1;
|
||||||
ensureFreshInFlight.clear();
|
ensureFreshInFlight.clear();
|
||||||
set({
|
set({
|
||||||
byDirectory: {},
|
byDirectory: {},
|
||||||
@@ -144,9 +149,11 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generation = mcpGeneration;
|
||||||
try {
|
try {
|
||||||
const api = getMcpApiClient(directory);
|
const api = getMcpApiClient(directory);
|
||||||
const result = await api.mcp.status();
|
const result = await api.mcp.status();
|
||||||
|
if (generation !== mcpGeneration) return;
|
||||||
const data = (result.data ?? {}) as McpStatusMap;
|
const data = (result.data ?? {}) as McpStatusMap;
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -162,6 +169,7 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
|
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== mcpGeneration) return;
|
||||||
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
|
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
loadingKeys: { ...state.loadingKeys, [key]: false },
|
loadingKeys: { ...state.loadingKeys, [key]: false },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ let isInitialized = true
|
|||||||
const fetched: string[] = []
|
const fetched: string[] = []
|
||||||
|
|
||||||
type StubPayload = { usageDropdownProviders: string[] } | ProviderResult
|
type StubPayload = { usageDropdownProviders: string[] } | ProviderResult
|
||||||
|
let quotaRequestsFail = false;
|
||||||
const json = (body: StubPayload) => new Response(
|
const json = (body: StubPayload) => new Response(
|
||||||
JSON.stringify(body),
|
JSON.stringify(body),
|
||||||
{ status: 200, headers: { "content-type": "application/json" } },
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
@@ -22,6 +23,7 @@ mock.module("@/lib/runtime-fetch", () => ({
|
|||||||
...runtimeFetchModule,
|
...runtimeFetchModule,
|
||||||
runtimeFetch: async (path: string) => {
|
runtimeFetch: async (path: string) => {
|
||||||
fetched.push(path)
|
fetched.push(path)
|
||||||
|
if (quotaRequestsFail) throw new Error("network down")
|
||||||
if (path.startsWith("/api/config/settings")) return json({ usageDropdownProviders: ["claude"] })
|
if (path.startsWith("/api/config/settings")) return json({ usageDropdownProviders: ["claude"] })
|
||||||
return json({ providerId: "claude", providerName: "Claude", ok: true, configured: true, usage: null, fetchedAt: 1 })
|
return json({ providerId: "claude", providerName: "Claude", ok: true, configured: true, usage: null, fetchedAt: 1 })
|
||||||
},
|
},
|
||||||
@@ -40,6 +42,7 @@ describe("Usage quotas are loaded once per ready instance", () => {
|
|||||||
runtimeKey = "url:https://instance-a"
|
runtimeKey = "url:https://instance-a"
|
||||||
isInitialized = true
|
isInitialized = true
|
||||||
fetched.length = 0
|
fetched.length = 0
|
||||||
|
quotaRequestsFail = false
|
||||||
useQuotaStore.getState().resetForRuntimeSwitch()
|
useQuotaStore.getState().resetForRuntimeSwitch()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,4 +100,40 @@ describe("Usage quotas are loaded once per ready instance", () => {
|
|||||||
|
|
||||||
expect(fetched).toHaveLength(0)
|
expect(fetched).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("a failed load is not recorded as loaded, so the next ask retries it", async () => {
|
||||||
|
quotaRequestsFail = true
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull()
|
||||||
|
|
||||||
|
quotaRequestsFail = false
|
||||||
|
fetched.length = 0
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
|
||||||
|
expect(fetched.length).toBeGreaterThan(0)
|
||||||
|
expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("concurrent asks share one load", async () => {
|
||||||
|
await Promise.all([
|
||||||
|
useQuotaStore.getState().ensureLoadedForRuntime(),
|
||||||
|
useQuotaStore.getState().ensureLoadedForRuntime(),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(fetched.filter((path) => path.startsWith("/api/quota/"))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a switch drops the previous instance's display settings", async () => {
|
||||||
|
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||||
|
expect(useQuotaStore.getState().dropdownProviderIds).toEqual(["claude"])
|
||||||
|
useQuotaStore.getState().setDisplayMode("remaining")
|
||||||
|
|
||||||
|
useQuotaStore.getState().resetForRuntimeSwitch()
|
||||||
|
|
||||||
|
// `dropdownProviderIds` decides which providers get queried, so carrying it
|
||||||
|
// over would ask the new instance through the old one's selection.
|
||||||
|
expect(useQuotaStore.getState().dropdownProviderIds.length).toBeGreaterThan(1)
|
||||||
|
expect(useQuotaStore.getState().displayMode).toBe("usage")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
|
|||||||
// instance, so both belong to that instance. Bumped on every reset so a
|
// instance, so both belong to that instance. Bumped on every reset so a
|
||||||
// response in flight for the previous instance cannot land in the new one.
|
// response in flight for the previous instance cannot land in the new one.
|
||||||
let quotaGeneration = 0;
|
let quotaGeneration = 0;
|
||||||
|
let inFlightRuntimeLoad: Promise<void> | null = null;
|
||||||
let quotaAutoRefreshConsumers = 0;
|
let quotaAutoRefreshConsumers = 0;
|
||||||
let quotaAutoRefreshInterval: number | null = null;
|
let quotaAutoRefreshInterval: number | null = null;
|
||||||
|
|
||||||
@@ -38,8 +39,10 @@ interface QuotaStore extends QuotaSettingsState {
|
|||||||
|
|
||||||
loadSettings: () => Promise<void>;
|
loadSettings: () => Promise<void>;
|
||||||
fetchAllQuotas: () => Promise<void>;
|
fetchAllQuotas: () => Promise<void>;
|
||||||
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<void>;
|
/** Resolves true when at least one provider answered — see `ensureLoadedForRuntime`. */
|
||||||
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<void>;
|
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<boolean>;
|
||||||
|
/** Resolves true when the instance answered, false on a transport failure. */
|
||||||
|
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<boolean>;
|
||||||
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
|
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
|
||||||
setDisplayMode: (mode: 'usage' | 'remaining') => void;
|
setDisplayMode: (mode: 'usage' | 'remaining') => void;
|
||||||
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
|
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
|
||||||
@@ -104,6 +107,13 @@ const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultQuotaSettings = (): QuotaSettingsState => ({
|
||||||
|
displayMode: 'usage',
|
||||||
|
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
||||||
|
selectedModels: {},
|
||||||
|
expandedFamilies: {},
|
||||||
|
});
|
||||||
|
|
||||||
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||||
if (runtimeSettings) {
|
if (runtimeSettings) {
|
||||||
@@ -127,12 +137,7 @@ const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return defaultQuotaSettings();
|
||||||
displayMode: 'usage',
|
|
||||||
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
|
||||||
selectedModels: {},
|
|
||||||
expandedFamilies: {},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useQuotaStore = create<QuotaStore>()(
|
export const useQuotaStore = create<QuotaStore>()(
|
||||||
@@ -165,18 +170,20 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
const generation = quotaGeneration;
|
const generation = quotaGeneration;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
const answered = await Promise.all(
|
||||||
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
|
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
|
||||||
);
|
);
|
||||||
if (generation !== quotaGeneration) return;
|
if (generation !== quotaGeneration) return false;
|
||||||
set({
|
set({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastUpdated: Date.now()
|
lastUpdated: Date.now()
|
||||||
});
|
});
|
||||||
|
return answered.some(Boolean);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== quotaGeneration) return;
|
if (generation !== quotaGeneration) return false;
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
|
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
|
||||||
set({ isLoading: false, error: message });
|
set({ isLoading: false, error: message });
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -196,15 +203,16 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
throw new Error(payload?.error || 'Failed to fetch quota');
|
throw new Error(payload?.error || 'Failed to fetch quota');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (generation !== quotaGeneration) return;
|
if (generation !== quotaGeneration) return false;
|
||||||
const result = payload as ProviderResult;
|
const result = payload as ProviderResult;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next = state.results.filter((entry) => entry.providerId !== providerId);
|
const next = state.results.filter((entry) => entry.providerId !== providerId);
|
||||||
next.push(result);
|
next.push(result);
|
||||||
return { results: next, error: null };
|
return { results: next, error: null };
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== quotaGeneration) return;
|
if (generation !== quotaGeneration) return false;
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
|
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
|
||||||
const fallback: ProviderResult = {
|
const fallback: ProviderResult = {
|
||||||
providerId,
|
providerId,
|
||||||
@@ -220,6 +228,7 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
next.push(fallback);
|
next.push(fallback);
|
||||||
return { results: next, error: message };
|
return { results: next, error: message };
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === quotaGeneration) {
|
if (generation === quotaGeneration) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -237,18 +246,34 @@ export const useQuotaStore = create<QuotaStore>()(
|
|||||||
// then cached as if it were the answer.
|
// then cached as if it were the answer.
|
||||||
if (!useConfigStore.getState().isInitialized) return;
|
if (!useConfigStore.getState().isInitialized) return;
|
||||||
if (get().loadedRuntimeKey === runtimeKey) return;
|
if (get().loadedRuntimeKey === runtimeKey) return;
|
||||||
set({ loadedRuntimeKey: runtimeKey });
|
if (inFlightRuntimeLoad) return inFlightRuntimeLoad;
|
||||||
|
|
||||||
const generation = quotaGeneration;
|
const generation = quotaGeneration;
|
||||||
await get().loadSettings();
|
inFlightRuntimeLoad = (async () => {
|
||||||
if (generation !== quotaGeneration) return;
|
await get().loadSettings();
|
||||||
const { dropdownProviderIds, fetchQuotas } = get();
|
if (generation !== quotaGeneration) return;
|
||||||
if (dropdownProviderIds.length === 0) return;
|
const { dropdownProviderIds, fetchQuotas } = get();
|
||||||
await fetchQuotas(dropdownProviderIds);
|
if (dropdownProviderIds.length === 0) return;
|
||||||
|
const answered = await fetchQuotas(dropdownProviderIds);
|
||||||
|
// Mark the instance loaded only once it actually answered. Claiming it
|
||||||
|
// up front meant a load that failed on a cold or briefly unreachable
|
||||||
|
// instance was never attempted again — Usage would stay empty until
|
||||||
|
// the three-minute refresh, or forever after a switch.
|
||||||
|
if (answered && generation === quotaGeneration) set({ loadedRuntimeKey: runtimeKey });
|
||||||
|
})().finally(() => { inFlightRuntimeLoad = null; });
|
||||||
|
|
||||||
|
return inFlightRuntimeLoad;
|
||||||
},
|
},
|
||||||
|
|
||||||
resetForRuntimeSwitch: () => {
|
resetForRuntimeSwitch: () => {
|
||||||
quotaGeneration += 1;
|
quotaGeneration += 1;
|
||||||
|
inFlightRuntimeLoad = null;
|
||||||
set({
|
set({
|
||||||
|
// Display mode, the provider selection and the per-provider model
|
||||||
|
// picks all come from the instance's own settings, and
|
||||||
|
// `dropdownProviderIds` decides what gets fetched — carrying them
|
||||||
|
// over would query the new instance through the old one's choices.
|
||||||
|
...defaultQuotaSettings(),
|
||||||
results: [],
|
results: [],
|
||||||
loadedRuntimeKey: null,
|
loadedRuntimeKey: null,
|
||||||
selectedProviderId: null,
|
selectedProviderId: null,
|
||||||
|
|||||||
@@ -198,6 +198,10 @@ const SKILLS_LOAD_CACHE_TTL_MS = 5000;
|
|||||||
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
|
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
|
||||||
const skillsLastLoadedAt = new Map<string, number>();
|
const skillsLastLoadedAt = new Map<string, number>();
|
||||||
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
|
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
|
||||||
|
// Bumped on every runtime switch. Skills are discovered on the connected
|
||||||
|
// instance and cached by directory, which two instances can share, so a load
|
||||||
|
// already in flight for the previous instance must not write into the new one.
|
||||||
|
let skillsGeneration = 0;
|
||||||
|
|
||||||
const getSkillsCacheKey = (directory: string | null): string => {
|
const getSkillsCacheKey = (directory: string | null): string => {
|
||||||
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
||||||
@@ -286,6 +290,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
skillDraft: null,
|
skillDraft: null,
|
||||||
|
|
||||||
resetForRuntimeSwitch: () => {
|
resetForRuntimeSwitch: () => {
|
||||||
|
skillsGeneration += 1;
|
||||||
skillsLastLoadedAt.clear();
|
skillsLastLoadedAt.clear();
|
||||||
skillsLoadInFlight.clear();
|
skillsLoadInFlight.clear();
|
||||||
set({ skills: [], skillsByDirectory: {}, isLoading: false });
|
set({ skills: [], skillsByDirectory: {}, isLoading: false });
|
||||||
@@ -316,6 +321,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
return inFlight;
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generation = skillsGeneration;
|
||||||
const request = (async () => {
|
const request = (async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
// Failure must never look like an empty project. The mirror is the
|
// Failure must never look like an empty project. The mirror is the
|
||||||
@@ -361,6 +367,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
data.externalSkills ?? null,
|
data.externalSkills ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (generation !== skillsGeneration) return false;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next: Partial<SkillsStore> = {
|
const next: Partial<SkillsStore> = {
|
||||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
|
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
|
||||||
@@ -379,6 +386,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.error("Failed to load skills:", lastError);
|
console.error("Failed to load skills:", lastError);
|
||||||
|
if (generation !== skillsGeneration) return false;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const next: Partial<SkillsStore> = {
|
const next: Partial<SkillsStore> = {
|
||||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
|
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
|
||||||
|
|||||||
Reference in New Issue
Block a user