@cad0p/pi-usage 0.11.3 → 0.11.4

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.
@@ -0,0 +1,1160 @@
1
+ /**
2
+ * Durable token history.
3
+ *
4
+ * The per-file scan cache under `~/.cache/pi-quota/` is disposable: it exists to
5
+ * avoid re-reading session bytes. The history here is not: once a local day is
6
+ * over it is frozen, and from then on it belongs to this ledger, not to the
7
+ * session files. Deleting a session, clearing the cache or losing today's
8
+ * numbers never rewrites a frozen day.
9
+ *
10
+ * Layout, under `${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-usage/` (Pi's own data
11
+ * directory, so token history travels with the agent installation):
12
+ *
13
+ * totals.json constant-size all-time rollup: per family and account,
14
+ * per-model buckets, prompt and session totals, active
15
+ * dates, first day, and `lastFrozenDay` — the watermark
16
+ * that makes a restart never add a day twice.
17
+ * tokens-YYYY-MM.json one file per local month: day -> family -> slot ->
18
+ * model. SET semantics per day, so re-freezing a day
19
+ * after a crash is idempotent. A run reads only the
20
+ * months covering the last seven days.
21
+ *
22
+ * Freezing order is month file first, then totals. A crash in between leaves
23
+ * `lastFrozenDay` behind, so the next run re-freezes the day from the open-day
24
+ * cache (which is only pruned after the totals write) and the SET write makes
25
+ * that harmless. Late writes to an already-frozen day (a session that appends
26
+ * past midnight) increment both the month file and the totals directly; a crash
27
+ * between those two writes can double that small delta, which is the one
28
+ * non-idempotent window in this design.
29
+ *
30
+ * A lost or corrupt totals file is moved aside as `totals.json.corrupt-<ts>`
31
+ * and rebuilt from the month files, never from sessions. Corrupt or unreadable
32
+ * month files block that rebuild so their counts cannot be silently discarded.
33
+ * `piquota stats --rebuild-totals` also refuses an incomplete month set.
34
+ */
35
+
36
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { dirname, join, resolve } from "node:path";
39
+
40
+ import { redact } from "../http.js";
41
+
42
+ /** Version of the durable formats; a foreign version is treated as corruption. */
43
+ export const HISTORY_SCHEMA_VERSION = 1;
44
+
45
+ const MONTH_FILE = /^tokens-(\d{4}-\d{2})\.json$/;
46
+
47
+ /**
48
+ * @typedef {Object} Bucket
49
+ * @property {number} input
50
+ * @property {number} output
51
+ * @property {number} cacheRead
52
+ * @property {number} cacheWrite
53
+ * @property {number} messages
54
+ */
55
+
56
+ /**
57
+ * One account's row for a day: distinct session files plus per-model buckets.
58
+ *
59
+ * @typedef {Object} SlotRow
60
+ * @property {number} sessions
61
+ * @property {Record<string, Bucket>} models
62
+ */
63
+
64
+ /**
65
+ * One family's row for a day.
66
+ *
67
+ * @typedef {Object} FamilyRow
68
+ * @property {number} sessions
69
+ * @property {Record<string, SlotRow>} slots
70
+ */
71
+
72
+ /** One day's rows, keyed by family. */
73
+ /** @typedef {Record<string, FamilyRow>} DayRows */
74
+
75
+ /**
76
+ * @typedef {Object} FamilyTotals
77
+ * @property {string} firstDay
78
+ * @property {Set<string>} activeDates
79
+ * @property {number} totalPrompts
80
+ * @property {number} totalSessions
81
+ * @property {Map<string, Bucket>} models
82
+ * @property {Map<string, AccountTotals>} accounts
83
+ */
84
+
85
+ /**
86
+ * @typedef {Object} AccountTotals
87
+ * @property {number} totalPrompts
88
+ * @property {number} totalSessions
89
+ * @property {Map<string, Bucket>} models
90
+ */
91
+
92
+ /**
93
+ * @typedef {Object} Totals
94
+ * @property {string} lastFrozenDay
95
+ * @property {Map<string, FamilyTotals>} families
96
+ */
97
+
98
+ /**
99
+ * Pi's agent directory: `PI_CODING_AGENT_DIR` wins, otherwise `~/.pi/agent`.
100
+ * The session scanner resolves its roots through the same value.
101
+ *
102
+ * @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
103
+ * @returns {string}
104
+ */
105
+ export function resolveAgentDir(options = {}) {
106
+ const env = options.env ?? process.env;
107
+ const home = options.home ?? homedir();
108
+ return env.PI_CODING_AGENT_DIR ? resolve(env.PI_CODING_AGENT_DIR) : join(home, ".pi", "agent");
109
+ }
110
+
111
+ /**
112
+ * Where the durable history lives. `historyDir` is the test seam: the suite
113
+ * must never write into the real `~/.pi`.
114
+ *
115
+ * @param {{ env?: Record<string, string | undefined>, home?: string, historyDir?: string }} [options]
116
+ * @returns {string}
117
+ */
118
+ export function resolveHistoryDir(options = {}) {
119
+ return options.historyDir ?? join(resolveAgentDir(options), "pi-usage");
120
+ }
121
+
122
+ /**
123
+ * @param {string} historyDir
124
+ * @returns {string}
125
+ */
126
+ export function resolveTotalsPath(historyDir) {
127
+ return join(historyDir, "totals.json");
128
+ }
129
+
130
+ /**
131
+ * @param {string} historyDir
132
+ * @param {string} month YYYY-MM
133
+ * @returns {string}
134
+ */
135
+ export function resolveMonthPath(historyDir, month) {
136
+ return join(historyDir, `tokens-${month}.json`);
137
+ }
138
+
139
+ /**
140
+ * @param {string} historyDir
141
+ * @param {string} month
142
+ * @returns {string | null}
143
+ */
144
+ function quarantinedMonthPath(historyDir, month) {
145
+ try {
146
+ const prefix = `tokens-${month}.json.corrupt-`;
147
+ const name = readdirSync(historyDir).find((entry) => entry.startsWith(prefix));
148
+ return name ? join(historyDir, name) : null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Whether the history directory holds any frozen month file at all.
156
+ *
157
+ * @param {string} historyDir
158
+ * @returns {boolean}
159
+ */
160
+ export function hasMonthFiles(historyDir) {
161
+ try {
162
+ return readdirSync(historyDir).some((name) => MONTH_FILE.test(name) || /^tokens-\d{4}-\d{2}\.json\.corrupt-/.test(name));
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * @param {string} slot
170
+ * @returns {string} "#1" for the base entry, "#<slot>" for a numbered sibling.
171
+ */
172
+ export function accountLabel(slot) {
173
+ return slot === "" ? "#1" : `#${slot}`;
174
+ }
175
+
176
+ /**
177
+ * @param {string} slot
178
+ * @returns {number}
179
+ */
180
+ export function accountOrder(slot) {
181
+ if (slot === "") return -1;
182
+ const numeric = Number.parseInt(slot, 10);
183
+ return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
184
+ }
185
+
186
+ /**
187
+ * @returns {Bucket}
188
+ */
189
+ export function emptyBucket() {
190
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0 };
191
+ }
192
+
193
+ /**
194
+ * @param {Bucket} bucket
195
+ * @returns {number}
196
+ */
197
+ export function bucketTokens(bucket) {
198
+ return (bucket?.input ?? 0) + (bucket?.output ?? 0) + (bucket?.cacheRead ?? 0) + (bucket?.cacheWrite ?? 0);
199
+ }
200
+
201
+ /**
202
+ * @param {Bucket} bucket
203
+ * @returns {number}
204
+ */
205
+ export function bucketMessages(bucket) {
206
+ return bucket?.messages ?? 0;
207
+ }
208
+
209
+ /**
210
+ * @param {Bucket} target
211
+ * @param {Bucket} source
212
+ */
213
+ export function addIntoBucket(target, source) {
214
+ target.input += source.input ?? 0;
215
+ target.output += source.output ?? 0;
216
+ target.cacheRead += source.cacheRead ?? 0;
217
+ target.cacheWrite += source.cacheWrite ?? 0;
218
+ target.messages += source.messages ?? 0;
219
+ }
220
+
221
+ /**
222
+ * @param {Map<string, Bucket>} models
223
+ * @param {string} model
224
+ * @param {Bucket} bucket
225
+ */
226
+ export function addToModels(models, model, bucket) {
227
+ const target = models.get(model) ?? emptyBucket();
228
+ addIntoBucket(target, bucket);
229
+ models.set(model, target);
230
+ }
231
+
232
+ /**
233
+ * Sum a family row's tokens.
234
+ *
235
+ * @param {FamilyRow} familyRow
236
+ * @returns {number}
237
+ */
238
+ export function familyRowTokens(familyRow) {
239
+ let total = 0;
240
+ for (const slotRow of Object.values(familyRow?.slots ?? {})) {
241
+ for (const bucket of Object.values(slotRow?.models ?? {})) total += bucketTokens(bucket);
242
+ }
243
+ return total;
244
+ }
245
+
246
+ /**
247
+ * Sum a family row's assistant messages.
248
+ *
249
+ * @param {FamilyRow} familyRow
250
+ * @returns {number}
251
+ */
252
+ export function familyRowMessages(familyRow) {
253
+ let total = 0;
254
+ for (const slotRow of Object.values(familyRow?.slots ?? {})) {
255
+ for (const bucket of Object.values(slotRow?.models ?? {})) total += bucketMessages(bucket);
256
+ }
257
+ return total;
258
+ }
259
+
260
+ /**
261
+ * @param {string} a
262
+ * @param {string} b
263
+ * @returns {string}
264
+ */
265
+ export function maxDay(a, b) {
266
+ if (!a) return b;
267
+ if (!b) return a;
268
+ return a > b ? a : b;
269
+ }
270
+
271
+ /**
272
+ * @param {DayRows} rows
273
+ * @returns {DayRows}
274
+ */
275
+ export function cloneDayRows(rows) {
276
+ return JSON.parse(JSON.stringify(rows));
277
+ }
278
+
279
+ /**
280
+ * @returns {Totals}
281
+ */
282
+ export function emptyTotals() {
283
+ return { lastFrozenDay: "", families: new Map() };
284
+ }
285
+
286
+ /**
287
+ * @param {Totals} totals
288
+ * @param {string} family
289
+ * @returns {FamilyTotals}
290
+ */
291
+ function ensureFamily(totals, family) {
292
+ let entry = totals.families.get(family);
293
+ if (!entry) {
294
+ entry = { firstDay: "", activeDates: new Set(), totalPrompts: 0, totalSessions: 0, models: new Map(), accounts: new Map() };
295
+ totals.families.set(family, entry);
296
+ }
297
+ return entry;
298
+ }
299
+
300
+ /**
301
+ * @param {FamilyTotals} family
302
+ * @param {string} slot
303
+ * @returns {AccountTotals}
304
+ */
305
+ function ensureAccount(family, slot) {
306
+ const label = accountLabel(slot);
307
+ let account = family.accounts.get(label);
308
+ if (!account) {
309
+ account = { totalPrompts: 0, totalSessions: 0, models: new Map() };
310
+ family.accounts.set(label, account);
311
+ }
312
+ return account;
313
+ }
314
+
315
+ /**
316
+ * Fold one complete, frozen day into the all-time rollup.
317
+ *
318
+ * @param {Totals} totals
319
+ * @param {string} day
320
+ * @param {DayRows} dayRows
321
+ */
322
+ export function applyDayToTotals(totals, day, dayRows) {
323
+ for (const [family, familyRow] of Object.entries(dayRows ?? {})) {
324
+ if (!familyRow || typeof familyRow !== "object") continue;
325
+ const familyTotals = ensureFamily(totals, family);
326
+ familyTotals.activeDates.add(day);
327
+ if (!familyTotals.firstDay || day < familyTotals.firstDay) familyTotals.firstDay = day;
328
+ familyTotals.totalSessions += Number(familyRow.sessions) || 0;
329
+
330
+ for (const [slot, slotRow] of Object.entries(familyRow.slots ?? {})) {
331
+ if (!slotRow || typeof slotRow !== "object") continue;
332
+ const account = ensureAccount(familyTotals, slot);
333
+ account.totalSessions += Number(slotRow.sessions) || 0;
334
+ for (const [model, bucket] of Object.entries(slotRow.models ?? {})) {
335
+ if (!bucket || typeof bucket !== "object") continue;
336
+ addToModels(familyTotals.models, model, bucket);
337
+ addToModels(account.models, model, bucket);
338
+ const messages = bucketMessages(bucket);
339
+ familyTotals.totalPrompts += messages;
340
+ account.totalPrompts += messages;
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Fold a late write into a day that is already frozen. Session counts are left
348
+ * alone: the day froze with its session list, and a late append comes from a
349
+ * session that day already counted (or from a month file that no longer knows
350
+ * it, in which case counting it again would be worse than omitting it).
351
+ *
352
+ * @param {Totals} totals
353
+ * @param {string} day
354
+ * @param {DayRows} dayRows
355
+ */
356
+ export function applyLateToTotals(totals, day, dayRows) {
357
+ for (const [family, familyRow] of Object.entries(dayRows ?? {})) {
358
+ if (!familyRow || typeof familyRow !== "object") continue;
359
+ const familyTotals = ensureFamily(totals, family);
360
+ familyTotals.activeDates.add(day);
361
+ if (!familyTotals.firstDay || day < familyTotals.firstDay) familyTotals.firstDay = day;
362
+ for (const [slot, slotRow] of Object.entries(familyRow.slots ?? {})) {
363
+ if (!slotRow || typeof slotRow !== "object") continue;
364
+ const account = ensureAccount(familyTotals, slot);
365
+ for (const [model, bucket] of Object.entries(slotRow.models ?? {})) {
366
+ if (!bucket || typeof bucket !== "object") continue;
367
+ addToModels(familyTotals.models, model, bucket);
368
+ addToModels(account.models, model, bucket);
369
+ const messages = bucketMessages(bucket);
370
+ familyTotals.totalPrompts += messages;
371
+ account.totalPrompts += messages;
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * @param {Map<string, Bucket>} models
379
+ * @returns {Record<string, Bucket>}
380
+ */
381
+ function modelsToJson(models) {
382
+ /** @type {Record<string, Bucket>} */
383
+ const out = {};
384
+ for (const [model, bucket] of models) out[model] = { ...bucket };
385
+ return out;
386
+ }
387
+
388
+ /**
389
+ * Serialize the internal rollup into the versioned `totals.json` shape.
390
+ *
391
+ * @param {Totals} totals
392
+ * @returns {Record<string, unknown>}
393
+ */
394
+ export function totalsToJson(totals) {
395
+ /** @type {Record<string, unknown>} */
396
+ const families = {};
397
+ for (const [family, entry] of totals.families) {
398
+ /** @type {Record<string, unknown>} */
399
+ const accounts = {};
400
+ for (const [label, account] of entry.accounts) {
401
+ accounts[label] = {
402
+ totalPrompts: account.totalPrompts,
403
+ totalSessions: account.totalSessions,
404
+ modelUsage: modelsToJson(account.models),
405
+ };
406
+ }
407
+ families[family] = {
408
+ firstDay: entry.firstDay,
409
+ activeDates: Array.from(entry.activeDates).sort(),
410
+ totalPrompts: entry.totalPrompts,
411
+ totalSessions: entry.totalSessions,
412
+ modelUsage: modelsToJson(entry.models),
413
+ accounts,
414
+ };
415
+ }
416
+ return {
417
+ schemaVersion: HISTORY_SCHEMA_VERSION,
418
+ lastFrozenDay: totals.lastFrozenDay,
419
+ families,
420
+ };
421
+ }
422
+
423
+ /**
424
+ * @param {unknown} value
425
+ * @returns {string}
426
+ */
427
+ function text(value) {
428
+ return typeof value === "string" ? value : "";
429
+ }
430
+
431
+ /**
432
+ * @param {unknown} value
433
+ * @returns {number}
434
+ */
435
+ function count(value) {
436
+ const number = Number(value);
437
+ return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 0;
438
+ }
439
+
440
+ /**
441
+ * @param {unknown} raw
442
+ * @returns {Map<string, Bucket>}
443
+ */
444
+ function modelsFromJson(raw) {
445
+ /** @type {Map<string, Bucket>} */
446
+ const models = new Map();
447
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return models;
448
+ for (const [model, bucket] of Object.entries(raw)) {
449
+ if (!bucket || typeof bucket !== "object") continue;
450
+ models.set(model, {
451
+ input: count(/** @type {Record<string, unknown>} */ (bucket).input),
452
+ output: count(/** @type {Record<string, unknown>} */ (bucket).output),
453
+ cacheRead: count(/** @type {Record<string, unknown>} */ (bucket).cacheRead),
454
+ cacheWrite: count(/** @type {Record<string, unknown>} */ (bucket).cacheWrite),
455
+ messages: count(/** @type {Record<string, unknown>} */ (bucket).messages),
456
+ });
457
+ }
458
+ return models;
459
+ }
460
+
461
+ /**
462
+ * Parse a totals file, or return null when it is not one this version wrote.
463
+ *
464
+ * @param {unknown} raw
465
+ * @returns {Totals | null}
466
+ */
467
+ export function parseTotals(raw) {
468
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
469
+ const object = /** @type {Record<string, unknown>} */ (raw);
470
+ if (object.schemaVersion !== HISTORY_SCHEMA_VERSION) return null;
471
+ if (typeof object.lastFrozenDay !== "string") return null;
472
+ if (!object.families || typeof object.families !== "object" || Array.isArray(object.families)) return null;
473
+
474
+ const totals = emptyTotals();
475
+ totals.lastFrozenDay = object.lastFrozenDay;
476
+ for (const [family, value] of Object.entries(object.families)) {
477
+ if (!value || typeof value !== "object") continue;
478
+ const entry = /** @type {Record<string, unknown>} */ (value);
479
+ const familyTotals = ensureFamily(totals, family);
480
+ familyTotals.firstDay = text(entry.firstDay);
481
+ familyTotals.totalPrompts = count(entry.totalPrompts);
482
+ familyTotals.totalSessions = count(entry.totalSessions);
483
+ familyTotals.models = modelsFromJson(entry.modelUsage);
484
+ if (Array.isArray(entry.activeDates)) {
485
+ for (const day of entry.activeDates) if (typeof day === "string" && day !== "") familyTotals.activeDates.add(day);
486
+ }
487
+ if (entry.accounts && typeof entry.accounts === "object" && !Array.isArray(entry.accounts)) {
488
+ for (const [label, accountValue] of Object.entries(entry.accounts)) {
489
+ if (!accountValue || typeof accountValue !== "object") continue;
490
+ const account = /** @type {Record<string, unknown>} */ (accountValue);
491
+ familyTotals.accounts.set(label, {
492
+ totalPrompts: count(account.totalPrompts),
493
+ totalSessions: count(account.totalSessions),
494
+ models: modelsFromJson(account.modelUsage),
495
+ });
496
+ }
497
+ }
498
+ }
499
+ return totals;
500
+ }
501
+
502
+ /**
503
+ * Move an unreadable history file aside instead of silently overwriting it.
504
+ *
505
+ * @param {string} path
506
+ * @param {string[]} warnings
507
+ * @param {string} label
508
+ * @returns {boolean} Whether the path is free to be written again.
509
+ */
510
+ export function quarantine(path, warnings, label) {
511
+ const target = `${path}.corrupt-${Date.now()}`;
512
+ try {
513
+ renameSync(path, target);
514
+ warnings.push(`${label} at ${path} was unreadable; moved it to ${target}`);
515
+ return true;
516
+ } catch (error) {
517
+ warnings.push(
518
+ `${label} at ${path} is unreadable and could not be moved aside (${redact(/** @type {{ message?: string }} */ (error)?.message)}); leaving it untouched`,
519
+ );
520
+ return false;
521
+ }
522
+ }
523
+
524
+ /** How long a lock file may sit there before another run treats it as abandoned. */
525
+ export const DEFAULT_LOCK_STALE_MS = 120_000;
526
+
527
+ /**
528
+ * Take an exclusive lock for one history write.
529
+ *
530
+ * Two writers are routine on this machine — the systemd timer that publishes
531
+ * Omarchy records and the /usage panel asking for a report — and both would
532
+ * otherwise load the same totals, apply the same late-write delta and write it
533
+ * twice. The lock is an `O_EXCL` file next to the disposable cache (never inside
534
+ * `~/.pi`, which is a git repository), holding the pid for debugging. A lock
535
+ * whose mtime is older than `staleMs` was left by a killed run and is taken
536
+ * over; a negative age (the clock moved backwards) is never treated as stale.
537
+ *
538
+ * @param {{
539
+ * lockPath: string,
540
+ * now?: number,
541
+ * staleMs?: number,
542
+ * }} input
543
+ * @returns {{ acquired: boolean, path: string, stale: boolean, reason?: string }}
544
+ */
545
+ export function acquireHistoryLock(input) {
546
+ const now = input.now ?? Date.now();
547
+ const staleMs = Number.isFinite(input.staleMs) ? /** @type {number} */ (input.staleMs) : DEFAULT_LOCK_STALE_MS;
548
+ const lockPath = input.lockPath;
549
+ // The pid alone is not an owner: it is repeated across containers and after a
550
+ // wrap, and the whole point of the token is that a run can prove the lock in
551
+ // the file is still the one it created.
552
+ const token = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
553
+
554
+ for (let attempt = 0; attempt < 2; attempt += 1) {
555
+ try {
556
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
557
+ } catch (error) {
558
+ return {
559
+ acquired: false,
560
+ path: lockPath,
561
+ token: null,
562
+ stale: false,
563
+ reason: `cannot create the lock directory: ${redact(/** @type {{ message?: string }} */ (error)?.message)}`,
564
+ };
565
+ }
566
+ try {
567
+ const fd = openSync(lockPath, "wx", 0o600);
568
+ try {
569
+ writeFileSync(fd, `${token}\n`, "utf-8");
570
+ } finally {
571
+ closeSync(fd);
572
+ }
573
+ return { acquired: true, path: lockPath, token, stale: false };
574
+ } catch (error) {
575
+ if (/** @type {{ code?: string }} */ (error)?.code !== "EEXIST") {
576
+ return {
577
+ acquired: false,
578
+ path: lockPath,
579
+ token: null,
580
+ stale: false,
581
+ reason: redact(/** @type {{ message?: string }} */ (error)?.message ?? String(error)),
582
+ };
583
+ }
584
+ let age = 0;
585
+ try {
586
+ age = now - statSync(lockPath).mtimeMs;
587
+ } catch {
588
+ // It vanished between open and stat: try once more.
589
+ continue;
590
+ }
591
+ if (age > staleMs) {
592
+ rmSync(lockPath, { force: true });
593
+ continue;
594
+ }
595
+ return { acquired: false, path: lockPath, token: null, stale: false };
596
+ }
597
+ }
598
+ return { acquired: false, path: lockPath, token: null, stale: true };
599
+ }
600
+
601
+ /**
602
+ * @param {string} lockPath
603
+ * @returns {string | null} The owner token currently in the file.
604
+ */
605
+ function readLockToken(lockPath) {
606
+ try {
607
+ return readFileSync(lockPath, "utf-8").trim();
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Say "I am still here" so a long scan is never mistaken for a dead one.
615
+ *
616
+ * @param {{ path: string, token: string | null }} lock
617
+ * @returns {boolean} Whether the lock is still ours.
618
+ */
619
+ export function refreshHistoryLock(lock) {
620
+ if (!lock?.token || readLockToken(lock.path) !== lock.token) return false;
621
+ try {
622
+ const now = new Date();
623
+ utimesSync(lock.path, now, now);
624
+ return true;
625
+ } catch {
626
+ return false;
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Release only a lock this run still owns: a run that was taken over must not
632
+ * delete its successor's file. The lock object is required — a bare path cannot
633
+ * prove ownership, and an unconditional unlink is exactly the bug this
634
+ * signature prevents.
635
+ *
636
+ * @param {{ path: string, token: string | null } | null | undefined} lock
637
+ * @returns {boolean} Whether the file was ours and is now gone.
638
+ */
639
+ export function releaseHistoryLock(lock) {
640
+ const path = lock?.path;
641
+ const token = lock?.token;
642
+ if (!path || !token) return false;
643
+ if (readLockToken(path) !== token) return false;
644
+ try {
645
+ rmSync(path, { force: true });
646
+ return true;
647
+ } catch {
648
+ // A lock that cannot be removed is reclaimed by the stale timeout.
649
+ return false;
650
+ }
651
+ }
652
+
653
+ /**
654
+ * @param {string} path
655
+ * @param {unknown} value
656
+ * @returns {{ ok: boolean, error?: string }}
657
+ */
658
+ export function writeJsonAtomic(path, value) {
659
+ const temporary = `${path}.${process.pid}.tmp`;
660
+ try {
661
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
662
+ writeFileSync(temporary, `${JSON.stringify(value)}\n`, { encoding: "utf-8", mode: 0o600 });
663
+ renameSync(temporary, path);
664
+ return { ok: true };
665
+ } catch (error) {
666
+ rmSync(temporary, { force: true });
667
+ return { ok: false, error: redact(/** @type {{ message?: string }} */ (error)?.message ?? String(error)) };
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Rebuild the rollup from the month files, in memory.
673
+ *
674
+ * The month files are the authoritative record of frozen days: `totals.json` is
675
+ * a cache of them. Whenever it is missing or unreadable, this recreates it, so a
676
+ * lost rollup can never make the scan treat a frozen day as open and overwrite
677
+ * it with whatever sessions survive (review finding N3).
678
+ *
679
+ * @param {{ historyDir: string, warnings: string[] }} input
680
+ * @returns {{ totals: Totals, months: number, days: number, unreadable: string[], quarantined: string[] }}
681
+ */
682
+ export function totalsFromMonths(input) {
683
+ /** @type {string[]} */
684
+ let names = [];
685
+ try {
686
+ names = readdirSync(input.historyDir);
687
+ } catch {
688
+ return { totals: emptyTotals(), months: 0, days: 0, unreadable: [], quarantined: [] };
689
+ }
690
+ const months = names
691
+ .map((name) => MONTH_FILE.exec(name))
692
+ .filter((match) => match !== null)
693
+ .map((match) => /** @type {RegExpExecArray} */ (match)[1])
694
+ .sort();
695
+
696
+ const totals = emptyTotals();
697
+ /** @type {string[]} */
698
+ const unreadable = [];
699
+ const presentMonths = new Set(months);
700
+ const quarantined = names
701
+ .map((name) => /^tokens-(\d{4}-\d{2})\.json\.corrupt-/.exec(name)?.[1])
702
+ .filter((month) => month !== undefined && !presentMonths.has(month));
703
+ let days = 0;
704
+ for (const month of months) {
705
+ const entry = readMonthFile(input.historyDir, month, input.warnings, { canWrite: input.canWrite });
706
+ if (entry.unreadable) unreadable.push(month);
707
+ if (entry.quarantined && !quarantined.includes(month)) quarantined.push(month);
708
+ if (!entry.writable) continue;
709
+ for (const day of Object.keys(entry.days).sort()) {
710
+ const rows = entry.days[day];
711
+ if (!rows || typeof rows !== "object") continue;
712
+ applyDayToTotals(totals, day, rows);
713
+ totals.lastFrozenDay = maxDay(totals.lastFrozenDay, day);
714
+ days += 1;
715
+ }
716
+ }
717
+ return { totals, months: months.length, days, unreadable, quarantined };
718
+ }
719
+
720
+ /**
721
+ * Load the durable rollup.
722
+ *
723
+ * A missing or corrupt totals file is rebuilt from complete month files, never
724
+ * from session files. If any month is unreadable or quarantined, the rollup is
725
+ * left untouched and this run cannot write history.
726
+ *
727
+ * @param {{ historyDir: string, warnings: string[], canWrite?: boolean }} input
728
+ * @returns {{ totals: Totals, rescanAll: boolean, writable: boolean, path: string, exists: boolean, rebuiltFromMonths: boolean }}
729
+ */
730
+ export function loadTotals(input) {
731
+ const path = resolveTotalsPath(input.historyDir);
732
+ /**
733
+ * Restore the rollup from the complete month files. Session bytes are never
734
+ * used to recreate a frozen day.
735
+ *
736
+ * @param {boolean} writableRollup Whether the rollup may be written now.
737
+ */
738
+ const fromMonths = (writableRollup) => {
739
+ const rebuilt = totalsFromMonths({
740
+ historyDir: input.historyDir,
741
+ warnings: input.warnings,
742
+ canWrite: writableRollup && input.canWrite !== false,
743
+ });
744
+ if (rebuilt.unreadable.length > 0 || rebuilt.quarantined.length > 0) {
745
+ if (rebuilt.unreadable.length > 0) {
746
+ input.warnings.push(
747
+ `local token stats: month file(s) could not be read (${rebuilt.unreadable.join(", ")}); ` +
748
+ "the rollup was not rewritten and no days were frozen into unreadable months",
749
+ );
750
+ }
751
+ input.warnings.push(
752
+ "local token stats: the rollup was not rewritten from an incomplete month set; restore the missing or quarantined history before rebuilding it",
753
+ );
754
+ return { totals: rebuilt.totals, rescanAll: true, writable: false, path, exists: false, rebuiltFromMonths: false };
755
+ }
756
+ if (rebuilt.months === 0) {
757
+ return { totals: emptyTotals(), rescanAll: true, writable: writableRollup, path, exists: false, rebuiltFromMonths: false };
758
+ }
759
+ input.warnings.push(
760
+ `local token stats: the history rollup was rebuilt from ${rebuilt.months} month file(s) (${rebuilt.days} frozen day(s))`,
761
+ );
762
+ if (writableRollup && input.canWrite !== false) {
763
+ const written = writeJsonAtomic(path, totalsToJson(rebuilt.totals));
764
+ if (!written.ok) {
765
+ input.warnings.push(`local token stats: could not write the rebuilt history totals: ${written.error}`);
766
+ }
767
+ }
768
+ return { totals: rebuilt.totals, rescanAll: true, writable: writableRollup, path, exists: true, rebuiltFromMonths: true };
769
+ };
770
+
771
+ let raw;
772
+ try {
773
+ raw = readFileSync(path, "utf-8");
774
+ } catch (error) {
775
+ if (/** @type {{ code?: string }} */ (error)?.code === "ENOENT") {
776
+ return fromMonths(true);
777
+ }
778
+ input.warnings.push(`local token stats: could not read the history at ${path}: ${redact(/** @type {{ message?: string }} */ (error)?.message)}`);
779
+ return fromMonths(false);
780
+ }
781
+
782
+ let parsed;
783
+ try {
784
+ parsed = JSON.parse(raw);
785
+ } catch {
786
+ parsed = null;
787
+ }
788
+ const totals = parseTotals(parsed);
789
+ if (!totals) {
790
+ const writable = quarantine(path, input.warnings, "local token history");
791
+ if (!writable) return { totals: emptyTotals(), rescanAll: true, writable: false, path, exists: true, rebuiltFromMonths: false };
792
+ return fromMonths(true);
793
+ }
794
+ // An empty rollup is either a fresh install (nothing has closed yet) or a
795
+ // rollup that lost its numbers. Month files on disk settle it: they are the
796
+ // evidence that something was frozen, so history is rebuilt from them.
797
+ if (totals.families.size === 0 && totals.lastFrozenDay === "" && hasMonthFiles(input.historyDir)) {
798
+ return fromMonths(true);
799
+ }
800
+ return { totals, rescanAll: false, writable: true, path, exists: true, rebuiltFromMonths: false };
801
+ }
802
+
803
+ /**
804
+ * Read one month file. A corrupt month is moved aside by the lock owner; its
805
+ * daily detail stays missing until the quarantined copy is restored. The
806
+ * all-time totals it already fed are untouched.
807
+ *
808
+ * @param {string} historyDir
809
+ * @param {string} month
810
+ * @param {string[]} warnings
811
+ * @returns {{ days: Record<string, DayRows>, writable: boolean, exists: boolean, path: string }}
812
+ */
813
+ export function readMonthFile(historyDir, month, warnings, options = {}) {
814
+ const canWrite = options.canWrite !== false;
815
+ const path = resolveMonthPath(historyDir, month);
816
+ let raw;
817
+ try {
818
+ raw = readFileSync(path, "utf-8");
819
+ } catch (error) {
820
+ if (/** @type {{ code?: string }} */ (error)?.code === "ENOENT") {
821
+ const backup = quarantinedMonthPath(historyDir, month);
822
+ if (backup) {
823
+ warnings.push(
824
+ `local token stats: ${path} was quarantined as ${backup}; its frozen days are missing from recentDays (shown as 0), ` +
825
+ `while totals.json keeps the all-time counts. Copy ${backup} back to ${path} if recoverable, or delete that backup to abandon those counts; days after it stay open until then`,
826
+ );
827
+ }
828
+ return { days: {}, writable: true, exists: false, unreadable: false, quarantined: backup !== null, path };
829
+ }
830
+ // A permission or I/O error is temporary: the file is still there and may be
831
+ // readable in a moment. It must NOT be quarantined, and the days it holds
832
+ // must not be silently dropped from the rollup (review finding N9).
833
+ warnings.push(
834
+ `local token stats: could not read ${path} (${redact(/** @type {{ message?: string }} */ (error)?.message)}); ` +
835
+ "leaving it untouched; its days stay pending and no reduced totals.json will be written",
836
+ );
837
+ return { days: {}, writable: false, exists: true, unreadable: true, quarantined: false, path };
838
+ }
839
+
840
+ let parsed;
841
+ try {
842
+ parsed = JSON.parse(raw);
843
+ } catch {
844
+ parsed = null;
845
+ }
846
+ const shapeOk = parsed && typeof parsed === "object" && parsed.schemaVersion === HISTORY_SCHEMA_VERSION && parsed.month === month
847
+ && parsed.days && typeof parsed.days === "object" && !Array.isArray(parsed.days);
848
+ if (!shapeOk) {
849
+ if (!canWrite) {
850
+ warnings.push(
851
+ `local token stats: ${path} is corrupt or from another version; leaving it in place because this run does not hold the history lock. ` +
852
+ "Its days are missing from recentDays (shown as 0), while totals.json keeps the all-time counts; a lock owner can quarantine it for restoration",
853
+ );
854
+ return { days: {}, writable: false, exists: true, unreadable: true, quarantined: false, path };
855
+ }
856
+ const label = `local token history month ${month}`;
857
+ const writable = quarantine(path, warnings, label);
858
+ if (writable) {
859
+ const backup = quarantinedMonthPath(historyDir, month);
860
+ warnings.push(
861
+ `local token stats: ${path} is corrupt or from another version; its frozen days are missing from recentDays (shown as 0), ` +
862
+ `while totals.json keeps the all-time counts. Copy ${backup ?? `${path}.corrupt-<timestamp>`} back to ${path} if recoverable, or delete that backup to abandon those counts; days after it stay open until then`,
863
+ );
864
+ }
865
+ return { days: {}, writable, exists: true, unreadable: !writable, quarantined: writable, path };
866
+ }
867
+ return { days: parsed.days, writable: true, exists: true, unreadable: false, quarantined: false, path };
868
+ }
869
+
870
+ /**
871
+ * Increment an already-frozen day with late-written lines. SET semantics are
872
+ * deliberately not used here: the day already exists and the new lines are a
873
+ * delta, not a replacement.
874
+ *
875
+ * @param {Record<string, DayRows>} days
876
+ * @param {string} day
877
+ * @param {DayRows} deltaRows
878
+ */
879
+ function incrementDayRows(days, day, deltaRows) {
880
+ const target = days[day] ?? (days[day] = {});
881
+ for (const [family, familyRow] of Object.entries(deltaRows)) {
882
+ const familyTarget = target[family] ?? (target[family] = { sessions: 0, slots: {} });
883
+ for (const [slot, slotRow] of Object.entries(familyRow.slots ?? {})) {
884
+ const slotTarget = familyTarget.slots[slot] ?? (familyTarget.slots[slot] = { sessions: 0, models: {} });
885
+ for (const [model, bucket] of Object.entries(slotRow.models ?? {})) {
886
+ const bucketTarget = slotTarget.models[model] ?? (slotTarget.models[model] = emptyBucket());
887
+ addIntoBucket(bucketTarget, bucket);
888
+ }
889
+ }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Write the durable part of a run: late increments to frozen days, then the
895
+ * freeze of every closed day that is not frozen yet.
896
+ *
897
+ * Ordering is the crash-safety contract:
898
+ * 1. month files (late increments and SET-frozen days),
899
+ * 2. totals (increments + the new `lastFrozenDay`),
900
+ * 3. the caller prunes those days from the disposable cache.
901
+ * A crash before (2) leaves the watermark untouched, so the next run re-freezes
902
+ * the day from the still-open cache and the SET write is idempotent. A day at
903
+ * or below the watermark that is still open was already counted: it is dropped
904
+ * without touching history, which is what makes a crash after (2) safe.
905
+ *
906
+ * @param {{
907
+ * historyDir: string,
908
+ * totals: Totals,
909
+ * openRows: Record<string, DayRows>,
910
+ * lateRows: Record<string, DayRows>,
911
+ * today: string,
912
+ * partial: boolean,
913
+ * writable: boolean,
914
+ * ensure?: boolean,
915
+ * warnings: string[],
916
+ * }} input
917
+ * @returns {{ droppedDays: Set<string>, frozenDays: string[], pendingLateDays: Set<string>, wroteTotals: boolean }}
918
+ */
919
+ export function applyDurable(input) {
920
+ /** @type {Set<string>} */
921
+ const dropped = new Set();
922
+ /** @type {string[]} */
923
+ const candidates = [];
924
+ for (const day of Object.keys(input.openRows)) {
925
+ if (day >= input.today) continue;
926
+ if (input.totals.lastFrozenDay && day <= input.totals.lastFrozenDay) dropped.add(day);
927
+ else candidates.push(day);
928
+ }
929
+ candidates.sort();
930
+ // A budget-truncated scan has not seen every session, so freezing now would
931
+ // bake an incomplete day into history forever. Late increments are real
932
+ // deltas from bytes that were read once, so they still apply.
933
+ const freezeCandidates = input.partial ? [] : candidates;
934
+ const lateDays = Object.keys(input.lateRows).sort();
935
+ /** @type {Set<string>} */
936
+ const pendingLateDays = new Set();
937
+ if (freezeCandidates.length === 0 && lateDays.length === 0) {
938
+ if (input.ensure && input.writable) {
939
+ const written = writeJsonAtomic(resolveTotalsPath(input.historyDir), totalsToJson(input.totals));
940
+ if (!written.ok) {
941
+ input.warnings.push(`local token stats: could not write the history totals: ${written.error}`);
942
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: false };
943
+ }
944
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: true };
945
+ }
946
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: false };
947
+ }
948
+ if (!input.writable) {
949
+ input.warnings.push("local token stats: the durable history is unreadable; this run leaves it untouched");
950
+ for (const day of lateDays) pendingLateDays.add(day);
951
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: false };
952
+ }
953
+
954
+ /** @type {Map<string, { days: Record<string, DayRows>, writable: boolean }>} */
955
+ const months = new Map();
956
+ const monthFor = (day) => {
957
+ const month = day.slice(0, 7);
958
+ let entry = months.get(month);
959
+ if (!entry) {
960
+ entry = readMonthFile(input.historyDir, month, input.warnings, { canWrite: true });
961
+ months.set(month, entry);
962
+ }
963
+ return entry;
964
+ };
965
+
966
+ /** @type {Set<string>} */
967
+ const dirtyMonths = new Set();
968
+ for (const day of lateDays) {
969
+ const month = monthFor(day);
970
+ if (!month.writable || month.quarantined) {
971
+ pendingLateDays.add(day);
972
+ continue;
973
+ }
974
+ incrementDayRows(month.days, day, input.lateRows[day]);
975
+ dirtyMonths.add(day.slice(0, 7));
976
+ }
977
+
978
+ /** @type {string[]} */
979
+ const blockedFreezeDays = [];
980
+ for (const day of freezeCandidates) {
981
+ const month = monthFor(day);
982
+ if (!month.writable || month.quarantined) blockedFreezeDays.push(day);
983
+ }
984
+ // The watermark is a high-water mark. Do not freeze past the first day
985
+ // blocked by an unreadable month, or a later freeze would make that open day
986
+ // look already counted on the next run.
987
+ const freezeCutoff = blockedFreezeDays[0] ?? null;
988
+ const freezeDays = freezeCandidates.filter((day) => {
989
+ const month = monthFor(day);
990
+ return (!freezeCutoff || day < freezeCutoff) && month.writable && !month.quarantined;
991
+ });
992
+ for (const day of freezeDays) {
993
+ const month = monthFor(day);
994
+ month.days[day] = cloneDayRows(input.openRows[day]);
995
+ dirtyMonths.add(day.slice(0, 7));
996
+ }
997
+
998
+ if (dirtyMonths.size === 0) {
999
+ if (input.ensure && input.writable) {
1000
+ const written = writeJsonAtomic(resolveTotalsPath(input.historyDir), totalsToJson(input.totals));
1001
+ if (!written.ok) {
1002
+ input.warnings.push(`local token stats: could not write the history totals: ${written.error}`);
1003
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: false };
1004
+ }
1005
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: true };
1006
+ }
1007
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: false };
1008
+ }
1009
+
1010
+ // Month files first: a frozen day must be on disk before the watermark that
1011
+ // claims it is frozen can move. Unreadable months are skipped independently.
1012
+ const writtenMonths = new Set();
1013
+ for (const month of dirtyMonths) {
1014
+ const entry = months.get(month);
1015
+ const written = writeJsonAtomic(resolveMonthPath(input.historyDir, month), {
1016
+ schemaVersion: HISTORY_SCHEMA_VERSION,
1017
+ month,
1018
+ days: entry.days,
1019
+ });
1020
+ if (!written.ok) {
1021
+ input.warnings.push(`local token stats: could not write the ${month} history: ${written.error}`);
1022
+ for (const day of lateDays) if (!writtenMonths.has(day.slice(0, 7))) pendingLateDays.add(day);
1023
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: false };
1024
+ }
1025
+ writtenMonths.add(month);
1026
+ }
1027
+
1028
+ const appliedLateDays = lateDays.filter((day) => !pendingLateDays.has(day));
1029
+ for (const day of appliedLateDays) applyLateToTotals(input.totals, day, input.lateRows[day]);
1030
+ for (const day of freezeDays) applyDayToTotals(input.totals, day, input.openRows[day]);
1031
+ if (freezeDays.length > 0) input.totals.lastFrozenDay = maxDay(input.totals.lastFrozenDay, freezeDays[freezeDays.length - 1]);
1032
+
1033
+ const written = writeJsonAtomic(resolveTotalsPath(input.historyDir), totalsToJson(input.totals));
1034
+ if (!written.ok) {
1035
+ input.warnings.push(
1036
+ `local token stats: could not write the history totals: ${written.error}; the month files are ahead, so \`piquota stats --rebuild-totals\` can recover them`,
1037
+ );
1038
+ return { droppedDays: dropped, frozenDays: [], pendingLateDays, wroteTotals: false };
1039
+ }
1040
+
1041
+ for (const day of freezeDays) dropped.add(day);
1042
+ return { droppedDays: dropped, frozenDays: freezeDays, pendingLateDays, wroteTotals: true };
1043
+ }
1044
+
1045
+ /**
1046
+ * Read the month files that cover the seven-day window (current month and, early
1047
+ * in a month, the previous one). Nothing older is ever loaded for a report.
1048
+ *
1049
+ * @param {{ historyDir: string, dates: string[], warnings: string[], canWrite?: boolean }} input
1050
+ * @returns {Record<string, DayRows>}
1051
+ */
1052
+ export function loadWindowDays(input) {
1053
+ const wanted = new Set(input.dates);
1054
+ const months = new Set(input.dates.map((date) => date.slice(0, 7)));
1055
+ /** @type {Record<string, DayRows>} */
1056
+ const days = {};
1057
+ for (const month of months) {
1058
+ const entry = readMonthFile(input.historyDir, month, input.warnings, { canWrite: input.canWrite });
1059
+ for (const [day, rows] of Object.entries(entry.days)) {
1060
+ if (wanted.has(day)) days[day] = rows;
1061
+ }
1062
+ }
1063
+ return days;
1064
+ }
1065
+
1066
+ /**
1067
+ * Recompute totals.json from every month file. This is the recovery path when
1068
+ * totals is lost but the month files are not, and the way to verify the
1069
+ * incremental rollup.
1070
+ *
1071
+ * @param {{ historyDir: string, warnings: string[] }} input
1072
+ * @returns {{ ok: boolean, error?: string, months: number, days: number, families: number, lastFrozenDay: string, path: string }}
1073
+ */
1074
+ export function rebuildTotalsFromMonths(input) {
1075
+ /** @type {string[]} */
1076
+ let names = [];
1077
+ try {
1078
+ names = readdirSync(input.historyDir);
1079
+ } catch (error) {
1080
+ if (/** @type {{ code?: string }} */ (error)?.code !== "ENOENT") {
1081
+ return { ok: false, error: redact(/** @type {{ message?: string }} */ (error)?.message ?? String(error)), months: 0, days: 0, families: 0, lastFrozenDay: "", path: resolveTotalsPath(input.historyDir) };
1082
+ }
1083
+ }
1084
+
1085
+ const months = names
1086
+ .map((name) => MONTH_FILE.exec(name))
1087
+ .filter((match) => match !== null)
1088
+ .map((match) => /** @type {RegExpExecArray} */ (match)[1])
1089
+ .sort();
1090
+ const presentMonths = new Set(months);
1091
+ const quarantined = names.filter((name) => {
1092
+ const month = /^tokens-(\d{4}-\d{2})\.json\.corrupt-/.exec(name)?.[1];
1093
+ return month !== undefined && !presentMonths.has(month);
1094
+ });
1095
+ if (quarantined.length > 0) {
1096
+ return {
1097
+ ok: false,
1098
+ error: `quarantined month history remains under ${input.historyDir} (${quarantined.join(", ")}); restore it before rebuilding totals`,
1099
+ months: months.length,
1100
+ days: 0,
1101
+ families: 0,
1102
+ lastFrozenDay: "",
1103
+ path: resolveTotalsPath(input.historyDir),
1104
+ };
1105
+ }
1106
+
1107
+ // Refuse to zero a rollup that still has numbers when the month files it
1108
+ // should be rebuilt from are gone: that turns one missing directory into
1109
+ // permanently lost history. The caller reports it and exits non-zero.
1110
+ if (months.length === 0) {
1111
+ const existing = loadTotals({ historyDir: input.historyDir, warnings: [] });
1112
+ if (existing.totals.families.size > 0) {
1113
+ return {
1114
+ ok: false,
1115
+ error:
1116
+ `no tokens-YYYY-MM.json under ${input.historyDir} while ${resolveTotalsPath(input.historyDir)} still has data; ` +
1117
+ "refusing to replace it with an empty rollup",
1118
+ months: 0,
1119
+ days: 0,
1120
+ families: 0,
1121
+ lastFrozenDay: existing.totals.lastFrozenDay,
1122
+ path: resolveTotalsPath(input.historyDir),
1123
+ };
1124
+ }
1125
+ }
1126
+
1127
+ const totals = emptyTotals();
1128
+ let days = 0;
1129
+ for (const month of months) {
1130
+ const entry = readMonthFile(input.historyDir, month, input.warnings, { canWrite: true });
1131
+ if (entry.unreadable || entry.quarantined) {
1132
+ return {
1133
+ ok: false,
1134
+ error: entry.unreadable
1135
+ ? `${resolveMonthPath(input.historyDir, month)} could not be read; refusing to rebuild the rollup without it`
1136
+ : `${resolveMonthPath(input.historyDir, month)} was quarantined; restore its .corrupt-<timestamp> copy, or delete it to abandon those counts, before rebuilding totals`,
1137
+ months: months.length,
1138
+ days,
1139
+ families: totals.families.size,
1140
+ lastFrozenDay: totals.lastFrozenDay,
1141
+ path: resolveTotalsPath(input.historyDir),
1142
+ };
1143
+ }
1144
+ if (!entry.writable) continue;
1145
+ for (const day of Object.keys(entry.days).sort()) {
1146
+ const rows = entry.days[day];
1147
+ if (!rows || typeof rows !== "object") continue;
1148
+ applyDayToTotals(totals, day, rows);
1149
+ totals.lastFrozenDay = maxDay(totals.lastFrozenDay, day);
1150
+ days += 1;
1151
+ }
1152
+ }
1153
+
1154
+ const path = resolveTotalsPath(input.historyDir);
1155
+ const written = writeJsonAtomic(path, totalsToJson(totals));
1156
+ if (!written.ok) {
1157
+ return { ok: false, error: written.error, months: months.length, days, families: totals.families.size, lastFrozenDay: totals.lastFrozenDay, path };
1158
+ }
1159
+ return { ok: true, months: months.length, days, families: totals.families.size, lastFrozenDay: totals.lastFrozenDay, path };
1160
+ }