authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 15:26:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 15:26:20-07:00
logb1ec4b36876e7f3ad03a1e9e921286ec7d58fe95
tree5fd94c1bd2aa4e2514d06027d567bde5ab0a9a78
parent9a0766b0238f2c044a965dcba32c6cb7ea0b63cd
signature Signed by PGP key B5690EEEBB952194

refactor(usage): usage readers use Effect FileSystem and SqlClient (#17615)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

8 files changed, 1280 insertions(+), 968 deletions(-)

apps/server/src/provider/Drivers/AntigravityUsage.ts+3-2
...@@ -106,8 +106,9 @@ const make = Effect.gen(function* () {...@@ -106,8 +106,9 @@ const make = Effect.gen(function* () {
106 : resolvedRoot;106 : resolvedRoot;
107 conversationDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir)));107 conversationDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir)));
108 }108 }
109 const result = yield* Effect.promise(() =>109 const result = yield* readAntigravityUsage([...conversationDirs], windowStartMs, cache).pipe(
110 readAntigravityUsage([...conversationDirs], windowStartMs, cache),110 Effect.provideService(FileSystem.FileSystem, fileSystem),
111 Effect.provideService(Path.Path, path),
111 );112 );
112 const scanned: ProviderUsageScan[] = [];113 const scanned: ProviderUsageScan[] = [];
113 for (const dir of conversationDirs) {114 for (const dir of conversationDirs) {
apps/server/src/provider/Drivers/antigravityUsageReader.test.ts+368-311
...@@ -1,15 +1,43 @@...@@ -1,15 +1,43 @@
1// @effect-diagnostics nodeBuiltinImport:off - the reader under test opens real1// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime or sub-millisecond mtime, which the cache tests compare.
2// SQLite databases, mirroring the reader's own deliberate node:sqlite usage.
3import * as NodeFSP from "node:fs/promises";2import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeSqlite from "node:sqlite";
73
8import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { assert, describe, it } from "@effect/vitest";
6import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12import * as SqlClient from "effect/sql/SqlClient";
913
10import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";14import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts";
1115
12let dir: string;16/** A writable connection that stays open until the enclosing scope closes. */
17const openDatabase = Effect.fn("openDatabase")(function* (filename: string) {
18 const context = yield* Layer.build(NodeSqliteClient.layer({ filename }));
19 const sql = Context.get(context, SqlClient.SqlClient);
20 return {
21 exec: (statement: string) => sql.unsafe(statement),
22 run: (statement: string, params: ReadonlyArray<unknown>) => sql.unsafe(statement, params),
23 };
24});
25
26/** Seeds `filename` and closes it, as a provider that has finished writing. */
27const seedDatabase = <E>(
28 filename: string,
29 seed: (db: Effect.Success<ReturnType<typeof openDatabase>>) => Effect.Effect<void, E>,
30) =>
31 Effect.scoped(
32 Effect.gen(function* () {
33 yield* seed(yield* openDatabase(filename));
34 }),
35 );
36
37const tempDir = Effect.gen(function* () {
38 const fileSystem = yield* FileSystem.FileSystem;
39 return yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
40});
1341
14function protoNumber(field: number, value: number): number[] {42function protoNumber(field: number, value: number): number[] {
15 const varint = (number: number) => {43 const varint = (number: number) => {
...@@ -34,224 +62,241 @@ function protoText(field: number, value: string): number[] {...@@ -34,224 +62,241 @@ function protoText(field: number, value: string): number[] {
34 return protoBytes(field, [...Buffer.from(value)]);62 return protoBytes(field, [...Buffer.from(value)]);
35}63}
3664
37beforeEach(async () => {65const TABLES =
38 dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-"));66 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)";
39});
4067
41afterEach(async () => {68const createTables = (db: Effect.Success<ReturnType<typeof openDatabase>>) =>
42 await NodeFSP.rm(dir, { recursive: true, force: true });69 Effect.forEach(TABLES.split("; "), db.exec, { discard: true });
43});
4470
45describe("readAntigravityUsage", () => {71const records = (result: Effect.Success<ReturnType<typeof readAntigravityUsage>>) =>
46 it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => {72 result.files.flatMap((file) => file.records);
47 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db"));
48 const stamp = protoNumber(1, 1780000000);
49 const usage = [
50 ...protoNumber(2, 100),
51 ...protoNumber(3, 40),
52 ...protoNumber(4, 5),
53 ...protoNumber(5, 20),
54 ...protoNumber(9, 10),
55 ...protoText(11, "response-1"),
56 ];
57 const retry = [
58 ...protoNumber(1, 1026),
59 ...protoNumber(2, 12),
60 ...protoNumber(3, 3),
61 ...protoText(11, "retry-1"),
62 ];
63 const generation = protoBytes(1, [
64 ...protoBytes(4, usage),
65 ...protoText(19, "Gemini 3 Pro"),
66 ...protoBytes(9, protoBytes(4, stamp)),
67 ]);
68 const step = [
69 ...protoBytes(9, usage),
70 ...protoBytes(8, stamp),
71 ...protoBytes(28, protoBytes(2, retry)),
72 ];
73 try {
74 db.exec(
75 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",
76 );
77 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, new Uint8Array(generation));
78 db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(step));
79 } finally {
80 db.close();
81 }
82 const result = await readAntigravityUsage(dir, 0);
83 assert.deepStrictEqual(result.errors, []);
84 const records = result.files.flatMap((file) => file.records);
85 assert.strictEqual(records.length, 2);
86 const main = records.find((record) => record.model === "gemini-3-pro");
87 assert.isDefined(main);
88 assert.strictEqual(main?.timestampMs, 1780000000000);
89 assert.strictEqual(main?.sessionId, "session-1");
90 assert.deepStrictEqual(main?.totals, {
91 uncachedInputTokens: 100,
92 cachedInputTokens: 20,
93 cacheCreationTokens: 5,
94 outputTokens: 40,
95 reasoningTokens: 10,
96 });
97 assert.strictEqual(
98 records.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens,
99 12,
100 );
101 assert.deepStrictEqual(
102 (await readAntigravityUsage(dir, 1780000000001)).files.flatMap((file) => file.records),
103 [],
104 );
105 });
10673
107 it("uses the matching Antigravity generation model for each model-less step", async () => {74describe("readAntigravityUsage", () => {
108 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "model-switch.db"));75 it.effect(
109 try {76 "deduplicates Antigravity generation and step usage while preserving retry model and token buckets",
110 db.exec(77 () =>
111 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",78 Effect.gen(function* () {
112 );79 const path = yield* Path.Path;
113 const generation = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)");80 const dir = yield* tempDir;
114 const step = db.prepare("INSERT INTO steps VALUES (?, ?)");81 const stamp = protoNumber(1, 1780000000);
115 for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) {82 const usage = [
116 generation.run(idx, new Uint8Array(protoBytes(1, protoText(19, name))));83 ...protoNumber(2, 100),
117 step.run(idx, new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx))));84 ...protoNumber(3, 40),
118 }85 ...protoNumber(4, 5),
119 } finally {86 ...protoNumber(5, 20),
120 db.close();87 ...protoNumber(9, 10),
121 }88 ...protoText(11, "response-1"),
122 const result = await readAntigravityUsage(dir, 0);89 ];
123 assert.deepStrictEqual(result.errors, []);90 const retry = [
124 assert.deepStrictEqual(91 ...protoNumber(1, 1026),
125 result.files.flatMap((file) => file.records).map((record) => record.model),92 ...protoNumber(2, 12),
126 ["gemini-3-pro", "claude-opus-4-6"],93 ...protoNumber(3, 3),
127 );94 ...protoText(11, "retry-1"),
128 });95 ];
96 const generation = protoBytes(1, [
97 ...protoBytes(4, usage),
98 ...protoText(19, "Gemini 3 Pro"),
99 ...protoBytes(9, protoBytes(4, stamp)),
100 ]);
101 const step = [
102 ...protoBytes(9, usage),
103 ...protoBytes(8, stamp),
104 ...protoBytes(28, protoBytes(2, retry)),
105 ];
106 yield* seedDatabase(path.join(dir, "session-1.db"), (db) =>
107 Effect.gen(function* () {
108 yield* createTables(db);
109 yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [
110 0,
111 new Uint8Array(generation),
112 ]);
113 yield* db.run("INSERT INTO steps VALUES (?, ?)", [0, new Uint8Array(step)]);
114 }),
115 );
116 const result = yield* readAntigravityUsage(dir, 0);
117 assert.deepStrictEqual(result.errors, []);
118 const read = records(result);
119 assert.strictEqual(read.length, 2);
120 const main = read.find((record) => record.model === "gemini-3-pro");
121 assert.isDefined(main);
122 assert.strictEqual(main?.timestampMs, 1780000000000);
123 assert.strictEqual(main?.sessionId, "session-1");
124 assert.deepStrictEqual(main?.totals, {
125 uncachedInputTokens: 100,
126 cachedInputTokens: 20,
127 cacheCreationTokens: 5,
128 outputTokens: 40,
129 reasoningTokens: 10,
130 });
131 assert.strictEqual(
132 read.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens,
133 12,
134 );
135 assert.deepStrictEqual(records(yield* readAntigravityUsage(dir, 1780000000001)), []);
136 }).pipe(Effect.provide(NodeServices.layer)),
137 );
129138
130 it("merges Antigravity aliases that bridge previously separate step records", async () => {139 it.effect("uses the matching Antigravity generation model for each model-less step", () =>
131 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "bridge.db"));140 Effect.gen(function* () {
132 try {141 const path = yield* Path.Path;
133 db.exec(142 const dir = yield* tempDir;
134 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",143 yield* seedDatabase(path.join(dir, "model-switch.db"), (db) =>
144 Effect.gen(function* () {
145 yield* createTables(db);
146 for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) {
147 yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [
148 idx,
149 new Uint8Array(protoBytes(1, protoText(19, name))),
150 ]);
151 yield* db.run("INSERT INTO steps VALUES (?, ?)", [
152 idx,
153 new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx))),
154 ]);
155 }
156 }),
135 );157 );
136 const step = db.prepare("INSERT INTO steps VALUES (?, ?)");158 const result = yield* readAntigravityUsage(dir, 0);
137 step.run(159 assert.deepStrictEqual(result.errors, []);
138 0,160 assert.deepStrictEqual(
139 new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])),161 records(result).map((record) => record.model),
140 );162 ["gemini-3-pro", "claude-opus-4-6"],
141 step.run(
142 1,
143 new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])),
144 );
145 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(
146 0,
147 new Uint8Array(
148 protoBytes(1, [
149 ...protoText(19, "Gemini 3 Pro"),
150 ...protoBytes(4, [
151 ...protoNumber(2, 50),
152 ...protoNumber(5, 20),
153 ...protoText(11, "response"),
154 ...protoText(12, "provider"),
155 ]),
156 ]),
157 ),
158 );163 );
159 } finally {164 }).pipe(Effect.provide(NodeServices.layer)),
160 db.close();165 );
161 }
162 const result = await readAntigravityUsage(dir, 0);
163 assert.deepStrictEqual(result.errors, []);
164 const records = result.files.flatMap((file) => file.records);
165 assert.strictEqual(records.length, 1);
166 assert.deepStrictEqual(records[0]?.totals, {
167 uncachedInputTokens: 100,
168 cachedInputTokens: 20,
169 cacheCreationTokens: 0,
170 outputTokens: 40,
171 reasoningTokens: 0,
172 });
173 });
174166
175 it("merges Antigravity provider and message aliases across configured roots while keeping original ownership", async () => {167 it.effect("merges Antigravity aliases that bridge previously separate step records", () =>
176 const roots = [NodePath.join(dir, "first"), NodePath.join(dir, "second")];168 Effect.gen(function* () {
177 for (const [index, root] of roots.entries()) {169 const path = yield* Path.Path;
178 await NodeFSP.mkdir(root);170 const dir = yield* tempDir;
179 const db = new NodeSqlite.DatabaseSync(NodePath.join(root, `session-${index}.db`));171 yield* seedDatabase(path.join(dir, "bridge.db"), (db) =>
180 try {172 Effect.gen(function* () {
181 db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");173 yield* createTables(db);
182 for (const identity of [7, 12]) {174 yield* db.run("INSERT INTO steps VALUES (?, ?)", [
183 const usage = [175 0,
184 ...protoNumber(1, 246),176 new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])),
185 ...protoNumber(2, index === 0 ? 100 : 150),177 ]);
186 ...protoText(11, `response-${index}-${identity}`),178 yield* db.run("INSERT INTO steps VALUES (?, ?)", [
187 ...protoText(identity, `shared-${identity}`),179 1,
188 ];180 new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])),
189 db.prepare("INSERT INTO steps VALUES (?, ?)").run(181 ]);
190 identity,182 yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [
191 new Uint8Array(protoBytes(9, usage)),183 0,
192 );
193 }
194 } finally {
195 db.close();
196 }
197 }
198 const result = await readAntigravityUsage(roots, 0);
199 assert.deepStrictEqual(result.errors, []);
200 assert.strictEqual(result.files.length, 2);
201 assert.strictEqual(result.files[0]?.root, roots[0]);
202 assert.strictEqual(result.files[0]?.records.length, 2);
203 assert.strictEqual(result.files[1]?.records.length, 0);
204 assert.deepStrictEqual(
205 result.files[0]?.records.map((record) => record.totals.uncachedInputTokens),
206 [150, 150],
207 );
208 assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0"));
209 });
210
211 it("upgrades Antigravity fallback timestamps before applying the date window", async () => {
212 for (const fallback of ["mtime", "trajectory"]) {
213 const path = NodePath.join(dir, `${fallback}.db`);
214 const db = new NodeSqlite.DatabaseSync(path);
215 try {
216 db.exec(
217 "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)",
218 );
219 if (fallback === "trajectory") {
220 db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)");
221 db.prepare("INSERT INTO trajectory_metadata_blob VALUES (?)").run(
222 new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))),
223 );
224 }
225 for (const [index, seconds] of [1780000000, 1780000200].entries()) {
226 const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)];
227 db.prepare("INSERT INTO steps VALUES (?, ?)").run(
228 index,
229 new Uint8Array(protoBytes(9, usage)),
230 );
231 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(
232 index,
233 new Uint8Array(184 new Uint8Array(
234 protoBytes(1, [185 protoBytes(1, [
235 ...protoBytes(4, usage),186 ...protoText(19, "Gemini 3 Pro"),
236 ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))),187 ...protoBytes(4, [
188 ...protoNumber(2, 50),
189 ...protoNumber(5, 20),
190 ...protoText(11, "response"),
191 ...protoText(12, "provider"),
192 ]),
237 ]),193 ]),
238 ),194 ),
195 ]);
196 }),
197 );
198 const result = yield* readAntigravityUsage(dir, 0);
199 assert.deepStrictEqual(result.errors, []);
200 const read = records(result);
201 assert.strictEqual(read.length, 1);
202 assert.deepStrictEqual(read[0]?.totals, {
203 uncachedInputTokens: 100,
204 cachedInputTokens: 20,
205 cacheCreationTokens: 0,
206 outputTokens: 40,
207 reasoningTokens: 0,
208 });
209 }).pipe(Effect.provide(NodeServices.layer)),
210 );
211
212 it.effect(
213 "merges Antigravity provider and message aliases across configured roots while keeping original ownership",
214 () =>
215 Effect.gen(function* () {
216 const fileSystem = yield* FileSystem.FileSystem;
217 const path = yield* Path.Path;
218 const dir = yield* tempDir;
219 const roots = [path.join(dir, "first"), path.join(dir, "second")];
220 for (const [index, root] of roots.entries()) {
221 yield* fileSystem.makeDirectory(root);
222 yield* seedDatabase(path.join(root, `session-${index}.db`), (db) =>
223 Effect.gen(function* () {
224 yield* db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");
225 for (const identity of [7, 12]) {
226 const usage = [
227 ...protoNumber(1, 246),
228 ...protoNumber(2, index === 0 ? 100 : 150),
229 ...protoText(11, `response-${index}-${identity}`),
230 ...protoText(identity, `shared-${identity}`),
231 ];
232 yield* db.run("INSERT INTO steps VALUES (?, ?)", [
233 identity,
234 new Uint8Array(protoBytes(9, usage)),
235 ]);
236 }
237 }),
239 );238 );
240 }239 }
241 } finally {240 const result = yield* readAntigravityUsage(roots, 0);
242 db.close();241 assert.deepStrictEqual(result.errors, []);
242 assert.strictEqual(result.files.length, 2);
243 assert.strictEqual(result.files[0]?.root, roots[0]);
244 assert.strictEqual(result.files[0]?.records.length, 2);
245 assert.strictEqual(result.files[1]?.records.length, 0);
246 assert.deepStrictEqual(
247 result.files[0]?.records.map((record) => record.totals.uncachedInputTokens),
248 [150, 150],
249 );
250 assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0"));
251 }).pipe(Effect.provide(NodeServices.layer)),
252 );
253
254 it.effect("upgrades Antigravity fallback timestamps before applying the date window", () =>
255 Effect.gen(function* () {
256 const fileSystem = yield* FileSystem.FileSystem;
257 const path = yield* Path.Path;
258 const dir = yield* tempDir;
259 for (const fallback of ["mtime", "trajectory"]) {
260 const file = path.join(dir, `${fallback}.db`);
261 yield* seedDatabase(file, (db) =>
262 Effect.gen(function* () {
263 yield* createTables(db);
264 if (fallback === "trajectory") {
265 yield* db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)");
266 yield* db.run("INSERT INTO trajectory_metadata_blob VALUES (?)", [
267 new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))),
268 ]);
269 }
270 for (const [index, seconds] of [1780000000, 1780000200].entries()) {
271 const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)];
272 yield* db.run("INSERT INTO steps VALUES (?, ?)", [
273 index,
274 new Uint8Array(protoBytes(9, usage)),
275 ]);
276 yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [
277 index,
278 new Uint8Array(
279 protoBytes(1, [
280 ...protoBytes(4, usage),
281 ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))),
282 ]),
283 ),
284 ]);
285 }
286 }),
287 );
288 yield* fileSystem.utimes(file, 1780000000, 1780000000);
243 }289 }
244 await NodeFSP.utimes(path, 1780000000, 1780000000);290 const result = yield* readAntigravityUsage(dir, 1780000100000);
245 }291 assert.deepStrictEqual(result.errors, []);
246 const result = await readAntigravityUsage(dir, 1780000100000);292 const read = records(result);
247 assert.deepStrictEqual(result.errors, []);293 assert.strictEqual(read.length, 2);
248 const records = result.files.flatMap((file) => file.records);294 assert.deepStrictEqual(
249 assert.strictEqual(records.length, 2);295 read.map((record) => record.timestampMs),
250 assert.deepStrictEqual(296 [1780000200000, 1780000200000],
251 records.map((record) => record.timestampMs),297 );
252 [1780000200000, 1780000200000],298 }).pipe(Effect.provide(NodeServices.layer)),
253 );299 );
254 });
255300
256 const antigravityGeneration = (responseId: string) =>301 const antigravityGeneration = (responseId: string) =>
257 new Uint8Array(302 new Uint8Array(
...@@ -266,126 +311,138 @@ describe("readAntigravityUsage", () => {...@@ -266,126 +311,138 @@ describe("readAntigravityUsage", () => {
266 ]),311 ]),
267 );312 );
268313
269 it("reuses an unchanged Antigravity database instead of decoding it again", async () => {314 it.effect("reuses an unchanged Antigravity database instead of decoding it again", () =>
270 const path = NodePath.join(dir, "session-1.db");315 Effect.gen(function* () {
271 const db = new NodeSqlite.DatabaseSync(path);316 const path = yield* Path.Path;
272 try {317 const dir = yield* tempDir;
273 db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)");318 const db = yield* openDatabase(path.join(dir, "session-1.db"));
274 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, antigravityGeneration("r-1"));319 yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)");
320 yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [0, antigravityGeneration("r-1")]);
275 const cache = makeAntigravityUsageCache();321 const cache = makeAntigravityUsageCache();
276 assert.deepStrictEqual((await readAntigravityUsage(dir, 0, cache)).errors, []);322 assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []);
277323
278 // An exclusive lock makes a fresh read fail without touching the file, so324 // An exclusive lock makes a fresh read fail without touching the file, so
279 // only a cache hit can still return the earlier records.325 // only a cache hit can still return the earlier records.
280 db.exec("BEGIN EXCLUSIVE");326 yield* db.exec("BEGIN EXCLUSIVE");
281 assert.strictEqual((await readAntigravityUsage(dir, 0)).errors.length, 1);327 assert.strictEqual((yield* readAntigravityUsage(dir, 0)).errors.length, 1);
282 const cached = await readAntigravityUsage(dir, 0, cache);328 const cached = yield* readAntigravityUsage(dir, 0, cache);
283 assert.deepStrictEqual(cached.errors, []);329 assert.deepStrictEqual(cached.errors, []);
284 assert.deepStrictEqual(330 assert.deepStrictEqual(
285 cached.files.flatMap((file) => file.records).map((record) => record.dedupeKey),331 records(cached).map((record) => record.dedupeKey),
286 ["antigravity:11:r-1"],332 ["antigravity:11:r-1"],
287 );333 );
288 db.exec("ROLLBACK");334 yield* db.exec("ROLLBACK");
289 } finally {335 }).pipe(Effect.provide(NodeServices.layer)),
290 db.close();336 );
291 }
292 });
293337
294 it("rereads an Antigravity database rewritten with its size and mtime restored", async () => {338 it.effect("rereads an Antigravity database rewritten with its size and mtime restored", () =>
295 const path = NodePath.join(dir, "session-1.db");339 Effect.gen(function* () {
296 const db = new NodeSqlite.DatabaseSync(path);340 const fileSystem = yield* FileSystem.FileSystem;
297 try {341 const pathService = yield* Path.Path;
298 db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)");342 const dir = yield* tempDir;
299 db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, antigravityGeneration("r-1"));343 const path = pathService.join(dir, "session-1.db");
300 } finally {344 yield* seedDatabase(path, (db) =>
301 db.close();345 Effect.gen(function* () {
302 }346 yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)");
303 await NodeFSP.utimes(path, 1780000000, 1780000000);347 yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [
304 const cache = makeAntigravityUsageCache();348 0,
305 assert.deepStrictEqual((await readAntigravityUsage(dir, 0, cache)).errors, []);349 antigravityGeneration("r-1"),
350 ]);
351 }),
352 );
353 yield* fileSystem.utimes(path, 1780000000, 1780000000);
354 const cache = makeAntigravityUsageCache();
355 assert.deepStrictEqual((yield* readAntigravityUsage(dir, 0, cache)).errors, []);
306356
307 // ctime has the kernel's timestamp granularity, which can be a few357 // ctime has the kernel's timestamp granularity, which can be a few
308 // milliseconds, so repeat the forged rewrite until it lands on a later tick358 // milliseconds, so repeat the forged rewrite until it lands on a later tick
309 // than the cached read, as any real rewrite does.359 // than the cached read, as any real rewrite does.
310 const cached = await NodeFSP.stat(path);360 const stat = () => Effect.promise(() => NodeFSP.stat(path));
311 do {361 const cached = yield* stat();
312 await NodeFSP.writeFile(path, Buffer.alloc(cached.size));362 do {
313 await NodeFSP.utimes(path, 1780000000, 1780000000);363 yield* fileSystem.writeFile(path, new Uint8Array(cached.size));
314 } while ((await NodeFSP.stat(path)).ctimeMs === cached.ctimeMs);364 yield* fileSystem.utimes(path, 1780000000, 1780000000);
315 const restored = await NodeFSP.stat(path);365 } while ((yield* stat()).ctimeMs === cached.ctimeMs);
316 assert.strictEqual(restored.size, cached.size);366 const restored = yield* stat();
317 assert.strictEqual(restored.mtimeMs, cached.mtimeMs);367 assert.strictEqual(restored.size, cached.size);
318 const next = await readAntigravityUsage(dir, 0, cache);368 assert.strictEqual(restored.mtimeMs, cached.mtimeMs);
319 assert.deepStrictEqual(next.errors, [path]);369 const next = yield* readAntigravityUsage(dir, 0, cache);
320 assert.deepStrictEqual(370 assert.deepStrictEqual(next.errors, [path]);
321 next.files.flatMap((file) => file.records),371 assert.deepStrictEqual(records(next), []);
322 [],372 }).pipe(Effect.provide(NodeServices.layer)),
323 );373 );
324 });
325374
326 it("rereads an Antigravity database when only its WAL changed", async () => {375 it.effect("rereads an Antigravity database when only its WAL changed", () =>
327 const path = NodePath.join(dir, "session-1.db");376 Effect.gen(function* () {
328 const db = new NodeSqlite.DatabaseSync(path);377 const pathService = yield* Path.Path;
329 try {378 const dir = yield* tempDir;
330 db.exec(379 const path = pathService.join(dir, "session-1.db");
331 "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE gen_metadata (idx INTEGER, data BLOB)",380 const db = yield* openDatabase(path);
332 );381 yield* db.exec("PRAGMA journal_mode = WAL");
333 const insert = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)");382 yield* db.exec("PRAGMA wal_autocheckpoint = 0");
334 insert.run(0, antigravityGeneration("r-1"));383 yield* db.exec("CREATE TABLE gen_metadata (idx INTEGER, data BLOB)");
384 const insert = (idx: number, responseId: string) =>
385 db.run("INSERT INTO gen_metadata VALUES (?, ?)", [idx, antigravityGeneration(responseId)]);
386 yield* insert(0, "r-1");
335 const cache = makeAntigravityUsageCache();387 const cache = makeAntigravityUsageCache();
336 const first = await readAntigravityUsage(dir, 0, cache);388 const first = yield* readAntigravityUsage(dir, 0, cache);
337 assert.strictEqual(first.files.flatMap((file) => file.records).length, 1);389 assert.strictEqual(records(first).length, 1);
338390
339 const before = await NodeFSP.stat(path);391 const before = yield* Effect.promise(() => NodeFSP.stat(path));
340 insert.run(1, antigravityGeneration("r-2"));392 yield* insert(1, "r-2");
341 const after = await NodeFSP.stat(path);393 const after = yield* Effect.promise(() => NodeFSP.stat(path));
342 assert.strictEqual(after.size, before.size);394 assert.strictEqual(after.size, before.size);
343 assert.strictEqual(after.mtimeMs, before.mtimeMs);395 assert.strictEqual(after.mtimeMs, before.mtimeMs);
344396
345 const next = await readAntigravityUsage(dir, 0, cache);397 const next = yield* readAntigravityUsage(dir, 0, cache);
346 assert.deepStrictEqual(398 assert.deepStrictEqual(
347 next.files.flatMap((file) => file.records).map((record) => record.dedupeKey),399 records(next).map((record) => record.dedupeKey),
348 ["antigravity:11:r-1", "antigravity:11:r-2"],400 ["antigravity:11:r-1", "antigravity:11:r-2"],
349 );401 );
350 } finally {402 }).pipe(Effect.provide(NodeServices.layer)),
351 db.close();403 );
352 }
353 });
354404
355 it("reads Antigravity step-only stores and reports malformed databases", async () => {405 it.effect("reads Antigravity step-only stores and reports malformed databases", () =>
356 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "steps.db"));406 Effect.gen(function* () {
357 try {407 const fileSystem = yield* FileSystem.FileSystem;
358 db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");408 const path = yield* Path.Path;
359 const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)];409 const dir = yield* tempDir;
360 db.prepare("INSERT INTO steps VALUES (?, ?)").run(410 yield* seedDatabase(path.join(dir, "steps.db"), (db) =>
361 0,411 Effect.gen(function* () {
362 new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]),412 yield* db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");
413 const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)];
414 yield* db.run("INSERT INTO steps VALUES (?, ?)", [
415 0,
416 new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]),
417 ]);
418 }),
363 );419 );
364 } finally {420 yield* fileSystem.writeFileString(path.join(dir, "broken.db"), "not a sqlite database");
365 db.close();421 const result = yield* readAntigravityUsage(dir, 0);
366 }422 assert.strictEqual(result.errors.length, 1);
367 await NodeFSP.writeFile(NodePath.join(dir, "broken.db"), "not a sqlite database");423 assert.strictEqual(records(result)[0]?.model, "gemini-2.5-pro");
368 const result = await readAntigravityUsage(dir, 0);424 assert.strictEqual(records(result)[0]?.totals.outputTokens, 5);
369 assert.strictEqual(result.errors.length, 1);425 }).pipe(Effect.provide(NodeServices.layer)),
370 assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.model, "gemini-2.5-pro");426 );
371 assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.totals.outputTokens, 5);
372 });
373427
374 it("ignores large values in unused Antigravity protobuf fields", async () => {428 it.effect("ignores large values in unused Antigravity protobuf fields", () =>
375 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "large-varint.db"));429 Effect.gen(function* () {
376 try {430 const path = yield* Path.Path;
377 db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");431 const dir = yield* tempDir;
378 const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01];432 yield* seedDatabase(path.join(dir, "large-varint.db"), (db) =>
379 const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField];433 Effect.gen(function* () {
380 db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(protoBytes(9, usage)));434 yield* db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)");
381 } finally {435 const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01];
382 db.close();436 const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField];
383 }437 yield* db.run("INSERT INTO steps VALUES (?, ?)", [
384 const result = await readAntigravityUsage(dir, 0);438 0,
385 assert.deepStrictEqual(result.errors, []);439 new Uint8Array(protoBytes(9, usage)),
386 assert.strictEqual(440 ]);
387 result.files.flatMap((file) => file.records)[0]?.totals.uncachedInputTokens,441 }),
388 10,442 );
389 );443 const result = yield* readAntigravityUsage(dir, 0);
390 });444 assert.deepStrictEqual(result.errors, []);
445 assert.strictEqual(records(result)[0]?.totals.uncachedInputTokens, 10);
446 }).pipe(Effect.provide(NodeServices.layer)),
447 );
391});448});
apps/server/src/provider/Drivers/antigravityUsageReader.ts+176-119
...@@ -1,11 +1,14 @@...@@ -1,11 +1,14 @@
1// node:sqlite reads live conversation databases while Node fs discovers them.1// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime, which the cache fingerprint needs.
2// @effect-diagnostics nodeBuiltinImport:off
3import * as NodeFSP from "node:fs/promises";2import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeSqlite from "node:sqlite";
6import * as NodeTimersPromises from "node:timers/promises";
73
8import type { UsageRecord } from "@t3tools/provider-core/server/usage";4import type { UsageRecord } from "@t3tools/provider-core/server/usage";
5import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as SqlClient from "effect/sql/SqlClient";
912
10type FieldValue = number | bigint | Uint8Array;13type FieldValue = number | bigint | Uint8Array;
11type Fields = Map<number, FieldValue[]>;14type Fields = Map<number, FieldValue[]>;
...@@ -158,103 +161,136 @@ interface UsageCandidate {...@@ -158,103 +161,136 @@ interface UsageCandidate {
158 timestampQuality: number;161 timestampQuality: number;
159}162}
160163
161async function readDatabase(path: string, fallbackTimestamp: number): Promise<UsageCandidate[]> {164/** A conversation database whose usage tables are missing or undecodable. */
162 const db = new NodeSqlite.DatabaseSync(path, { readOnly: true });165class AntigravityDatabaseDecodeError extends Schema.TaggedError<AntigravityDatabaseDecodeError>()(
163 try {166 "AntigravityDatabaseDecodeError",
164 db.exec("PRAGMA busy_timeout = 100; BEGIN");167 { path: Schema.String, cause: Schema.optional(Schema.Defect()) },
168) {
169 override get message(): string {
170 return `Antigravity usage could not be decoded from ${this.path}.`;
171 }
172}
173
174const readDatabase = Effect.fn("readDatabase")(function* (
175 path: string,
176 sessionId: string,
177 fallbackTimestamp: number,
178) {
179 const sql = yield* SqlClient.SqlClient;
180 const decode = <A>(evaluate: () => A) =>
181 Effect.try({
182 try: evaluate,
183 catch: (cause) => new AntigravityDatabaseDecodeError({ path, cause }),
184 });
185 yield* sql.unsafe("PRAGMA busy_timeout = 100");
186 return yield* Effect.gen(function* () {
165 const tables = new Set(187 const tables = new Set(
166 db188 (yield* sql.unsafe<{ readonly name: unknown }>(
167 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")189 "SELECT name FROM sqlite_master WHERE type = 'table'",
168 .all()190 )).map((row) => row.name),
169 .map((row) => row.name),
170 );191 );
171 if (!tables.has("gen_metadata") && !tables.has("steps")) {192 if (!tables.has("gen_metadata") && !tables.has("steps")) {
172 throw new Error("Missing Antigravity usage tables");193 return yield* new AntigravityDatabaseDecodeError({ path });
173 }194 }
174 const readMetadata = async (query: string, column: string, step: boolean) => {195 const readMetadata = Effect.fnUntraced(function* (
196 query: string,
197 column: string,
198 step: boolean,
199 ) {
175 const entries: Array<{ idx: number; entry: Metadata }> = [];200 const entries: Array<{ idx: number; entry: Metadata }> = [];
176 for (const row of db.prepare(query).iterate()) {201 for (const row of yield* sql.unsafe<Record<string, unknown>>(query)) {
177 if (typeof row.idx !== "number") throw new Error("Invalid Antigravity metadata index");202 const idx = row.idx;
178 entries.push({ idx: row.idx, entry: metadata(blob(row[column]), step) });203 if (typeof idx !== "number") {
179 if (entries.length % 256 === 0) await NodeTimersPromises.setImmediate();204 return yield* new AntigravityDatabaseDecodeError({ path });
205 }
206 entries.push({ idx, entry: yield* decode(() => metadata(blob(row[column]), step)) });
207 if (entries.length % 256 === 0) yield* Effect.yieldNow;
180 }208 }
181 return entries;209 return entries;
182 };210 });
183 const generations = tables.has("gen_metadata")211 const generations = tables.has("gen_metadata")
184 ? await readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false)212 ? yield* readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false)
185 : [];213 : [];
186 let trajectoryTimestamp: number | null = null;214 let trajectoryTimestamp: number | null = null;
187 if (tables.has("trajectory_metadata_blob")) {215 if (tables.has("trajectory_metadata_blob")) {
188 for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").iterate()) {216 for (const row of yield* sql.unsafe<{ readonly data: unknown }>(
189 trajectoryTimestamp ??= timestamp(nested(fields(blob(row.data)), 2));217 "SELECT data FROM trajectory_metadata_blob",
218 )) {
219 trajectoryTimestamp ??= yield* decode(() => timestamp(nested(fields(blob(row.data)), 2)));
190 }220 }
191 }221 }
192 const steps = tables.has("steps")222 const steps = tables.has("steps")
193 ? await readMetadata(223 ? yield* readMetadata(
194 "SELECT idx, metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx",224 "SELECT idx, metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx",
195 "metadata",225 "metadata",
196 true,226 true,
197 )227 )
198 : [];228 : [];
199 const sessionId = NodePath.basename(path, ".db");229 return yield* decode(() =>
200 const records: UsageCandidate[] = [];230 usageCandidates(sessionId, generations, steps, trajectoryTimestamp, fallbackTimestamp),
201 const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model]));231 );
202 for (const [source, entries] of [232 }).pipe(sql.withTransaction);
203 ["step", steps],233});
204 ["generation", generations],234
205 ] as const) {235function usageCandidates(
206 for (const [index, { idx, entry }] of entries.entries()) {236 sessionId: string,
207 for (const [usageIndex, usage] of entry.usages.entries()) {237 generations: ReadonlyArray<{ idx: number; entry: Metadata }>,
208 const outputTokens = Math.max(238 steps: ReadonlyArray<{ idx: number; entry: Metadata }>,
209 numberAt(usage, 3),239 trajectoryTimestamp: number | null,
210 numberAt(usage, 9) + numberAt(usage, 10),240 fallbackTimestamp: number,
211 );241): UsageCandidate[] {
212 const totals = {242 const records: UsageCandidate[] = [];
213 uncachedInputTokens: numberAt(usage, 2),243 const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model]));
214 cachedInputTokens: numberAt(usage, 5),244 for (const [source, entries] of [
215 cacheCreationTokens: numberAt(usage, 4),245 ["step", steps],
216 outputTokens,246 ["generation", generations],
217 reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)),247 ] as const) {
218 };248 for (const [index, { idx, entry }] of entries.entries()) {
219 if (249 for (const [usageIndex, usage] of entry.usages.entries()) {
220 totals.uncachedInputTokens +250 const outputTokens = Math.max(numberAt(usage, 3), numberAt(usage, 9) + numberAt(usage, 10));
221 totals.cachedInputTokens +251 const totals = {
222 totals.cacheCreationTokens +252 uncachedInputTokens: numberAt(usage, 2),
223 outputTokens ===253 cachedInputTokens: numberAt(usage, 5),
224 0254 cacheCreationTokens: numberAt(usage, 4),
225 )255 outputTokens,
226 continue;256 reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)),
227 const keys = ([11, 12, 7] as const).flatMap((key) => {257 };
228 const id = textAt(usage, key);258 if (
229 return id ? [`antigravity:${key}:${id}`] : [];259 totals.uncachedInputTokens +
230 });260 totals.cachedInputTokens +
231 const record: UsageRecord = {261 totals.cacheCreationTokens +
232 provider: "antigravity",262 outputTokens ===
233 sessionId,263 0
234 timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp,264 )
235 model:265 continue;
236 MODEL_IDS[numberAt(usage, 1)] ||266 const keys = ([11, 12, 7] as const).flatMap((key) => {
237 entry.model ||267 const id = textAt(usage, key);
238 (source === "step" ? generationModels.get(idx) : "") ||268 return id ? [`antigravity:${key}:${id}`] : [];
239 modelName("", numberAt(usage, 1)) ||269 });
240 "antigravity-unknown",270 const record: UsageRecord = {
241 totals,271 provider: "antigravity",
242 reportedCostUsd: null,272 sessionId,
243 speed: "standard",273 timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp,
244 dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`,274 model:
245 };275 MODEL_IDS[numberAt(usage, 1)] ||
246 records.push({276 entry.model ||
247 record,277 (source === "step" ? generationModels.get(idx) : "") ||
248 keys,278 modelName("", numberAt(usage, 1)) ||
249 timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0,279 "antigravity-unknown",
250 });280 totals,
251 }281 reportedCostUsd: null,
282 speed: "standard",
283 dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`,
284 };
285 records.push({
286 record,
287 keys,
288 timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0,
289 });
252 }290 }
253 }291 }
254 return records;
255 } finally {
256 db.close();
257 }292 }
293 return records;
258}294}
259295
260interface CachedDatabase {296interface CachedDatabase {
...@@ -275,11 +311,13 @@ export const makeAntigravityUsageCache = () => new Map<string, CachedDatabase>()...@@ -275,11 +311,13 @@ export const makeAntigravityUsageCache = () => new Map<string, CachedDatabase>()
275 * filtering. With a cache, databases unchanged since the previous read are not311 * filtering. With a cache, databases unchanged since the previous read are not
276 * decoded again.312 * decoded again.
277 */313 */
278export async function readAntigravityUsage(314export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* (
279 conversationsDirectories: string | readonly string[],315 conversationsDirectories: string | readonly string[],
280 sinceMs: number,316 sinceMs: number,
281 cache?: Map<string, CachedDatabase>,317 cache?: Map<string, CachedDatabase>,
282) {318) {
319 const fileSystem = yield* FileSystem.FileSystem;
320 const pathService = yield* Path.Path;
283 const roots =321 const roots =
284 typeof conversationsDirectories === "string"322 typeof conversationsDirectories === "string"
285 ? [conversationsDirectories]323 ? [conversationsDirectories]
...@@ -350,50 +388,69 @@ export async function readAntigravityUsage(...@@ -350,50 +388,69 @@ export async function readAntigravityUsage(
350 }388 }
351 };389 };
352 const visited = new Set<string>();390 const visited = new Set<string>();
353 const walk = async (directory: string, root: string): Promise<void> => {391 const readFile = Effect.fn("readAntigravityUsage.readFile")(function* (path: string) {
354 let entries;392 const canonical = yield* fileSystem.realPath(path);
355 try {393 if (visited.has(canonical)) return null;
356 entries = await NodeFSP.readdir(directory, { withFileTypes: true });394 visited.add(canonical);
357 } catch (error) {395 const fingerprintOf = (file: string) =>
358 if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(directory);396 Effect.tryPromise(() => NodeFSP.stat(file)).pipe(
359 return;397 Effect.map((stat) => ({ stat, part: `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` })),
360 }398 );
361 entries.sort((a, b) => a.name.localeCompare(b.name));399 const { stat, part } = yield* fingerprintOf(path);
362 for (const entry of entries) {400 const wal = yield* fingerprintOf(`${path}-wal`).pipe(Effect.option);
363 const path = NodePath.join(directory, entry.name);401 const fingerprint = `${part}/${wal._tag === "Some" ? wal.value.part : "-"}`;
364 if (entry.isDirectory()) {402 const cached = cache?.get(canonical);
365 await walk(path, root);403 if (cached?.fingerprint === fingerprint) return cached.candidates;
366 } else if (entry.isFile() && entry.name.endsWith(".db")) {404 const candidates = yield* readDatabase(
367 try {405 path,
368 const canonical = await NodeFSP.realpath(path);406 pathService.basename(path, ".db"),
369 if (visited.has(canonical)) continue;407 stat.mtimeMs,
370 visited.add(canonical);408 ).pipe(Effect.provide(NodeSqliteClient.layer({ filename: path, readonly: true })));
371 const stat = await NodeFSP.stat(path);409 cache?.set(canonical, { fingerprint, candidates });
372 const wal = await NodeFSP.stat(`${path}-wal`).catch(() => null);410 return candidates;
373 const fingerprint = [stat, wal]411 });
374 .map((file) => (file ? `${file.size}:${file.mtimeMs}:${file.ctimeMs}` : "-"))412 const walk = (directory: string, root: string): Effect.Effect<void> =>
375 .join("/");413 Effect.gen(function* () {
376 const cached = cache?.get(canonical);414 const names = yield* fileSystem.readDirectory(directory).pipe(
377 let candidates: readonly UsageCandidate[];415 Effect.catchTags({
378 if (cached?.fingerprint === fingerprint) {416 PlatformError: (cause) => {
379 candidates = cached.candidates;417 if (cause.reason._tag !== "NotFound") errors.push(directory);
380 } else {418 return Effect.succeed(null);
381 candidates = await readDatabase(path, stat.mtimeMs);419 },
382 cache?.set(canonical, { fingerprint, candidates });420 }),
383 }421 );
422 if (names === null) return;
423 names.sort((a, b) => a.localeCompare(b));
424 for (const name of names) {
425 const path = pathService.join(directory, name);
426 // Like a directory listing's entry types: symlinks are neither files nor directories.
427 const isLink = Exit.isSuccess(yield* Effect.exit(fileSystem.readLink(path)));
428 const type = isLink
429 ? "SymbolicLink"
430 : yield* fileSystem.stat(path).pipe(
431 Effect.map((info) => info.type),
432 Effect.orElseSucceed(() => null),
433 );
434 if (type === "Directory") {
435 yield* walk(path, root);
436 } else if ((type === "File" || type === null) && name.endsWith(".db")) {
437 const candidates = yield* readFile(path).pipe(
438 Effect.catch(() => {
439 errors.push(path);
440 return Effect.succeed(null);
441 }),
442 );
443 if (candidates === null) continue;
384 const fileIndex = files.length;444 const fileIndex = files.length;
385 files.push({ root, path, records: [] });445 files.push({ root, path, records: [] });
386 for (const [index, candidate] of candidates.entries()) {446 for (const [index, candidate] of candidates.entries()) {
387 append(candidate, fileIndex);447 append(candidate, fileIndex);
388 if (index % 256 === 255) await NodeTimersPromises.setImmediate();448 if (index % 256 === 255) yield* Effect.yieldNow;
389 }449 }
390 } catch {
391 errors.push(path);
392 }450 }
393 }451 }
394 }452 });
395 };453 for (const root of roots) yield* walk(root, root);
396 for (const root of roots) await walk(root, root);
397 if (cache !== undefined) {454 if (cache !== undefined) {
398 for (const key of cache.keys()) if (!visited.has(key)) cache.delete(key);455 for (const key of cache.keys()) if (!visited.has(key)) cache.delete(key);
399 }456 }
...@@ -403,4 +460,4 @@ export async function readAntigravityUsage(...@@ -403,4 +460,4 @@ export async function readAntigravityUsage(
403 }460 }
404 }461 }
405 return { files, errors };462 return { files, errors };
406}463});
packages/provider-cursor/src/server/CursorAccountReader.ts+11-16
...@@ -5,9 +5,11 @@...@@ -5,9 +5,11 @@
5 * @module provider-cursor/server/CursorAccountReader5 * @module provider-cursor/server/CursorAccountReader
6 */6 */
7import * as Context from "effect/Context";7import * as Context from "effect/Context";
8import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as HttpClient from "effect/http/HttpClient";
9import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
10import * as Result from "effect/Result";
1113
12import type { CursorCredentialSource } from "./accountCache.ts";14import type { CursorCredentialSource } from "./accountCache.ts";
13import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts";15import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts";
...@@ -29,23 +31,16 @@ export const layer = Layer.effect(...@@ -29,23 +31,16 @@ export const layer = Layer.effect(
29 CursorAccountReader,31 CursorAccountReader,
30 Effect.gen(function* () {32 Effect.gen(function* () {
31 const keychain = yield* CursorKeychain.CursorKeychain;33 const keychain = yield* CursorKeychain.CursorKeychain;
34 const context = yield* Effect.context<
35 FileSystem.FileSystem | Crypto.Crypto | HttpClient.HttpClient
36 >();
32 return CursorAccountReader.of({37 return CursorAccountReader.of({
38 // Only a Keychain login asks the Keychain. The dashboard reader turns
39 // its outcome, a failure included, into the source's message.
33 read: (credentialSource, sinceMs, untilMs) =>40 read: (credentialSource, sinceMs, untilMs) =>
34 Effect.gen(function* () {41 readCursorAccountUsage(credentialSource, sinceMs, untilMs, keychain.accessToken).pipe(
35 // Only a Keychain login asks the Keychain. The dashboard reader42 Effect.provideContext(context),
36 // turns its outcome, a failure included, into the source's message.43 ),
37 const token =
38 typeof credentialSource === "string"
39 ? Result.succeed(null)
40 : yield* Effect.result(keychain.accessToken);
41 return yield* Effect.promise(() =>
42 readCursorAccountUsage(credentialSource, sinceMs, untilMs, globalThis.fetch, () =>
43 Result.isSuccess(token)
44 ? Promise.resolve(token.success)
45 : Promise.reject(token.failure),
46 ),
47 );
48 }),
49 });44 });
50 }),45 }),
51);46);
packages/provider-cursor/src/server/accountUsage.test.ts+379-308
...@@ -1,340 +1,411 @@...@@ -1,340 +1,411 @@
1// @effect-diagnostics nodeBuiltinImport:off - the reader under test reads a1import * as NodeServices from "@effect/platform-node/NodeServices";
2// real CLI credential file from disk.2import { assert, describe, it } from "@effect/vitest";
3import * as NodeFSP from "node:fs/promises";3import * as Context from "effect/Context";
4import * as NodeOS from "node:os";4import * as Effect from "effect/Effect";
5import * as NodePath from "node:path";5import * as FileSystem from "effect/FileSystem";
6import * as FetchHttpClient from "effect/http/FetchHttpClient";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientError from "effect/http/HttpClientError";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import * as HttpClientResponse from "effect/http/HttpClientResponse";
11import * as Path from "effect/Path";
612
7import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";13import * as CursorKeychain from "./CursorKeychain.ts";
8
9import { CursorKeychainTimeoutError } from "./CursorKeychain.ts";
10import { readCursorAccountUsage } from "./accountUsage.ts";14import { readCursorAccountUsage } from "./accountUsage.ts";
1115
12let dir: string;16/** A scoped temporary directory holding `auth.json` with `accessToken`. */
1317const writeAuth = (accessToken: string) =>
14beforeEach(async () => {18 Effect.gen(function* () {
15 dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-"));19 const fileSystem = yield* FileSystem.FileSystem;
16});20 const path = yield* Path.Path;
21 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
22 const authPath = path.join(dir, "auth.json");
23 yield* fileSystem.writeFileString(authPath, JSON.stringify({ accessToken }));
24 return { dir, authPath };
25 });
1726
18afterEach(async () => {27/** One dashboard request as Cursor sees it: the URL, the fetch options and the decoded page. */
19 await NodeFSP.rm(dir, { recursive: true, force: true });28interface DashboardRequest {
20});29 readonly url: string;
30 readonly init: RequestInit;
31 readonly page: number;
32 readonly headers: Headers;
33}
2134
22describe("Cursor account history", () => {35/** Stands in for Cursor's dashboard: `answer` replies to each page request. */
23 it("reads Cursor account history with the default macOS Keychain login", async () => {36const dashboard = (answer: (request: DashboardRequest) => Response | Promise<Response>) =>
24 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;37 HttpClient.make((request, url, signal, fiber) =>
25 let keychainReads = 0;38 Effect.tryPromise({
26 const result = await readCursorAccountUsage(39 try: async () => {
27 { kind: "keychain" },40 const init = Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit);
28 0,41 const body =
29 1781000000000,42 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
30 async (_url, init) => {43 const response = await answer({
31 assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A");44 url: url.toString(),
32 return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] });45 init: { ...init, signal, body },
33 },46 page: JSON.parse(body).page,
34 async () => {47 headers: new Headers(request.headers),
35 keychainReads++;48 });
36 return accessToken;49 return HttpClientResponse.fromWeb(request, response);
37 },50 },
38 );51 catch: (cause) =>
39 assert.strictEqual(keychainReads, 1);52 new HttpClientError.HttpClientError({
40 assert.isNull(result.error);53 reason: new HttpClientError.TransportError({ request, cause }),
41 assert.isFalse(result.missing);54 }),
42 assert.isNotNull(result.accountKey);55 }),
43 });56 );
4457
45 it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => {58/** Runs `readCursorAccountUsage` against `answer` standing in for Cursor. */
46 const authPath = NodePath.join(dir, "auth.json");59const readAccountUsage = (
47 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;60 credentialSource: Parameters<typeof readCursorAccountUsage>[0],
48 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));61 sinceMs: number,
49 const pages: number[] = [];62 endDate: number,
50 const signals: AbortSignal[] = [];63 answer: (request: DashboardRequest) => Response | Promise<Response>,
51 const request = async (url: string, init: RequestInit) => {64 keychainToken?: Parameters<typeof readCursorAccountUsage>[3],
52 assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events");65) =>
53 assert.strictEqual(init?.redirect, "error");66 readCursorAccountUsage(credentialSource, sinceMs, endDate, keychainToken).pipe(
54 const headers = new Headers(init?.headers);67 Effect.provideService(HttpClient.HttpClient, dashboard(answer)),
55 assert.strictEqual(headers.get("origin"), "https://cursor.com");68 );
56 assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A");
57 const body = JSON.parse(String(init?.body));
58 pages.push(body.page);
59 if (init.signal) signals.push(init.signal);
60 return Response.json({
61 totalUsageEventsCount: 1001,
62 usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({
63 timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000),
64 model: "claude-sonnet-4-5",
65 conversationId: `conversation-${body.page}`,
66 isHeadless: body.page === 2,
67 chargedCents: 0,
68 tokenUsage: {
69 inputTokens: 10,
70 outputTokens: 5,
71 cacheReadTokens: 30,
72 cacheWriteTokens: 2,
73 totalCents: 25,
74 },
75 })),
76 });
77 };
78 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request);
79 assert.isNull(result.error);
80 assert.deepStrictEqual(pages, [1, 2]);
81 assert.lengthOf(signals, 2);
82 assert.notStrictEqual(signals[0], signals[1]);
83 assert.strictEqual(result.records.length, 1001);
84 assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2");
85 assert.deepStrictEqual(result.records[0]?.totals, {
86 uncachedInputTokens: 10,
87 cachedInputTokens: 30,
88 cacheCreationTokens: 2,
89 outputTokens: 5,
90 reasoningTokens: 0,
91 });
92 assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25);
93 assert.isFalse(result.accountKey?.includes("demo") ?? true);
94 });
95
96 it("reads Cursor account history beyond 100 pages", async () => {
97 const authPath = NodePath.join(dir, "auth.json");
98 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
99 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));
100 const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null }));
101 let requests = 0;
102 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {
103 requests += 1;
104 return Response.json({
105 totalUsageEventsCount: 100_001,
106 usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }],
107 });
108 });
109 assert.isNull(result.error);
110 assert.strictEqual(requests, 101);
111 assert.deepStrictEqual(result.records, []);
112 });
11369
114 it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => {70describe("Cursor account history", () => {
115 const authPath = NodePath.join(dir, "auth.json");71 it.effect("reads Cursor account history with the default macOS Keychain login", () =>
116 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;72 Effect.gen(function* () {
117 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));73 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
118 for (const body of [74 let keychainReads = 0;
119 {},75 const result = yield* readAccountUsage(
120 { totalUsageEventsCount: 0 },76 { kind: "keychain" },
121 { totalUsageEventsCount: 0, usageEventsDisplay: [] },77 0,
122 ]) {78 1781000000000,
123 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>79 ({ headers }) => {
124 Response.json(body),80 assert.include(headers.get("cookie") ?? "", "demo%3A%3A");
81 return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] });
82 },
83 Effect.sync(() => {
84 keychainReads++;
85 return accessToken;
86 }),
125 );87 );
88 assert.strictEqual(keychainReads, 1);
126 assert.isNull(result.error);89 assert.isNull(result.error);
127 assert.deepStrictEqual(result.records, []);
128 assert.isFalse(result.missing);90 assert.isFalse(result.missing);
129 }91 assert.isNotNull(result.accountKey);
130 for (const body of [92 }).pipe(Effect.provide(NodeServices.layer)),
131 { error: "upstream error" },93 );
132 { detail: "unknown error envelope" },
133 { totalUsageEventsCount: 0, error: "upstream error" },
134 null,
135 [],
136 "invalid",
137 0,
138 ]) {
139 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>
140 Response.json(body),
141 );
142 assert.isNotNull(result.error);
143 assert.deepStrictEqual(result.records, []);
144 }
145 });
14694
147 it("requires a terminal Cursor page after a full page reaches the reported count", async () => {95 it.effect(
148 const authPath = NodePath.join(dir, "auth.json");96 "reads paginated Cursor account history including headless calls with separate cache tokens",
149 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;97 () =>
150 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));98 Effect.gen(function* () {
151 let requests = 0;99 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
152 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {100 const { authPath } = yield* writeAuth(accessToken);
153 requests++;101 const pages: number[] = [];
154 return Response.json(102 const signals: AbortSignal[] = [];
155 requests === 1103 const request = ({ url, init, headers, page }: DashboardRequest) => {
156 ? {104 assert.strictEqual(url, "https://cursor.com/api/dashboard/get-filtered-usage-events");
157 totalUsageEventsCount: 1000,105 assert.strictEqual(init.redirect, "error");
158 usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({106 assert.strictEqual(headers.get("origin"), "https://cursor.com");
159 timestamp: String(1780000000000 + index),107 assert.strictEqual(headers.get("content-type"), "application/json");
160 model: "gpt-5",108 assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A");
161 tokenUsage: { inputTokens: 10, outputTokens: 5 },109 const body = { page };
162 })),110 pages.push(body.page);
163 }111 if (init.signal) signals.push(init.signal);
164 : { totalUsageEventsCount: 1000 },112 return Response.json({
165 );113 totalUsageEventsCount: 1001,
166 });114 usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({
167 assert.isNull(result.error);115 timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000),
168 assert.strictEqual(result.records.length, 1000);116 model: "claude-sonnet-4-5",
169 assert.strictEqual(requests, 2);117 conversationId: `conversation-${body.page}`,
170 });118 isHeadless: body.page === 2,
119 chargedCents: 0,
120 tokenUsage: {
121 inputTokens: 10,
122 outputTokens: 5,
123 cacheReadTokens: 30,
124 cacheWriteTokens: 2,
125 totalCents: 25,
126 },
127 })),
128 });
129 };
130 const result = yield* readAccountUsage(authPath, 0, 1781000000000, request);
131 assert.isNull(result.error);
132 assert.deepStrictEqual(pages, [1, 2]);
133 assert.lengthOf(signals, 2);
134 assert.notStrictEqual(signals[0], signals[1]);
135 assert.strictEqual(result.records.length, 1001);
136 assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2");
137 assert.deepStrictEqual(result.records[0]?.totals, {
138 uncachedInputTokens: 10,
139 cachedInputTokens: 30,
140 cacheCreationTokens: 2,
141 outputTokens: 5,
142 reasoningTokens: 0,
143 });
144 assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25);
145 assert.isFalse(result.accountKey?.includes("demo") ?? true);
146 }).pipe(Effect.provide(NodeServices.layer)),
147 );
171148
172 it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => {149 it.effect("reads Cursor account history beyond 100 pages", () =>
173 const authPath = NodePath.join(dir, "auth.json");150 Effect.gen(function* () {
174 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;151 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
175 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));152 const { authPath } = yield* writeAuth(accessToken);
176 const event = (index: number) => ({153 const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null }));
177 timestamp: String(1780000000000 + index),
178 model: "gpt-5",
179 tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 },
180 });
181 for (const total of [2000, 2001]) {
182 let requests = 0;154 let requests = 0;
183 const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {155 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
184 requests++;156 requests += 1;
185 return Response.json({157 return Response.json({
186 totalUsageEventsCount: total,158 totalUsageEventsCount: 100_001,
187 usageEventsDisplay:159 usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }],
188 requests === 1
189 ? Array.from({ length: 1000 }, (_, index) => event(index))
190 : requests === 2
191 ? Array.from({ length: 1000 }, (_, index) => event(999 + index))
192 : [event(1999)],
193 });160 });
194 });161 });
195 assert.isNull(result.error);162 assert.isNull(result.error);
196 assert.strictEqual(result.records.length, total);163 assert.strictEqual(requests, 101);
197 assert.strictEqual(requests, 3);164 assert.deepStrictEqual(result.records, []);
198 assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999);165 }).pipe(Effect.provide(NodeServices.layer)),
199 assert.strictEqual(166 );
200 result.records.filter((record) => record.timestampMs === 1780000000999).length,167
201 total === 2000 ? 1 : 2,168 it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () =>
202 );169 Effect.gen(function* () {
203 assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total);170 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
204 }171 const { authPath } = yield* writeAuth(accessToken);
205 let requests = 0;172 for (const body of [
206 const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => {173 {},
207 requests++;174 { totalUsageEventsCount: 0 },
208 return Response.json({175 { totalUsageEventsCount: 0, usageEventsDisplay: [] },
209 totalUsageEventsCount: 1001,176 ]) {
210 usageEventsDisplay:177 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () =>
178 Response.json(body),
179 );
180 assert.isNull(result.error);
181 assert.deepStrictEqual(result.records, []);
182 assert.isFalse(result.missing);
183 }
184 for (const body of [
185 { error: "upstream error" },
186 { detail: "unknown error envelope" },
187 { totalUsageEventsCount: 0, error: "upstream error" },
188 null,
189 [],
190 "invalid",
191 0,
192 ]) {
193 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () =>
194 Response.json(body),
195 );
196 assert.isNotNull(result.error);
197 assert.deepStrictEqual(result.records, []);
198 }
199 }).pipe(Effect.provide(NodeServices.layer)),
200 );
201
202 it.effect("requires a terminal Cursor page after a full page reaches the reported count", () =>
203 Effect.gen(function* () {
204 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
205 const { authPath } = yield* writeAuth(accessToken);
206 let requests = 0;
207 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
208 requests++;
209 return Response.json(
211 requests === 1210 requests === 1
212 ? Array.from({ length: 1000 }, (_, index) => event(index))211 ? {
213 : [event(500), event(1000)],212 totalUsageEventsCount: 1000,
213 usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({
214 timestamp: String(1780000000000 + index),
215 model: "gpt-5",
216 tokenUsage: { inputTokens: 10, outputTokens: 5 },
217 })),
218 }
219 : { totalUsageEventsCount: 1000 },
220 );
214 });221 });
215 });222 assert.isNull(result.error);
216 assert.isNotNull(inconsistent.error);223 assert.strictEqual(result.records.length, 1000);
217 assert.deepStrictEqual(inconsistent.records, []);224 assert.strictEqual(requests, 2);
218 });225 }).pipe(Effect.provide(NodeServices.layer)),
226 );
219227
220 it("does not present truncated Cursor account pages or authentication failures as complete history", async () => {228 it.effect(
221 const authPath = NodePath.join(dir, "auth.json");229 "removes only count-proven Cursor boundary copies and preserves identical billed events",
222 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;230 () =>
223 await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken }));231 Effect.gen(function* () {
224 const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () =>232 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
225 Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }),233 const { authPath } = yield* writeAuth(accessToken);
226 );234 const event = (index: number) => ({
227 assert.isNotNull(truncated.error);235 timestamp: String(1780000000000 + index),
228 assert.deepStrictEqual(truncated.records, []);236 model: "gpt-5",
229 const denied = await readCursorAccountUsage(237 tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 },
230 authPath,238 });
231 0,239 for (const total of [2000, 2001]) {
232 1781000000000,240 let requests = 0;
233 async () => new Response(accessToken, { status: 401 }),241 const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
234 );242 requests++;
235 assert.isNotNull(denied.error);243 return Response.json({
236 assert.isFalse(denied.error?.includes(accessToken) ?? true);244 totalUsageEventsCount: total,
237 assert.deepStrictEqual(denied.records, []);245 usageEventsDisplay:
238 let requested = false;246 requests === 1
239 const missing = await readCursorAccountUsage(247 ? Array.from({ length: 1000 }, (_, index) => event(index))
240 NodePath.join(dir, "missing.json"),248 : requests === 2
241 0,249 ? Array.from({ length: 1000 }, (_, index) => event(999 + index))
242 1781000000000,250 : [event(1999)],
243 async () => {251 });
244 requested = true;252 });
245 return Response.json({});253 assert.isNull(result.error);
246 },254 assert.strictEqual(result.records.length, total);
247 );255 assert.strictEqual(requests, 3);
248 assert.isTrue(missing.missing);256 assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999);
249 assert.isFalse(requested);257 assert.strictEqual(
250 });258 result.records.filter((record) => record.timestampMs === 1780000000999).length,
259 total === 2000 ? 1 : 2,
260 );
261 assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total);
262 }
263 let requests = 0;
264 const inconsistent = yield* readAccountUsage(authPath, 0, 1781000000000, async () => {
265 requests++;
266 return Response.json({
267 totalUsageEventsCount: 1001,
268 usageEventsDisplay:
269 requests === 1
270 ? Array.from({ length: 1000 }, (_, index) => event(index))
271 : [event(500), event(1000)],
272 });
273 });
274 assert.isNotNull(inconsistent.error);
275 assert.deepStrictEqual(inconsistent.records, []);
276 }).pipe(Effect.provide(NodeServices.layer)),
277 );
278
279 it.effect(
280 "does not present truncated Cursor account pages or authentication failures as complete history",
281 () =>
282 Effect.gen(function* () {
283 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
284 const { dir, authPath } = yield* writeAuth(accessToken);
285 const truncated = yield* readAccountUsage(authPath, 0, 1781000000000, async () =>
286 Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }),
287 );
288 assert.isNotNull(truncated.error);
289 assert.deepStrictEqual(truncated.records, []);
290 const denied = yield* readAccountUsage(
291 authPath,
292 0,
293 1781000000000,
294 async () => new Response(accessToken, { status: 401 }),
295 );
296 assert.isNotNull(denied.error);
297 assert.isFalse(denied.error?.includes(accessToken) ?? true);
298 assert.deepStrictEqual(denied.records, []);
299 let requested = false;
300 const missing = yield* readAccountUsage(
301 (yield* Path.Path).join(dir, "missing.json"),
302 0,
303 1781000000000,
304 async () => {
305 requested = true;
306 return Response.json({});
307 },
308 );
309 assert.isTrue(missing.missing);
310 assert.isFalse(requested);
311 }).pipe(Effect.provide(NodeServices.layer)),
312 );
251});313});
252314
253describe("readCursorAccountUsage", () => {315describe("readCursorAccountUsage", () => {
254 it("asks for Keychain approval when the prompt goes unanswered", async () => {316 it.effect("asks for Keychain approval when the prompt goes unanswered", () =>
255 const result = await readCursorAccountUsage(317 Effect.gen(function* () {
256 { kind: "keychain" },318 const result = yield* readAccountUsage(
257 0,319 { kind: "keychain" },
258 1,320 0,
259 () => Promise.reject(new Error("no network expected")),321 1,
260 () => Promise.reject(new CursorKeychainTimeoutError()),322 () => {
261 );323 throw new Error("no network expected");
262 assert.deepStrictEqual(result, {324 },
263 accountKey: null,325 Effect.fail(new CursorKeychain.CursorKeychainTimeoutError()),
264 records: [],326 );
265 missing: false,327 assert.deepStrictEqual(result, {
266 error: "Allow Keychain access on the Mac running T3 Code, then refresh.",328 accountKey: null,
267 });329 records: [],
268 });330 missing: false,
269331 error: "Allow Keychain access on the Mac running T3 Code, then refresh.",
270 it("reads the pages behind the first together and keeps them in page order", async () => {332 });
271 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;333 }).pipe(Effect.provide(NodeServices.layer)),
272 let inFlight = 0;334 );
273 let mostInFlight = 0;
274 const result = await readCursorAccountUsage(
275 { kind: "keychain" },
276 0,
277 1781000000000,
278 async (_url, init) => {
279 const page: number = JSON.parse(String(init.body)).page;
280 inFlight++;
281 mostInFlight = Math.max(mostInFlight, inFlight);
282 // Later pages answer first, so record order cannot come from arrival order.
283 for (let turn = page; turn < 9; turn++) await Promise.resolve();
284 inFlight--;
285 return Response.json({
286 totalUsageEventsCount: 8001,
287 usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({
288 timestamp: String(1780000000000 + (page - 1) * 1000 + index),
289 model: "gpt-5",
290 tokenUsage: { inputTokens: 1 },
291 })),
292 });
293 },
294 async () => accessToken,
295 );
296 assert.isNull(result.error);
297 assert.strictEqual(mostInFlight, 6);
298 assert.deepStrictEqual(
299 result.records.map((record) => record.timestampMs),
300 Array.from({ length: 8001 }, (_, index) => 1780000000000 + index),
301 );
302 });
303335
304 it("ends like a page-by-page read when a page fails, leaving no request open", async () => {336 it.effect("reads the pages behind the first together and keeps them in page order", () =>
305 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;337 Effect.gen(function* () {
306 const fullPage = {338 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
307 totalUsageEventsCount: 8001,339 let inFlight = 0;
308 usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })),340 let mostInFlight = 0;
309 };341 const result = yield* readAccountUsage(
310 // Page 3 fails while the pages after it are still waiting on Cursor.
311 for (const [secondPage, error] of [
312 [() => Response.json(fullPage), "Cursor account usage could not be read."],
313 [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."],
314 ] as const) {
315 let open = 0;
316 const result = await readCursorAccountUsage(
317 { kind: "keychain" },342 { kind: "keychain" },
318 0,343 0,
319 1781000000000,344 1781000000000,
320 (_url, init) => {345 async ({ page }) => {
321 const page: number = JSON.parse(String(init.body)).page;346 inFlight++;
322 if (page === 1) return Promise.resolve(Response.json(fullPage));347 mostInFlight = Math.max(mostInFlight, inFlight);
323 if (page === 2) return Promise.resolve(secondPage());348 // Later pages answer first, so record order cannot come from arrival order.
324 if (page === 3) return Promise.reject(new Error("connection reset"));349 for (let turn = page; turn < 9; turn++) await Promise.resolve();
325 open++;350 inFlight--;
326 return new Promise((_resolve, reject) => {351 return Response.json({
327 init.signal?.addEventListener("abort", () => {352 totalUsageEventsCount: 8001,
328 open--;353 usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({
329 reject(init.signal?.reason);354 timestamp: String(1780000000000 + (page - 1) * 1000 + index),
330 });355 model: "gpt-5",
356 tokenUsage: { inputTokens: 1 },
357 })),
331 });358 });
332 },359 },
333 async () => accessToken,360 Effect.succeed(accessToken),
334 );361 );
335 assert.strictEqual(result.error, error);362 assert.isNull(result.error);
336 assert.deepStrictEqual(result.records, []);363 assert.strictEqual(mostInFlight, 6);
337 assert.strictEqual(open, 0);364 assert.deepStrictEqual(
338 }365 result.records.map((record) => record.timestampMs),
339 });366 Array.from({ length: 8001 }, (_, index) => 1780000000000 + index),
367 );
368 }).pipe(Effect.provide(NodeServices.layer)),
369 );
370
371 it.effect("ends like a page-by-page read when a page fails, leaving no request open", () =>
372 Effect.gen(function* () {
373 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
374 const fullPage = {
375 totalUsageEventsCount: 8001,
376 usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })),
377 };
378 // Page 3 fails while the pages after it are still waiting on Cursor.
379 for (const [secondPage, error] of [
380 [() => Response.json(fullPage), "Cursor account usage could not be read."],
381 [
382 () => new Response(null, { status: 401 }),
383 "Sign in to Cursor again to read account usage.",
384 ],
385 ] as const) {
386 let open = 0;
387 const result = yield* readAccountUsage(
388 { kind: "keychain" },
389 0,
390 1781000000000,
391 ({ init, page }) => {
392 if (page === 1) return Promise.resolve(Response.json(fullPage));
393 if (page === 2) return Promise.resolve(secondPage());
394 if (page === 3) return Promise.reject(new Error("connection reset"));
395 open++;
396 return new Promise((_resolve, reject) => {
397 init.signal?.addEventListener("abort", () => {
398 open--;
399 reject(init.signal?.reason);
400 });
401 });
402 },
403 Effect.succeed(accessToken),
404 );
405 assert.strictEqual(result.error, error);
406 assert.deepStrictEqual(result.records, []);
407 assert.strictEqual(open, 0);
408 }
409 }).pipe(Effect.provide(NodeServices.layer)),
410 );
340});411});
packages/provider-cursor/src/server/accountUsage.ts+201-115
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1// Node fs reads CLI credentials, and crypto hashes account IDs for deduplication.
2// @effect-diagnostics nodeBuiltinImport:off
3/**1/**
4 * Cursor account usage from its dashboard API, read with the saved CLI or2 * Cursor account usage from its dashboard API, read with the saved CLI or
5 * Keychain login. The dashboard covers CLI, desktop and headless usage from3 * Keychain login. The dashboard covers CLI, desktop and headless usage from
...@@ -7,13 +5,22 @@...@@ -7,13 +5,22 @@
7 *5 *
8 * @module provider-cursor/server/accountUsage6 * @module provider-cursor/server/accountUsage
9 */7 */
10import * as NodeFSP from "node:fs/promises";
11import * as NodeCrypto from "node:crypto";
12import * as NodeTimersPromises from "node:timers/promises";
13
14import type { UsageRecord } from "@t3tools/provider-core/server/usage";8import type { UsageRecord } from "@t3tools/provider-core/server/usage";
9import * as Crypto from "effect/Crypto";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Base64Url from "effect/encoding/Base64Url";
13import * as Hex from "effect/encoding/Hex";
14import * as Fiber from "effect/Fiber";
15import * as FileSystem from "effect/FileSystem";
16import * as FetchHttpClient from "effect/http/FetchHttpClient";
17import * as HttpClient from "effect/http/HttpClient";
18import * as HttpClientRequest from "effect/http/HttpClientRequest";
19import * as PlatformError from "effect/PlatformError";
20import * as Result from "effect/Result";
21import * as Schema from "effect/Schema";
1522
16import { isCursorKeychainTimeoutError } from "./CursorKeychain.ts";23import * as CursorKeychain from "./CursorKeychain.ts";
1724
18function object(value: unknown): Record<string, unknown> {25function object(value: unknown): Record<string, unknown> {
19 return typeof value === "object" && value !== null && !Array.isArray(value)26 return typeof value === "object" && value !== null && !Array.isArray(value)
...@@ -44,7 +51,27 @@ export interface CursorAccountUsageReadResult {...@@ -44,7 +51,27 @@ export interface CursorAccountUsageReadResult {
44 readonly error: string | null;51 readonly error: string | null;
45}52}
4653
47const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex");54/** Why a dashboard read was abandoned; the caller only sees the generic message. */
55class CursorAccountUsageInvalidError extends Schema.TaggedError<CursorAccountUsageInvalidError>()(
56 "CursorAccountUsageInvalidError",
57 { reason: Schema.String },
58) {}
59
60const invalid = (reason: string) => Effect.fail(new CursorAccountUsageInvalidError({ reason }));
61
62const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
63
64const pageRequestBody = (page: number, pageSize: number, sinceMs: number, endDate: number) =>
65 JSON.stringify({ page, pageSize, startDate: String(sinceMs), endDate: String(endDate) });
66
67const billedEventIdentity = (...fields: readonly unknown[]) => JSON.stringify(fields);
68
69const textEncoder = new TextEncoder();
70
71const accountHash = Effect.fn("accountHash")(function* (value: string) {
72 const crypto = yield* Crypto.Crypto;
73 return Hex.encode(yield* crypto.digest("SHA-256", textEncoder.encode(value)));
74});
4875
49function canonicalJson(value: unknown): string {76function canonicalJson(value: unknown): string {
50 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;77 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
...@@ -70,26 +97,54 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[]...@@ -70,26 +97,54 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[]
70 return lengths.at(-1) ?? 0;97 return lengths.at(-1) ?? 0;
71}98}
7299
100const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events";
101
102/** The session cookie must never follow a redirect off cursor.com. */
103const NO_REDIRECT: RequestInit = { redirect: "error" };
104
73/**105/**
74 * Dashboard usage includes headless agents and reports fresh input separately106 * Dashboard usage includes headless agents and reports fresh input separately
75 * from cache reads. `keychainToken` is only called for a Keychain login.107 * from cache reads. `keychainToken` is only run for a Keychain login.
76 */108 */
77export async function readCursorAccountUsage(109export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* (
78 credentialSource: string | { readonly kind: "keychain" },110 credentialSource: string | { readonly kind: "keychain" },
79 sinceMs: number,111 sinceMs: number,
80 endDate: number,112 endDate: number,
81 request: (url: string, init: RequestInit) => Promise<Response> = globalThis.fetch,113 keychainToken: Effect.Effect<
82 keychainToken: () => Promise<string | null> = () =>114 string | null,
83 Promise.reject(new Error("No Keychain reader")),115 | CursorKeychain.CursorKeychainTimeoutError
84): Promise<CursorAccountUsageReadResult> {116 | CursorKeychain.CursorKeychainReadError
85 let accessToken: unknown;117 | CursorAccountUsageInvalidError
86 try {118 > = invalid("No Keychain reader"),
87 accessToken =119): Effect.fn.Return<
88 typeof credentialSource === "string"120 CursorAccountUsageReadResult,
89 ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken121 never,
90 : await keychainToken();122 FileSystem.FileSystem | Crypto.Crypto | HttpClient.HttpClient
91 } catch (cause) {123> {
92 const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT";124 const fileSystem = yield* FileSystem.FileSystem;
125 const httpClient = yield* HttpClient.HttpClient;
126 const credential = yield* Effect.result<
127 unknown,
128 | PlatformError.PlatformError
129 | Schema.SchemaError
130 | CursorKeychain.CursorKeychainTimeoutError
131 | CursorKeychain.CursorKeychainReadError
132 | CursorAccountUsageInvalidError,
133 never
134 >(
135 typeof credentialSource === "string"
136 ? fileSystem.readFileString(credentialSource).pipe(
137 Effect.flatMap(decodeJson),
138 Effect.map((credentials) => object(credentials).accessToken),
139 )
140 : keychainToken,
141 );
142 if (Result.isFailure(credential)) {
143 const cause = credential.failure;
144 const missing =
145 typeof credentialSource === "string" &&
146 PlatformError.isPlatformError(cause) &&
147 cause.reason._tag === "NotFound";
93 return {148 return {
94 accountKey: null,149 accountKey: null,
95 records: [],150 records: [],
...@@ -98,11 +153,12 @@ export async function readCursorAccountUsage(...@@ -98,11 +153,12 @@ export async function readCursorAccountUsage(
98 ? null153 ? null
99 : typeof credentialSource === "string"154 : typeof credentialSource === "string"
100 ? "Cursor credentials could not be read."155 ? "Cursor credentials could not be read."
101 : isCursorKeychainTimeoutError(cause)156 : CursorKeychain.isCursorKeychainTimeoutError(cause)
102 ? "Allow Keychain access on the Mac running T3 Code, then refresh."157 ? "Allow Keychain access on the Mac running T3 Code, then refresh."
103 : "Cursor Keychain credentials could not be read.",158 : "Cursor Keychain credentials could not be read.",
104 };159 };
105 }160 }
161 const accessToken = credential.success;
106 if (typeof accessToken !== "string" || !accessToken) {162 if (typeof accessToken !== "string" || !accessToken) {
107 return {163 return {
108 accountKey: null,164 accountKey: null,
...@@ -115,95 +171,112 @@ export async function readCursorAccountUsage(...@@ -115,95 +171,112 @@ export async function readCursorAccountUsage(
115 };171 };
116 }172 }
117 let accountKey: string | null = null;173 let accountKey: string | null = null;
118 const cancel = new AbortController();174 const read = Effect.gen(function* () {
119 try {175 const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? "");
120 const payload = accessToken.split(".")[1];176 if (Result.isFailure(payload)) return yield* invalid("Invalid authentication");
121 const subject = object(177 const subject = object(
122 JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")),178 yield* decodeJson(payload.success).pipe(
179 Effect.catch(() => invalid("Invalid authentication")),
180 ),
123 ).sub;181 ).sub;
124 if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication");182 if (typeof subject !== "string" || !subject) return yield* invalid("Invalid authentication");
125 const userId = subject.split("|").at(-1);183 const userId = subject.split("|").at(-1);
126 if (!userId) throw new Error("Invalid authentication");184 if (!userId) return yield* invalid("Invalid authentication");
127 accountKey = accountHash(subject);185 accountKey = yield* accountHash(subject);
128 if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)186 if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
129 throw new Error("Invalid date window");187 return yield* invalid("Invalid date window");
130 const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]);
131 const records: UsageRecord[] = [];188 const records: UsageRecord[] = [];
132 const occurrences = new Map<string, number>();189 const occurrences = new Map<string, number>();
133 const pages: unknown[][] = [];190 const pages: unknown[][] = [];
134 let completed = false;191 let completed = false;
135 const pageSize = 1000;192 const pageSize = 1000;
136 let total: number | undefined;193 let total: number | undefined;
137 const readPage = async (page: number) => {194 const readPage = (page: number) =>
138 // A count can include overlapping page boundaries. Allow room to195 Effect.gen(function* () {
139 // reconcile them without imposing a fixed account-size limit.196 // A count can include overlapping page boundaries. Allow room to
140 if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {197 // reconcile them without imposing a fixed account-size limit.
141 throw new Error("Account usage page limit exceeded");198 if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {
142 }199 return yield* invalid("Account usage page limit exceeded");
143 const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", {200 }
144 method: "POST",201 const response = yield* httpClient
145 redirect: "error",202 .execute(
146 signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]),203 HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe(
147 headers: {204 HttpClientRequest.setHeaders({
148 "Content-Type": "application/json",205 Origin: "https://cursor.com",
149 Origin: "https://cursor.com",206 Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`,
150 Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`,207 }),
151 },208 HttpClientRequest.bodyText(
152 body: JSON.stringify({209 pageRequestBody(page, pageSize, sinceMs, endDate),
153 page,210 "application/json",
154 pageSize,211 ),
155 startDate: String(sinceMs),212 ),
156 endDate: String(endDate),213 )
214 .pipe(
215 Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT),
216 Effect.mapError(
217 () => new CursorAccountUsageInvalidError({ reason: "Account usage request failed" }),
218 ),
219 );
220 if (response.status === 401 || response.status === 403) {
221 return {
222 accountKey,
223 records: [],
224 missing: false,
225 error: "Sign in to Cursor again to read account usage.",
226 } satisfies CursorAccountUsageReadResult;
227 }
228 if (response.status < 200 || response.status >= 300)
229 return yield* invalid("Account usage request failed");
230 const parsed: unknown = yield* response.json.pipe(
231 Effect.mapError(
232 () => new CursorAccountUsageInvalidError({ reason: "Invalid account usage page" }),
233 ),
234 );
235 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
236 return yield* invalid("Invalid account usage page");
237 }
238 const body = object(parsed);
239 const keys = Object.keys(body);
240 if ("error" in body || "message" in body || "code" in body)
241 return yield* invalid("Account usage error response");
242 const count = keys.length === 0 ? 0 : body.totalUsageEventsCount;
243 const events =
244 keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount")
245 ? []
246 : body.usageEventsDisplay;
247 if (
248 (count !== undefined &&
249 (typeof count !== "number" ||
250 !Number.isSafeInteger(count) ||
251 count < 0 ||
252 (total !== undefined && count !== total))) ||
253 !Array.isArray(events) ||
254 events.length > pageSize ||
255 (count === undefined && !Array.isArray(body.usageEventsDisplay))
256 ) {
257 return yield* invalid("Inconsistent account usage page");
258 }
259 if (typeof count === "number") total = count;
260 return events as unknown[];
261 }).pipe(
262 Effect.timeoutOrElse({
263 duration: Duration.seconds(10),
264 orElse: () => invalid("Account usage request timed out"),
157 }),265 }),
158 });266 );
159 if (response.status === 401 || response.status === 403) {
160 return {
161 accountKey,
162 records: [],
163 missing: false,
164 error: "Sign in to Cursor again to read account usage.",
165 };
166 }
167 if (!response.ok) throw new Error("Account usage request failed");
168 const parsed: unknown = await response.json();
169 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
170 throw new Error("Invalid account usage page");
171 }
172 const body = object(parsed);
173 const keys = Object.keys(body);
174 if ("error" in body || "message" in body || "code" in body)
175 throw new Error("Account usage error response");
176 const count = keys.length === 0 ? 0 : body.totalUsageEventsCount;
177 const events =
178 keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount")
179 ? []
180 : body.usageEventsDisplay;
181 if (
182 (count !== undefined &&
183 (typeof count !== "number" ||
184 !Number.isSafeInteger(count) ||
185 count < 0 ||
186 (total !== undefined && count !== total))) ||
187 !Array.isArray(events) ||
188 events.length > pageSize ||
189 (count === undefined && !Array.isArray(body.usageEventsDisplay))
190 ) {
191 throw new Error("Inconsistent account usage page");
192 }
193 if (typeof count === "number") total = count;
194 return events;
195 };
196 // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages267 // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages
197 // the first one's count implies are requested up to six ahead and consumed in page order.268 // the first one's count implies are requested up to six ahead and consumed in page order.
198 const ahead: ReturnType<typeof readPage>[] = [];269 // Pages still in flight when the read ends are interrupted with its scope.
270 const ahead: Fiber.Fiber<
271 Effect.Success<ReturnType<typeof readPage>>,
272 CursorAccountUsageInvalidError
273 >[] = [];
199 for (let page = 1; ; page++) {274 for (let page = 1; ; page++) {
200 while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) {275 while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) {
201 const pending = readPage(page + ahead.length);276 ahead.push(yield* Effect.forkScoped(readPage(page + ahead.length)));
202 // Handled when its turn comes, or cancelled if the read ends first.
203 pending.catch(() => undefined);
204 ahead.push(pending);
205 }277 }
206 const events = await (ahead.shift() ?? readPage(page));278 const next = ahead.shift();
279 const events = yield* next === undefined ? readPage(page) : Fiber.join(next);
207 // A rejected login comes back as the finished result instead of a page.280 // A rejected login comes back as the finished result instead of a page.
208 if (!Array.isArray(events)) return events;281 if (!Array.isArray(events)) return events;
209 pages.push(events);282 pages.push(events);
...@@ -212,14 +285,17 @@ export async function readCursorAccountUsage(...@@ -212,14 +285,17 @@ export async function readCursorAccountUsage(
212 break;285 break;
213 }286 }
214 }287 }
215 if (!completed) throw new Error("Account usage page limit exceeded");288 if (!completed) return yield* invalid("Account usage page limit exceeded");
216 const rawCount = pages.reduce((sum, page) => sum + page.length, 0);289 const rawCount = pages.reduce((sum, page) => sum + page.length, 0);
217 if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages");290 if (total !== undefined && rawCount < total)
291 return yield* invalid("Incomplete account usage pages");
218 let removalsRemaining = total === undefined ? 0 : rawCount - total;292 let removalsRemaining = total === undefined ? 0 : rawCount - total;
219 let previousKeys: string[] = [];293 let previousKeys: string[] = [];
220 for (const events of pages) {294 for (const events of pages) {
221 const eventKeys =295 const eventKeys =
222 removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : [];296 removalsRemaining > 0
297 ? yield* Effect.forEach(events, (event) => accountHash(canonicalJson(event)))
298 : [];
223 const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys));299 const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys));
224 removalsRemaining -= removalCount;300 removalsRemaining -= removalCount;
225 previousKeys = eventKeys;301 previousKeys = eventKeys;
...@@ -239,7 +315,7 @@ export async function readCursorAccountUsage(...@@ -239,7 +315,7 @@ export async function readCursorAccountUsage(
239 value !== undefined &&315 value !== undefined &&
240 (typeof value !== "number" || !Number.isFinite(value) || value < 0)316 (typeof value !== "number" || !Number.isFinite(value) || value < 0)
241 ) {317 ) {
242 throw new Error("Invalid account usage totals");318 return yield* invalid("Invalid account usage totals");
243 }319 }
244 }320 }
245 const timestampMs =321 const timestampMs =
...@@ -252,7 +328,7 @@ export async function readCursorAccountUsage(...@@ -252,7 +328,7 @@ export async function readCursorAccountUsage(
252 typeof event.model !== "string" ||328 typeof event.model !== "string" ||
253 !event.model329 !event.model
254 )330 )
255 throw new Error("Invalid account usage event");331 return yield* invalid("Invalid account usage event");
256 if (timestampMs < sinceMs || timestampMs > endDate) continue;332 if (timestampMs < sinceMs || timestampMs > endDate) continue;
257 const totals = {333 const totals = {
258 uncachedInputTokens: tokens(usage.inputTokens),334 uncachedInputTokens: tokens(usage.inputTokens),
...@@ -265,8 +341,8 @@ export async function readCursorAccountUsage(...@@ -265,8 +341,8 @@ export async function readCursorAccountUsage(
265 typeof usage.totalCents === "number" ? usage.totalCents / 100 : null;341 typeof usage.totalCents === "number" ? usage.totalCents / 100 : null;
266 const sessionId = typeof event.conversationId === "string" ? event.conversationId : "";342 const sessionId = typeof event.conversationId === "string" ? event.conversationId : "";
267 // No event ID is provided. Preserve identical billed rows with an occurrence index.343 // No event ID is provided. Preserve identical billed rows with an occurrence index.
268 const key = accountHash(344 const key = yield* accountHash(
269 JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]),345 billedEventIdentity(timestampMs, event.model, sessionId, totals, reportedCostUsd),
270 );346 );
271 const occurrence = occurrences.get(key) ?? 0;347 const occurrence = occurrences.get(key) ?? 0;
272 occurrences.set(key, occurrence + 1);348 occurrences.set(key, occurrence + 1);
...@@ -282,19 +358,29 @@ export async function readCursorAccountUsage(...@@ -282,19 +358,29 @@ export async function readCursorAccountUsage(
282 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,358 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
283 });359 });
284 }360 }
285 await NodeTimersPromises.setImmediate();361 yield* Effect.yieldNow;
286 }362 }
287 if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries");363 if (removalsRemaining !== 0) return yield* invalid("Inconsistent account usage boundaries");
288 return { accountKey, records, missing: false, error: null };
289 } catch {
290 return {364 return {
291 accountKey,365 accountKey,
292 records: [],366 records,
293 missing: false,367 missing: false,
294 error: "Cursor account usage could not be read.",368 error: null,
295 };369 } satisfies CursorAccountUsageReadResult;
296 } finally {370 });
297 // An early exit leaves the pages requested ahead of it in flight.371 return yield* read.pipe(
298 cancel.abort();372 Effect.scoped,
299 }373 Effect.timeoutOrElse({
300}374 duration: Duration.seconds(60),
375 orElse: () => invalid("Account usage read timed out"),
376 }),
377 Effect.catch(() =>
378 Effect.succeed({
379 accountKey,
380 records: [],
381 missing: false,
382 error: "Cursor account usage could not be read.",
383 } satisfies CursorAccountUsageReadResult),
384 ),
385 );
386});
packages/provider-opencode/src/server/usage.test.ts+35-36
...@@ -1,31 +1,31 @@...@@ -1,31 +1,31 @@
1// @effect-diagnostics nodeBuiltinImport:off - the reader under test opens real1import * as NodeServices from "@effect/platform-node/NodeServices";
2// SQLite databases and walks a real legacy JSON store.2import { assert, describe, it } from "@effect/vitest";
3import * as NodeFSP from "node:fs/promises";3import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4import * as NodeOS from "node:os";4import * as Context from "effect/Context";
5import * as NodePath from "node:path";5import * as Effect from "effect/Effect";
6import * as NodeSqlite from "node:sqlite";6import * as FileSystem from "effect/FileSystem";
77import * as Layer from "effect/Layer";
8import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest";8import * as Path from "effect/Path";
9import * as SqlClient from "effect/sql/SqlClient";
910
10import { readOpenCodeUsage } from "./usage.ts";11import { readOpenCodeUsage } from "./usage.ts";
1112
12let dir: string;13/** A writable connection that stays open until the test's scope closes. */
1314const openDatabase = (filename: string) =>
14beforeEach(async () => {15 Layer.build(NodeSqliteClient.layer({ filename })).pipe(
15 dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-"));16 Effect.map(Context.get(SqlClient.SqlClient)),
16});17 );
17
18afterEach(async () => {
19 await NodeFSP.rm(dir, { recursive: true, force: true });
20});
2118
22describe("readOpenCodeUsage", () => {19describe("readOpenCodeUsage", () => {
23 it("counts migrated OpenCode messages once and sees subsequent WAL writes", async () => {20 it.effect("counts migrated OpenCode messages once and sees subsequent WAL writes", () =>
24 const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "opencode.db"));21 Effect.gen(function* () {
25 try {22 const fileSystem = yield* FileSystem.FileSystem;
26 db.exec(23 const path = yield* Path.Path;
27 "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)",24 const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" });
28 );25 const db = yield* openDatabase(path.join(dir, "opencode.db"));
26 yield* db.unsafe("PRAGMA journal_mode = WAL");
27 yield* db.unsafe("PRAGMA wal_autocheckpoint = 0");
28 yield* db.unsafe("CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)");
29 const message = {29 const message = {
30 id: "msg-1",30 id: "msg-1",
31 sessionID: "session-1",31 sessionID: "session-1",
...@@ -35,12 +35,13 @@ describe("readOpenCodeUsage", () => {...@@ -35,12 +35,13 @@ describe("readOpenCodeUsage", () => {
35 cost: 0.25,35 cost: 0.25,
36 tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } },36 tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } },
37 };37 };
38 const insert = db.prepare("INSERT INTO message VALUES (?, ?, ?)");38 const insert = (id: string, sessionId: string, data: string) =>
39 insert.run(message.id, message.sessionID, JSON.stringify(message));39 db.unsafe("INSERT INTO message VALUES (?, ?, ?)", [id, sessionId, data]);
40 const legacy = NodePath.join(dir, "storage", "message", message.sessionID);40 yield* insert(message.id, message.sessionID, JSON.stringify(message));
41 await NodeFSP.mkdir(legacy, { recursive: true });41 const legacy = path.join(dir, "storage", "message", message.sessionID);
42 await NodeFSP.writeFile(NodePath.join(legacy, "msg-1.json"), JSON.stringify(message));42 yield* fileSystem.makeDirectory(legacy, { recursive: true });
43 const first = await readOpenCodeUsage(dir, 0);43 yield* fileSystem.writeFileString(path.join(legacy, "msg-1.json"), JSON.stringify(message));
44 const first = yield* readOpenCodeUsage(dir, 0);
44 assert.isFalse(first.error);45 assert.isFalse(first.error);
45 const records = first.files.flatMap((file) => file.records);46 const records = first.files.flatMap((file) => file.records);
46 assert.strictEqual(records.length, 1);47 assert.strictEqual(records.length, 1);
...@@ -52,20 +53,18 @@ describe("readOpenCodeUsage", () => {...@@ -52,20 +53,18 @@ describe("readOpenCodeUsage", () => {
52 reasoningTokens: 5,53 reasoningTokens: 5,
53 });54 });
54 assert.strictEqual(records[0]?.reportedCostUsd, 0.25);55 assert.strictEqual(records[0]?.reportedCostUsd, 0.25);
55 insert.run(56 yield* insert(
56 "msg-2",57 "msg-2",
57 message.sessionID,58 message.sessionID,
58 JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }),59 JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }),
59 );60 );
60 const next = await readOpenCodeUsage(dir, 1780000001000);61 const next = yield* readOpenCodeUsage(dir, 1780000001000);
61 assert.isFalse(next.error);62 assert.isFalse(next.error);
62 assert.deepStrictEqual(63 assert.deepStrictEqual(
63 next.files.flatMap((file) => file.records).map((record) => record.dedupeKey),64 next.files.flatMap((file) => file.records).map((record) => record.dedupeKey),
64 ["opencode:msg-2"],65 ["opencode:msg-2"],
65 );66 );
66 assert.isAbove((await NodeFSP.stat(NodePath.join(dir, "opencode.db-wal"))).size, 0);67 assert.isAbove(Number((yield* fileSystem.stat(path.join(dir, "opencode.db-wal"))).size), 0);
67 } finally {68 }).pipe(Effect.provide(NodeServices.layer)),
68 db.close();69 );
69 }
70 });
71});70});
packages/provider-opencode/src/server/usage.ts+107-61
...@@ -1,16 +1,10 @@...@@ -1,16 +1,10 @@
1// node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history.
2// @effect-diagnostics nodeBuiltinImport:off
3/**1/**
4 * Usage history for OpenCode, read from its SQLite databases and legacy JSON2 * Usage history for OpenCode, read from its SQLite databases and legacy JSON
5 * message store under each data directory.3 * message store under each data directory.
6 *4 *
7 * @module provider-opencode/server/usage5 * @module provider-opencode/server/usage
8 */6 */
9import * as NodeFSP from "node:fs/promises";
10import * as NodeOS from "node:os";7import * as NodeOS from "node:os";
11import * as NodePath from "node:path";
12import * as NodeSqlite from "node:sqlite";
13import * as NodeTimersPromises from "node:timers/promises";
148
15import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";9import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
16import {10import {
...@@ -19,9 +13,13 @@ import {...@@ -19,9 +13,13 @@ import {
19 type UsageRecord,13 type UsageRecord,
20} from "@t3tools/provider-core/server/usage";14} from "@t3tools/provider-core/server/usage";
21import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";15import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
16import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
22import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
18import * as Exit from "effect/Exit";
23import * as FileSystem from "effect/FileSystem";19import * as FileSystem from "effect/FileSystem";
24import * as Path from "effect/Path";20import * as Path from "effect/Path";
21import type * as PlatformError from "effect/PlatformError";
22import * as SqlClient from "effect/sql/SqlClient";
2523
26import type { OpenCodeSettings } from "../settings.ts";24import type { OpenCodeSettings } from "../settings.ts";
2725
...@@ -93,11 +91,23 @@ export interface OpenCodeUsageReadResult {...@@ -93,11 +91,23 @@ export interface OpenCodeUsageReadResult {
93 readonly error: boolean;91 readonly error: boolean;
94}92}
9593
94const isNotFound = (cause: PlatformError.PlatformError) => cause.reason._tag === "NotFound";
95
96/** A regular file or directory, never a symlink to one. */
97const entryType = Effect.fn("entryType")(function* (path: string) {
98 const fileSystem = yield* FileSystem.FileSystem;
99 const link = yield* Effect.exit(fileSystem.readLink(path));
100 if (Exit.isSuccess(link)) return "SymbolicLink" as const;
101 return (yield* fileSystem.stat(path)).type;
102});
103
96/** Reads current SQLite and pre-migration JSON stores without modifying either. */104/** Reads current SQLite and pre-migration JSON stores without modifying either. */
97export async function readOpenCodeUsage(105export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* (
98 root: string,106 root: string,
99 sinceMs: number,107 sinceMs: number,
100): Promise<OpenCodeUsageReadResult> {108): Effect.fn.Return<OpenCodeUsageReadResult, never, FileSystem.FileSystem | Path.Path> {
109 const fileSystem = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;
101 const files: { path: string; records: UsageRecord[] }[] = [];111 const files: { path: string; records: UsageRecord[] }[] = [];
102 const seen = new Set<string>();112 const seen = new Set<string>();
103 let found = false;113 let found = false;
...@@ -111,49 +121,69 @@ export async function readOpenCodeUsage(...@@ -111,49 +121,69 @@ export async function readOpenCodeUsage(
111 records.push(record);121 records.push(record);
112 };122 };
113123
114 let databases: string[] = [];124 const databases = yield* fileSystem.readDirectory(root).pipe(
115 try {125 Effect.flatMap((names) =>
116 databases = (await NodeFSP.readdir(root, { withFileTypes: true }))126 Effect.filter(
117 .filter((entry) => entry.isFile() && /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(entry.name))127 names.filter((name) => /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(name)),
118 .map((entry) => entry.name)128 // One database removed between listing and stat must not hide the rest.
119 .sort((a, b) => (a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b)));129 (name) =>
120 } catch (cause) {130 entryType(path.join(root, name)).pipe(
121 if (object(cause).code !== "ENOENT") error = true;131 Effect.map((type) => type === "File"),
122 }132 Effect.catchTags({
133 PlatformError: (cause) =>
134 isNotFound(cause) ? Effect.succeed(false) : Effect.fail(cause),
135 }),
136 ),
137 ),
138 ),
139 Effect.map((names) =>
140 names.sort((a, b) =>
141 a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b),
142 ),
143 ),
144 Effect.catchTags({
145 PlatformError: (cause) => {
146 if (!isNotFound(cause)) error = true;
147 return Effect.succeed([]);
148 },
149 }),
150 );
123 for (const name of databases) {151 for (const name of databases) {
124 found = true;152 found = true;
125 const file = { path: NodePath.join(root, name), records: [] as UsageRecord[] };153 const file = { path: path.join(root, name), records: [] as UsageRecord[] };
126 files.push(file);154 files.push(file);
127 let database: NodeSqlite.DatabaseSync | undefined;155 yield* Effect.gen(function* () {
128 try {156 const sql = yield* SqlClient.SqlClient;
129 database = new NodeSqlite.DatabaseSync(NodePath.join(root, name), { readOnly: true });
130 // A busy live provider should fail this source promptly rather than157 // A busy live provider should fail this source promptly rather than
131 // stalling the server while SQLite waits for its writer.158 // stalling the server while SQLite waits for its writer.
132 database.exec("PRAGMA busy_timeout = 100");159 yield* sql.unsafe("PRAGMA busy_timeout = 100");
133 const tables = new Set(160 const tables = new Set(
134 database161 (yield* sql.unsafe<{ readonly name: unknown }>(
135 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")162 "SELECT name FROM sqlite_master WHERE type = 'table'",
136 .all()163 )).map((row) => row.name),
137 .map((row) => row.name),
138 );164 );
139 if (!tables.has("message") && !tables.has("session_message")) error = true;165 if (!tables.has("message") && !tables.has("session_message")) error = true;
140 for (const table of ["message", "session_message"] as const) {166 for (const table of ["message", "session_message"] as const) {
141 if (!tables.has(table)) continue;167 if (!tables.has(table)) continue;
142 const columns = new Set(168 const columns = new Set(
143 database169 (yield* sql.unsafe<{ readonly name: unknown }>(`PRAGMA table_info(${table})`)).map(
144 .prepare(`PRAGMA table_info(${table})`)170 (row) => row.name,
145 .all()171 ),
146 .map((row) => row.name),
147 );172 );
148 const timestamp = columns.has("time_created") ? "time_created" : "NULL";173 const timestamp = columns.has("time_created") ? "time_created" : "NULL";
149 const predicates = table === "session_message" ? ["type = 'assistant'"] : [];174 const predicates = table === "session_message" ? ["type = 'assistant'"] : [];
150 if (timestamp !== "NULL") predicates.push("time_created >= ?");175 if (timestamp !== "NULL") predicates.push("time_created >= ?");
151 const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : "";176 const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : "";
152 const statement = database.prepare(177 const rows = yield* sql.unsafe<{
178 readonly id: unknown;
179 readonly session_id: unknown;
180 readonly data: unknown;
181 readonly created: unknown;
182 }>(
153 `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`,183 `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`,
184 timestamp === "NULL" ? [] : [sinceMs],
154 );185 );
155 let count = 0;186 for (const [index, row] of rows.entries()) {
156 for (const row of statement.iterate(...(timestamp === "NULL" ? [] : [sinceMs]))) {
157 append(187 append(
158 file.records,188 file.records,
159 parseOpenCodeMessage(text(row.data), {189 parseOpenCodeMessage(text(row.data), {
...@@ -162,48 +192,64 @@ export async function readOpenCodeUsage(...@@ -162,48 +192,64 @@ export async function readOpenCodeUsage(
162 ...(typeof row.created === "number" ? { timestampMs: row.created } : {}),192 ...(typeof row.created === "number" ? { timestampMs: row.created } : {}),
163 }),193 }),
164 );194 );
165 if (++count % 256 === 0) await NodeTimersPromises.setImmediate();195 if (index % 256 === 255) yield* Effect.yieldNow;
166 }196 }
167 }197 }
168 } catch {198 }).pipe(
169 error = true;199 Effect.provide(NodeSqliteClient.layer({ filename: file.path, readonly: true })),
170 } finally {200 Effect.catchTags({
171 database?.close();201 SqlError: () => {
172 }202 error = true;
203 return Effect.void;
204 },
205 }),
206 );
173 }207 }
174208
175 // Do not follow symlinks, including cycles. Database records win over their209 // Do not follow symlinks, including cycles. Database records win over their
176 // old JSON copies when OpenCode has migrated a store in place.210 // old JSON copies when OpenCode has migrated a store in place.
177 const directories = [NodePath.join(root, "storage", "message")];211 const directories = [path.join(root, "storage", "message")];
178 while (directories.length > 0) {212 while (directories.length > 0) {
179 const directory = directories.pop()!;213 const directory = directories.pop()!;
180 try {214 yield* Effect.gen(function* () {
181 for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) {215 for (const name of yield* fileSystem.readDirectory(directory)) {
182 const path = NodePath.join(directory, entry.name);216 const entry = path.join(directory, name);
183 if (entry.isDirectory()) {217 const type = yield* entryType(entry).pipe(
184 directories.push(path);218 Effect.catchTags({
185 } else if (entry.isFile() && entry.name.endsWith(".json")) {219 PlatformError: (cause) =>
220 isNotFound(cause) ? Effect.succeed(null) : Effect.fail(cause),
221 }),
222 );
223 if (type === "Directory") {
224 directories.push(entry);
225 } else if (type === "File" && name.endsWith(".json")) {
186 found = true;226 found = true;
187 const id = entry.name.slice(0, -5);227 const id = name.slice(0, -5);
188 if (seen.has(`opencode:${id}`)) continue;228 if (seen.has(`opencode:${id}`)) continue;
189 const file = { path, records: [] as UsageRecord[] };229 const file = { path: entry, records: [] as UsageRecord[] };
190 files.push(file);230 files.push(file);
191 try {231 yield* fileSystem.readFileString(entry).pipe(
192 append(232 Effect.map((source) => append(file.records, parseOpenCodeMessage(source, { id }))),
193 file.records,233 Effect.catchTags({
194 parseOpenCodeMessage(await NodeFSP.readFile(path, "utf8"), { id }),234 PlatformError: (cause) => {
195 );235 if (!isNotFound(cause)) error = true;
196 } catch (cause) {236 return Effect.void;
197 if (object(cause).code !== "ENOENT") error = true;237 },
198 }238 }),
239 );
199 }240 }
200 }241 }
201 } catch (cause) {242 }).pipe(
202 if (object(cause).code !== "ENOENT") error = true;243 Effect.catchTags({
203 }244 PlatformError: (cause) => {
245 if (!isNotFound(cause)) error = true;
246 return Effect.void;
247 },
248 }),
249 );
204 }250 }
205 return { files, missing: !found && !error, error };251 return { files, missing: !found && !error, error };
206}252});
207253
208/**254/**
209 * The data directories to read: `OPENCODE_DATA_DIR` (comma-separated) or the255 * The data directories to read: `OPENCODE_DATA_DIR` (comma-separated) or the
...@@ -244,7 +290,7 @@ export const openCodeUsageReader: ProviderUsageReader<OpenCodeSettings, OpenCode...@@ -244,7 +290,7 @@ export const openCodeUsageReader: ProviderUsageReader<OpenCodeSettings, OpenCode
244 return yield* Effect.forEach(290 return yield* Effect.forEach(
245 roots,291 roots,
246 (dir) =>292 (dir) =>
247 Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)).pipe(293 readOpenCodeUsage(dir, windowStartMs).pipe(
248 Effect.map((result) => ({294 Effect.map((result) => ({
249 dir,295 dir,
250 files: result.missing && !result.error ? null : result.files,296 files: result.missing && !result.error ? null : result.files,