Skip to content

Commit 8145616

Browse files
fix(stats): store dashboard cache beside settings
The previous dashboard cache lived in browser localStorage, which missed the desktop settings path the app actually uses. Store cached stats next to settings.json so the app can render cached values immediately, keep the loading scaffold, and refresh in the background. Co-Authored-By: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
1 parent 2852f6c commit 8145616

12 files changed

Lines changed: 544 additions & 119 deletions

File tree

apps/desktop/src/bun/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
getUpdateSettingsHandler,
2525
getVersionHandler,
2626
isFirstLaunchHandler,
27+
refreshStatsHandler,
2728
resetSettingsHandler,
2829
searchSessionsHandler,
2930
updateGeneralSettingsHandler,
@@ -154,11 +155,11 @@ const win = new BrowserWindow({
154155
rpc: rpc,
155156
});
156157

157-
const pushStatsUpdate = getStatsHandler.pipe(
158+
const pushStatsUpdate = refreshStatsHandler.pipe(
158159
Effect.flatMap((result) =>
159160
Effect.try({
160161
try: () => {
161-
win.webview.rpc?.send.statsUpdated(result);
162+
win.webview.rpc?.send.statsUpdated({ stats: result.stats });
162163
},
163164
catch: () => undefined,
164165
}).pipe(Effect.ignore),

apps/desktop/src/bun/rpc-handlers.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import {
1818
updatePluginSetting as updatePluginSettingEffect,
1919
updateUpdateSettings as updateUpdateSettingsEffect,
2020
} from "@cookielab.io/klovi-server/services/settings-service";
21-
import { getStats as getStatsEffect } from "@cookielab.io/klovi-server/services/stats-service";
21+
import {
22+
getStats as getStatsEffect,
23+
invalidateStatsCache,
24+
refreshStats as refreshStatsEffect,
25+
} from "@cookielab.io/klovi-server/services/stats-service";
2226
import { getVersion } from "@cookielab.io/klovi-server/services/version-service";
2327
import { Effect, Ref } from "effect";
2428
import type { UpdateChannel } from "../shared/rpc-types.ts";
@@ -52,8 +56,15 @@ const currentRegistry = Effect.gen(function* () {
5256
});
5357

5458
const getStatsHandler = Effect.gen(function* () {
59+
const { path } = yield* SettingsPathRef;
60+
const registry = yield* currentRegistry;
61+
return yield* getStatsEffect(path, registry);
62+
});
63+
64+
const refreshStatsHandler = Effect.gen(function* () {
65+
const { path } = yield* SettingsPathRef;
5566
const registry = yield* currentRegistry;
56-
return yield* getStatsEffect(registry);
67+
return yield* refreshStatsEffect(path, registry);
5768
});
5869

5970
const getProjectsHandler = Effect.gen(function* () {
@@ -96,6 +107,7 @@ const updatePluginSettingHandler = (params: { pluginId: string; enabled?: boolea
96107
const { path } = yield* SettingsPathRef;
97108
const result = yield* updatePluginSettingEffect(path, params);
98109
yield* refreshRegistry;
110+
yield* invalidateStatsCache(path);
99111
return result;
100112
});
101113

@@ -114,6 +126,7 @@ const resetSettingsHandler = Effect.gen(function* () {
114126
const { path } = yield* SettingsPathRef;
115127
const result = yield* resetSettingsEffect(path);
116128
yield* refreshRegistry;
129+
yield* invalidateStatsCache(path);
117130
return result;
118131
});
119132

@@ -160,6 +173,7 @@ export {
160173
getUpdateSettingsHandler,
161174
getVersionHandler,
162175
isFirstLaunchHandler,
176+
refreshStatsHandler,
163177
resetSettingsHandler,
164178
searchSessionsHandler,
165179
updateGeneralSettingsHandler,

packages/server/src/effect/server-services.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type {
2-
DashboardStats,
32
GlobalSessionResult,
43
RegistryRequirements,
54
Session,
@@ -32,14 +31,14 @@ import {
3231
updatePluginSetting,
3332
updateUpdateSettings,
3433
} from "../services/settings-service.ts";
35-
import { getStats } from "../services/stats-service.ts";
34+
import { getStats, invalidateStatsCache, type StatsResponse } from "../services/stats-service.ts";
3635
import { getVersion, makeVersionState, type VersionInfo } from "../services/version-service.ts";
3736
import { ServerConfig } from "./server-config.ts";
3837

3938
export type KloviServicesShape = {
4039
readonly acceptRisks: () => Effect.Effect<{ ok: boolean }, SettingsWriteError, FileSystem.FileSystem>;
4140
readonly getVersion: () => Effect.Effect<VersionInfo>;
42-
readonly getStats: () => Effect.Effect<{ stats: DashboardStats }, never, RegistryRequirements>;
41+
readonly getStats: () => Effect.Effect<StatsResponse, never, RegistryRequirements>;
4342
readonly getProjects: () => Effect.Effect<{ projects: MergedProject[] }, never, RegistryRequirements>;
4443
readonly getSessions: (params: {
4544
encodedPath: string;
@@ -104,7 +103,7 @@ export const KloviServicesLive = Layer.effect(
104103
return {
105104
acceptRisks: () => completeOnboarding(settingsPath),
106105
getVersion: () => Effect.succeed(getVersion(versionState)),
107-
getStats: () => getStats(registry),
106+
getStats: () => getStats(settingsPath, registry),
108107
getProjects: () => getProjects(registry),
109108
getSessions: (params) => getSessions(registry, params),
110109
getSession: (params) => getSession(registry, params),
@@ -115,6 +114,7 @@ export const KloviServicesLive = Layer.effect(
115114
Effect.gen(function* () {
116115
const result = yield* updatePluginSetting(settingsPath, params);
117116
yield* refreshRegistry();
117+
yield* invalidateStatsCache(settingsPath);
118118
return result;
119119
}),
120120
getGeneralSettings: () => getGeneralSettings(settingsPath),
@@ -124,6 +124,7 @@ export const KloviServicesLive = Layer.effect(
124124
Effect.gen(function* () {
125125
const result = yield* resetSettings(settingsPath);
126126
yield* refreshRegistry();
127+
yield* invalidateStatsCache(settingsPath);
127128
return result;
128129
}),
129130
getUpdateSettings: () => getUpdateSettings(settingsPath),
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import type { RegistryRequirements, Session, SessionSummary } from "@cookielab.io/klovi-plugin-core";
6+
import { PluginError, SqliteClientTag } from "@cookielab.io/klovi-plugin-core";
7+
import { NodeFileSystem } from "@effect/platform-node";
8+
import { Effect, Layer } from "effect";
9+
import type { ToolPlugin } from "./plugin-types.ts";
10+
import { PluginRegistry } from "./registry.ts";
11+
import { getStats, getStatsCachePath, invalidateStatsCache } from "./stats-service.ts";
12+
13+
const testLayer = Layer.merge(
14+
NodeFileSystem.layer,
15+
Layer.succeed(SqliteClientTag, { open: () => Effect.succeed(null) }),
16+
);
17+
const runEffect = <A>(effect: Effect.Effect<A, never, RegistryRequirements>) =>
18+
Effect.runPromise(effect.pipe(Effect.provide(testLayer)));
19+
20+
const testConfig = { dataDir: "/test" };
21+
const tempDirs = new Set<string>();
22+
23+
function isoDaysAgo(days: number): string {
24+
const d = new Date();
25+
d.setHours(12, 0, 0, 0);
26+
d.setDate(d.getDate() - days);
27+
return d.toISOString();
28+
}
29+
30+
// biome-ignore lint/complexity/useMaxParams: test helper with positional args for readability
31+
function makeSession(
32+
id: string,
33+
project: string,
34+
timestamp: string,
35+
model: string,
36+
inputTokens: number,
37+
outputTokens: number,
38+
): Session {
39+
return {
40+
sessionId: id,
41+
project: project,
42+
pluginId: "mock-plugin",
43+
turns: [
44+
{
45+
kind: "user",
46+
uuid: `${id}-user`,
47+
timestamp: timestamp,
48+
text: "hello",
49+
},
50+
{
51+
kind: "assistant",
52+
uuid: `${id}-assistant`,
53+
timestamp: timestamp,
54+
model: model,
55+
usage: {
56+
inputTokens: inputTokens,
57+
outputTokens: outputTokens,
58+
cacheReadTokens: 3,
59+
cacheCreationTokens: 2,
60+
},
61+
contentBlocks: [{ type: "text", text: "result" }],
62+
},
63+
],
64+
};
65+
}
66+
67+
function createMockPlugin(sessionsById: Record<string, Session>, list: SessionSummary[]): ToolPlugin {
68+
return {
69+
id: "mock-plugin",
70+
displayName: "Mock",
71+
getDefaultDataDir: () => null,
72+
isDataAvailable: Effect.succeed(true),
73+
discoverProjects: Effect.succeed([
74+
{
75+
pluginId: "mock-plugin",
76+
nativeId: "project-1",
77+
resolvedPath: "/tmp/project-1",
78+
displayName: "project-1",
79+
sessionCount: list.length,
80+
lastActivity: list[0]?.timestamp ?? "",
81+
},
82+
]),
83+
listSessions: () => Effect.succeed(list),
84+
loadSession: (_nativeId, sessionId) => {
85+
const session = sessionsById[sessionId];
86+
if (!session) {
87+
return Effect.fail(
88+
new PluginError({
89+
pluginId: "mock-plugin",
90+
operation: "loadSession",
91+
message: "missing session",
92+
}),
93+
);
94+
}
95+
return Effect.succeed(session);
96+
},
97+
};
98+
}
99+
100+
async function makeSettingsPath(): Promise<string> {
101+
const dir = await mkdtemp(join(tmpdir(), "klovi-stats-cache-"));
102+
tempDirs.add(dir);
103+
return join(dir, "settings.json");
104+
}
105+
106+
function waitFor(condition: () => Promise<boolean>, timeoutMs = 1000): Promise<void> {
107+
const startedAt = Date.now();
108+
109+
const poll = async (): Promise<void> => {
110+
if (await condition()) {
111+
return;
112+
}
113+
114+
if (Date.now() - startedAt >= timeoutMs) {
115+
throw new Error("Timed out waiting for condition");
116+
}
117+
118+
await new Promise((resolve) => setTimeout(resolve, 10));
119+
return poll();
120+
};
121+
122+
return poll();
123+
}
124+
125+
afterEach(async () => {
126+
await Promise.all([...tempDirs].map((dir) => rm(dir, { recursive: true, force: true })));
127+
tempDirs.clear();
128+
});
129+
130+
describe("stats-service", () => {
131+
test("writes a stats cache file next to settings.json on cold load", async () => {
132+
const settingsPath = await makeSettingsPath();
133+
const registry = new PluginRegistry();
134+
const session = makeSession("s1", "project-1", isoDaysAgo(0), "claude-opus", 10, 5);
135+
const list: SessionSummary[] = [
136+
{
137+
sessionId: "s1",
138+
timestamp: session.turns[0]?.timestamp ?? "",
139+
slug: "s1",
140+
firstMessage: "session 1",
141+
model: "claude-opus",
142+
gitBranch: "main",
143+
},
144+
];
145+
146+
registry.register(createMockPlugin({ s1: session }, list), testConfig);
147+
148+
const result = await runEffect(getStats(settingsPath, registry));
149+
expect(result.refreshing).toBe(false);
150+
expect(result.stats.inputTokens).toBe(10);
151+
152+
const cachedRaw = await readFile(getStatsCachePath(settingsPath), "utf-8");
153+
const cached = JSON.parse(cachedRaw) as {
154+
version: number;
155+
cachedAt: string;
156+
stats: { inputTokens: number };
157+
};
158+
159+
expect(cached.version).toBe(1);
160+
expect(typeof cached.cachedAt).toBe("string");
161+
expect(cached.stats.inputTokens).toBe(10);
162+
});
163+
164+
test("returns the sidecar cache first and refreshes it in the background", async () => {
165+
const settingsPath = await makeSettingsPath();
166+
const registry = new PluginRegistry();
167+
const session = makeSession("s1", "project-1", isoDaysAgo(0), "claude-opus", 99, 5);
168+
const list: SessionSummary[] = [
169+
{
170+
sessionId: "s1",
171+
timestamp: session.turns[0]?.timestamp ?? "",
172+
slug: "s1",
173+
firstMessage: "session 1",
174+
model: "claude-opus",
175+
gitBranch: "main",
176+
},
177+
];
178+
179+
registry.register(createMockPlugin({ s1: session }, list), testConfig);
180+
181+
await writeFile(
182+
getStatsCachePath(settingsPath),
183+
JSON.stringify(
184+
{
185+
version: 1,
186+
cachedAt: "2000-01-01T00:00:00.000Z",
187+
stats: {
188+
projects: 1,
189+
sessions: 1,
190+
messages: 2,
191+
todaySessions: 0,
192+
thisWeekSessions: 0,
193+
inputTokens: 10,
194+
outputTokens: 5,
195+
cacheReadTokens: 3,
196+
cacheCreationTokens: 2,
197+
toolCalls: 0,
198+
models: {},
199+
},
200+
},
201+
null,
202+
2,
203+
),
204+
);
205+
206+
const cachedFirst = await runEffect(getStats(settingsPath, registry));
207+
expect(cachedFirst.stats.inputTokens).toBe(10);
208+
expect(cachedFirst.refreshing).toBe(true);
209+
210+
await waitFor(async () => {
211+
const refreshedRaw = await readFile(getStatsCachePath(settingsPath), "utf-8");
212+
const refreshed = JSON.parse(refreshedRaw) as { stats: { inputTokens: number } };
213+
return refreshed.stats.inputTokens === 99;
214+
});
215+
216+
const refreshed = await runEffect(getStats(settingsPath, registry));
217+
expect(refreshed.stats.inputTokens).toBe(99);
218+
expect(refreshed.refreshing).toBe(false);
219+
});
220+
221+
test("invalidates the sidecar cache file", async () => {
222+
const settingsPath = await makeSettingsPath();
223+
await writeFile(
224+
getStatsCachePath(settingsPath),
225+
JSON.stringify({ version: 1, cachedAt: "2000-01-01T00:00:00.000Z", stats: {} }),
226+
);
227+
228+
await runEffect(invalidateStatsCache(settingsPath));
229+
230+
await expect(readFile(getStatsCachePath(settingsPath), "utf-8")).rejects.toThrow();
231+
});
232+
});

0 commit comments

Comments
 (0)