@hank-warren/pi-stats 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/cache.ts +113 -35
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @hank-warren/pi-stats
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - c079c51: Scan sessions through a bounded worker pool instead of one file at a time.
8
+
9
+ The stats scan now runs an ordered pool capped at eight concurrent readers, reduced by the discovery index, which shortens a cold scan over a large session history. Results stay in discovery order regardless of completion order, and progress is reported monotonically.
10
+
11
+ Cancellation is settled rather than abandoned: an abort stops further work being assigned and publishes nothing afterwards, but waits for reads already in flight, so the caller's `catch` never runs while file reads are still landing in the result array or progress is still being reported into a dashboard that is being torn down.
12
+
3
13
  ## 0.4.0
4
14
 
5
15
  ### Minor Changes
package/cache.ts CHANGED
@@ -240,6 +240,64 @@ export function statsCachePath(agentDir: string): string {
240
240
  return join(agentDir, "pi-stats", "cache.json");
241
241
  }
242
242
 
243
+ export async function mapConcurrentOrdered<T, R>(
244
+ items: readonly T[],
245
+ limit: number,
246
+ worker: (item: T, index: number) => Promise<R>,
247
+ onProgress?: (completed: number, total: number) => void,
248
+ signal?: AbortSignal,
249
+ ): Promise<R[]> {
250
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("concurrency limit must be positive");
251
+ const results = new Array<R>(items.length);
252
+ let nextIndex = 0;
253
+ let completed = 0;
254
+ // The first failure (an abort, a worker throw, or a progress callback that
255
+ // throws) stops work being assigned and stops anything else being published,
256
+ // but never abandons work already in flight: the caller's `catch` must not
257
+ // run while file reads are still landing in `results` and progress is still
258
+ // being reported into a widget that is about to be torn down.
259
+ let failure: unknown;
260
+ const failed = () => failure !== undefined;
261
+ const run = async () => {
262
+ while (!failed()) {
263
+ try {
264
+ signal?.throwIfAborted();
265
+ } catch (error) {
266
+ failure ??= error;
267
+ return;
268
+ }
269
+ const index = nextIndex;
270
+ if (index >= items.length) return;
271
+ nextIndex += 1;
272
+ try {
273
+ const value = await worker(items[index]!, index);
274
+ // Re-check *after* the await: an abort during the read must not still
275
+ // publish a result or a progress tick into a dashboard that is being
276
+ // torn down. A sibling runner may not have noticed the abort yet.
277
+ signal?.throwIfAborted();
278
+ if (failed()) return;
279
+ results[index] = value;
280
+ completed += 1;
281
+ onProgress?.(completed, items.length);
282
+ } catch (error) {
283
+ failure ??= error;
284
+ return;
285
+ }
286
+ }
287
+ };
288
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
289
+ if (failed()) throw failure;
290
+ return results;
291
+ }
292
+
293
+ type ScannedFile = {
294
+ file: string;
295
+ record?: CachedFileRecord;
296
+ reused: boolean;
297
+ unreadable: boolean;
298
+ changed: boolean;
299
+ };
300
+
243
301
  export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
244
302
  const roots = await resolveSessionRoots(options);
245
303
  const sessionFiles = await discoverJsonlFiles(roots, options.signal);
@@ -260,45 +318,65 @@ export async function scanStats(options: ScanOptions): Promise<StatsIndex> {
260
318
  };
261
319
  let changed = options.force === true || !loaded.valid;
262
320
 
263
- for (let index = 0; index < files.length; index++) {
264
- options.signal?.throwIfAborted();
265
- const file = files[index]!;
266
- try {
267
- const metadata = await stat(file);
268
- const previous = loaded.cache.files[file];
269
- if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
270
- next.files[file] = previous!;
271
- diagnostics.reusedFiles++;
272
- } else if (sidecars.has(file)) {
273
- const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
274
- next.files[file] = {
275
- size: metadata.size,
276
- mtimeMs: metadata.mtimeMs,
277
- sidecar: parsed.records,
278
- malformedLines: parsed.malformedLines,
279
- };
280
- diagnostics.parsedFiles++;
281
- changed = true;
282
- } else {
321
+ const scanned = await mapConcurrentOrdered(
322
+ files,
323
+ 8,
324
+ async (file): Promise<ScannedFile> => {
325
+ options.signal?.throwIfAborted();
326
+ try {
327
+ const metadata = await stat(file);
328
+ const previous = loaded.cache.files[file];
329
+ if (!options.force && sameFile(previous, metadata.size, metadata.mtimeMs)) {
330
+ return { file, record: previous!, reused: true, unreadable: false, changed: false };
331
+ }
332
+ if (sidecars.has(file)) {
333
+ const parsed = parseUsageSidecar(file, await readFile(file, "utf8"));
334
+ return {
335
+ file,
336
+ record: {
337
+ size: metadata.size,
338
+ mtimeMs: metadata.mtimeMs,
339
+ sidecar: parsed.records,
340
+ malformedLines: parsed.malformedLines,
341
+ },
342
+ reused: false,
343
+ unreadable: false,
344
+ changed: true,
345
+ };
346
+ }
283
347
  const parsed = parseSessionText(file, await readFile(file, "utf8"));
284
- next.files[file] = {
285
- size: metadata.size,
286
- mtimeMs: metadata.mtimeMs,
287
- ...(parsed.ignored ? { ignored: true as const } : {}),
288
- ...(parsed.session ? { session: parsed.session } : {}),
289
- malformedLines: parsed.malformedLines,
348
+ return {
349
+ file,
350
+ record: {
351
+ size: metadata.size,
352
+ mtimeMs: metadata.mtimeMs,
353
+ ...(parsed.ignored ? { ignored: true as const } : {}),
354
+ ...(parsed.session ? { session: parsed.session } : {}),
355
+ malformedLines: parsed.malformedLines,
356
+ },
357
+ reused: false,
358
+ unreadable: false,
359
+ changed: true,
290
360
  };
291
- diagnostics.parsedFiles++;
292
- changed = true;
361
+ } catch {
362
+ options.signal?.throwIfAborted();
363
+ return { file, reused: false, unreadable: true, changed: true };
293
364
  }
294
- const current = next.files[file]!;
295
- if (current.ignored) diagnostics.ignoredFiles++;
296
- diagnostics.malformedLines += current.malformedLines;
297
- } catch {
298
- diagnostics.unreadableFiles++;
299
- changed = true;
365
+ },
366
+ options.onProgress,
367
+ options.signal,
368
+ );
369
+
370
+ for (const result of scanned) {
371
+ if (result.record) {
372
+ next.files[result.file] = result.record;
373
+ if (result.reused) diagnostics.reusedFiles++;
374
+ else diagnostics.parsedFiles++;
375
+ if (result.record.ignored) diagnostics.ignoredFiles++;
376
+ diagnostics.malformedLines += result.record.malformedLines;
300
377
  }
301
- options.onProgress?.(index + 1, files.length);
378
+ if (result.unreadable) diagnostics.unreadableFiles++;
379
+ if (result.changed) changed = true;
302
380
  }
303
381
 
304
382
  if (Object.keys(loaded.cache.files).some((file) => !(file in next.files))) changed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-stats",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
5
5
  "type": "module",
6
6
  "keywords": [