@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.
- package/CHANGELOG.md +28 -0
- package/README.md +274 -12
- package/bin/piquota.js +49 -4
- package/extensions/quota-panel.ts +188 -31
- package/omarchy/cad0p.pi-usage/Agent.qml +33 -0
- package/omarchy/cad0p.pi-usage/Main.qml +333 -0
- package/omarchy/cad0p.pi-usage/Panel.qml +136 -0
- package/omarchy/cad0p.pi-usage/README.md +40 -0
- package/omarchy/cad0p.pi-usage/UsageContent.qml +1001 -0
- package/omarchy/cad0p.pi-usage/UsageLogic.js +603 -0
- package/omarchy/cad0p.pi-usage/assets/antigravity.svg +3 -0
- package/omarchy/cad0p.pi-usage/assets/claude.svg +1 -0
- package/omarchy/cad0p.pi-usage/assets/codex-light.svg +1 -0
- package/omarchy/cad0p.pi-usage/assets/codex.svg +1 -0
- package/omarchy/cad0p.pi-usage/assets/fireworks.svg +7 -0
- package/omarchy/cad0p.pi-usage/assets/github-actions.svg +3 -0
- package/omarchy/cad0p.pi-usage/assets/opencode-go.svg +3 -0
- package/omarchy/cad0p.pi-usage/manifest.json +56 -0
- package/package.json +2 -1
- package/src/auth/pi-auth.js +3 -0
- package/src/cli/args.js +5 -0
- package/src/cli/commands/stats.js +76 -0
- package/src/engine.js +44 -0
- package/src/local/history.js +1160 -0
- package/src/local/pi-sessions.js +1511 -0
- package/src/omarchy/publish.js +1 -65
- package/src/omarchy/record.js +82 -7
|
@@ -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
|
+
}
|