Preserve app-server metadata snapshots atomically

This commit is contained in:
murashit 2026-07-10 12:09:22 +09:00
parent ca62b40929
commit 2ca7dc42d2
2 changed files with 67 additions and 21 deletions

View file

@ -183,23 +183,14 @@ export class AppServerQueryCache {
this.client.invalidateQueries({ queryKey: appServerModelsQueryKey(refreshContext) }),
]);
const metadata = await this.client.fetchQuery(this.appServerMetadataQueryOptions(refreshContext, options));
return this.writeAppServerMetadata(refreshContext, metadata);
return cloneSharedServerMetadata(metadata);
}
writeAppServerMetadata(context: AppServerQueryContext, metadata: SharedServerMetadata): SharedServerMetadata | null {
if (!appServerQueryContextIsComplete(context)) return null;
const previous = this.appServerMetadataSnapshot(context);
const probes = metadata.serverDiagnostics.probes;
const next = cloneSharedServerMetadata({
...metadata,
availableModels: probes.models.status === "ok" ? metadata.availableModels : (this.modelsSnapshot(context) ?? []),
availableSkills: probes.skills.status === "ok" ? metadata.availableSkills : (previous?.availableSkills ?? []),
availablePermissionProfiles:
probes.permissionProfiles.status === "ok" ? metadata.availablePermissionProfiles : (previous?.availablePermissionProfiles ?? []),
rateLimit: probes.rateLimits.status === "ok" ? metadata.rateLimit : (previous?.rateLimit ?? null),
});
const next = metadataWithLastKnownGood(metadata, this.appServerMetadataSnapshot(context), this.modelsSnapshot(context));
this.client.setQueryData(appServerMetadataQueryKey(context), cloneSharedServerMetadata(next));
if (probes.models.status === "ok") {
if (metadata.serverDiagnostics.probes.models.status === "ok") {
this.client.setQueryData(appServerModelsQueryKey(context), cloneModelMetadata(next.availableModels));
}
return cloneSharedServerMetadata(next);
@ -273,6 +264,7 @@ export class AppServerQueryCache {
return {
queryKey: appServerMetadataQueryKey(refreshContext),
queryFn: async (): Promise<SharedServerMetadata> => {
const previous = this.appServerMetadataSnapshot(refreshContext);
return this.runWithClient(refreshContext, async (client) => {
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, refreshContext.vaultPath));
const [models, skills, permissionProfiles, rateLimit] = await Promise.all([
@ -283,16 +275,20 @@ export class AppServerQueryCache {
]);
const diagnostics = [models.probe, skills.probe, permissionProfiles.probe, rateLimit.probe].reduce(
(current, probe) => diagnosticsWithProbe(current, probe),
this.appServerMetadataSnapshot(refreshContext)?.serverDiagnostics ?? createServerDiagnostics(),
previous?.serverDiagnostics ?? createServerDiagnostics(),
);
return metadataWithLastKnownGood(
{
runtimeConfig,
availableModels: models.value,
availableSkills: skills.value,
availablePermissionProfiles: permissionProfiles.value,
rateLimit: rateLimit.value,
serverDiagnostics: diagnostics,
},
previous,
this.modelsSnapshot(refreshContext),
);
return {
runtimeConfig,
availableModels: models.value,
availableSkills: skills.value,
availablePermissionProfiles: permissionProfiles.value,
rateLimit: rateLimit.value,
serverDiagnostics: diagnostics,
};
});
},
staleTime: APP_SERVER_METADATA_STALE_TIME_MS,
@ -381,6 +377,22 @@ export class AppServerQueryCache {
}
}
function metadataWithLastKnownGood(
metadata: SharedServerMetadata,
previous: SharedServerMetadata | null,
models: readonly ModelMetadata[] | null,
): SharedServerMetadata {
const probes = metadata.serverDiagnostics.probes;
return cloneSharedServerMetadata({
...metadata,
availableModels: probes.models.status === "ok" ? metadata.availableModels : (models ?? previous?.availableModels ?? []),
availableSkills: probes.skills.status === "ok" ? metadata.availableSkills : (previous?.availableSkills ?? []),
availablePermissionProfiles:
probes.permissionProfiles.status === "ok" ? metadata.availablePermissionProfiles : (previous?.availablePermissionProfiles ?? []),
rateLimit: probes.rateLimits.status === "ok" ? metadata.rateLimit : (previous?.rateLimit ?? null),
});
}
function createAppServerQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {

View file

@ -303,6 +303,40 @@ describe("AppServerQueryCache", () => {
expect(cache.modelsSnapshot(context)?.map((model) => model.model)).toEqual(["gpt-cached"]);
});
it("keeps every last-known-good resource through the full metadata refresh path", async () => {
const context = cacheContext();
const cache = cacheWithRequestHandlers({
"config/read": vi.fn().mockResolvedValue({}),
"model/list": vi.fn().mockRejectedValue(new Error("models offline")),
"skills/list": vi.fn().mockRejectedValue(new Error("skills offline")),
"permissionProfile/list": vi.fn().mockRejectedValue(new Error("profiles offline")),
"account/rateLimits/read": vi.fn().mockRejectedValue(new Error("limits offline")),
});
cache.writeAppServerMetadata(
context,
metadata({
availableModels: [modelMetadata("gpt-cached")],
availableSkills: [skillMetadata("cached-skill")],
availablePermissionProfiles: [permissionProfile(":cached")],
rateLimit: rateLimit(17),
}),
);
const refreshed = await cache.refreshAppServerMetadata(context);
expect(refreshed?.availableModels.map((model) => model.model)).toEqual(["gpt-cached"]);
expect(refreshed?.availableSkills.map((skill) => skill.name)).toEqual(["cached-skill"]);
expect(refreshed?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":cached"]);
expect(refreshed?.rateLimit?.primary?.usedPercent).toBe(17);
expect(refreshed?.serverDiagnostics.probes).toMatchObject({
models: { status: "failed" },
skills: { status: "failed" },
permissionProfiles: { status: "failed" },
rateLimits: { status: "failed" },
});
expect(cache.appServerMetadataSnapshot(context)).toEqual(refreshed);
});
it("clears thread list snapshots by context", () => {
const cache = new AppServerQueryCache();
const context = cacheContext();