@adrrr/tarmac 0.7.0 → 0.8.0

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,320 @@
1
+ // Reading the journal back. The store writes and never reads; this reads and never writes.
2
+ //
3
+ // `history-store.ts` appends one line a minute and is the only thing that touches those files.
4
+ // This is the other half: given a directory and a range, it hands back the three shapes the
5
+ // questions in #3 are actually asked in. What a context reached, hour by hour. What a project
6
+ // spent, day by day. When the plan window turned over. The raw minute stays on disk, so the
7
+ // aggregation lives here, on the reading side, where changing it costs nothing already written.
8
+ //
9
+ // Two rules run through all of it.
10
+ //
11
+ // It never throws. A journal is a file on someone's machine, and a full volume tears a line in
12
+ // half: `appendFileSync` loops on `writeSync`, so a disk that filled mid-record leaves the
13
+ // front of it behind and the next minute glues itself on (#134). A reader that failed the whole
14
+ // month on one bad byte would be a reader nobody keeps a journal for. A line that will not
15
+ // parse, or that parses into something that is not a reading, is counted and stepped over, and
16
+ // the count is served with the answer so nobody has to guess what they are looking at.
17
+ //
18
+ // It reads a file at a time, asynchronously, and `list` never comes here. `serve` samples the
19
+ // fleet on a timer of its own and answers requests between ticks; thirty days of journal is
20
+ // tens of megabytes, and reading them synchronously would stop the sampler mid-range.
21
+ import fs from 'node:fs/promises';
22
+ import path from 'node:path';
23
+ import { readLimits } from './limits.js';
24
+ /** The ranges this reads. `24h` is not one of them: that is the ring, in memory, and it is served
25
+ * without touching the disk. */
26
+ export const HISTORY_RANGES = ['7d', '30d'];
27
+ /** How many local days each range covers, today included, exactly as the store's retention counts. */
28
+ const RANGE_DAYS = { '7d': 7, '30d': 30 };
29
+ /**
30
+ * How far a window has to fall between two readings to be a window that turned over.
31
+ *
32
+ * A rolling allowance sags as the oldest usage in it ages out, a point or two at a time, and
33
+ * calling that a reset would put a turnover marker on the curve several times an hour. What a
34
+ * real reset looks like is a cliff: the window ends, everything in it goes, and the next reading
35
+ * is near zero. Five points is comfortably above the sag and far below the cliff.
36
+ */
37
+ const RESET_DROP_POINTS = 5;
38
+ /**
39
+ * The journal, aggregated over a range of local days.
40
+ *
41
+ * Sequential on purpose, a file at a time: `serve` answers this on the same thread it samples
42
+ * the fleet on, and thirty files opened at once is thirty buffers of a day each in memory for
43
+ * an answer that is a few hundred rows.
44
+ */
45
+ export async function readRange({ dir, range, now, capped = false }) {
46
+ const daysRequested = RANGE_DAYS[range];
47
+ const coverage = { daysRequested, lines: 0, skipped: 0, outOfRange: 0, droppedSessions: 0, capped };
48
+ const hours = new Map();
49
+ const days = new Map();
50
+ const resets = [];
51
+ // The last percentage each window was seen at, carried across files: a reset at four in the
52
+ // morning is a fall from the last reading of one day to the first of the next. It is never
53
+ // cleared, so a serve that was off for four days reports the fall across the gap as one
54
+ // turnover on the morning it came back, which is where the record resumes rather than where
55
+ // the window actually rolled. A reset in the first minute of the oldest day has nothing to
56
+ // fall from and is not reported at all, so `7d` and `30d` can disagree about that one day.
57
+ const previous = new Map();
58
+ // The window a reading has to fall in to be aggregated at all. The day files are named by the
59
+ // writer's clock and each line carries the reading's own, so the two can disagree: a torn line
60
+ // that still parses, a clock corrected by NTP between the two. Bucketing that by its `t` puts
61
+ // an hour in 1970 on a chart that says "the last seven days", and a `t` past what a Date can
62
+ // express buckets to `NaN`, which serialises as `null` and takes the ordering with it.
63
+ const days_ = calendarDays(now, daysRequested);
64
+ const windowStart = startOfDay(now, daysRequested - 1);
65
+ const windowEnd = startOfDay(now, -1);
66
+ for (const date of days_) {
67
+ let text;
68
+ try {
69
+ text = await fs.readFile(path.join(dir, `${date}.jsonl`), 'utf8');
70
+ }
71
+ catch {
72
+ // A day with no file is the normal case: `serve` was not running. A day whose file cannot
73
+ // be read is the same answer for this reader, and `serve` is not the process that fixes it.
74
+ continue;
75
+ }
76
+ // The day a record is CHARGED to is the file it is in, and the hour it falls in is its own
77
+ // clock. The file name is the day the writer decided on, so a reading taken a second before
78
+ // midnight stays on the day it was journalled to rather than moving under the reader.
79
+ const spent = days.get(date) ?? new Map();
80
+ days.set(date, spent);
81
+ for (const line of text.split('\n')) {
82
+ if (line === '')
83
+ continue;
84
+ const record = recordOf(line);
85
+ if (record === null) {
86
+ coverage.skipped += 1;
87
+ continue;
88
+ }
89
+ // A reading nobody can date is a reading nothing can be charged to, hours and cost alike.
90
+ // `NaN` fails both comparisons, which is the point. Counted apart from the torn lines:
91
+ // one is a filesystem that failed, the other a clock that disagrees with a file name.
92
+ if (!(record.t >= windowStart && record.t < windowEnd)) {
93
+ coverage.outOfRange += 1;
94
+ continue;
95
+ }
96
+ coverage.lines += 1;
97
+ coverage.droppedSessions += record.dropped;
98
+ const hour = hourOf(record.t);
99
+ const acc = hours.get(hour) ?? { n: 0, sessions: new Map(), five: null, seven: null };
100
+ acc.n += 1;
101
+ hours.set(hour, acc);
102
+ for (const s of record.sessions) {
103
+ // No id, no history. Two nameless readings a minute apart are not knowably one session,
104
+ // and folding them together would invent a cost nobody spent. They are in the file, and
105
+ // the live views still show them: what cannot be done is follow them through time.
106
+ if (s.sid === null) {
107
+ coverage.droppedSessions += 1;
108
+ continue;
109
+ }
110
+ const held = acc.sessions.get(s.sid);
111
+ if (held === undefined) {
112
+ acc.sessions.set(s.sid, {
113
+ project: s.project,
114
+ kind: s.kind,
115
+ ctxPct: s.ctxPct,
116
+ costUsd: s.costUsd,
117
+ // Dated by the reading that MEASURED it, so a first reading carrying no cost cannot
118
+ // date an absence and refuse every earlier reading that had one.
119
+ costAt: s.costUsd === null ? -Infinity : record.t,
120
+ state: s.state,
121
+ lastAt: record.t,
122
+ });
123
+ }
124
+ else {
125
+ if (s.ctxPct !== null && (held.ctxPct === null || s.ctxPct > held.ctxPct))
126
+ held.ctxPct = s.ctxPct;
127
+ // A reading that measured no cost does not erase the one before it: what is wanted is
128
+ // the last cost that was MEASURED, and a snapshot that never landed measured nothing.
129
+ if (s.costUsd !== null && record.t >= held.costAt) {
130
+ held.costUsd = s.costUsd;
131
+ held.costAt = record.t;
132
+ }
133
+ if (record.t >= held.lastAt) {
134
+ held.lastAt = record.t;
135
+ held.state = s.state;
136
+ }
137
+ // A project and a kind are identities, not measurements: a reading that could not name
138
+ // one has not renamed anything, and letting the last one win moved a whole session
139
+ // under a nameless heading because the minute it was last seen in was a thin one.
140
+ if (s.project !== null)
141
+ held.project = s.project;
142
+ if (s.kind !== null)
143
+ held.kind = s.kind;
144
+ }
145
+ if (s.costUsd === null)
146
+ continue;
147
+ const day = spent.get(s.sid);
148
+ if (day === undefined)
149
+ spent.set(s.sid, { project: s.project, min: s.costUsd, max: s.costUsd });
150
+ else {
151
+ if (s.project !== null)
152
+ day.project = s.project;
153
+ if (s.costUsd < day.min)
154
+ day.min = s.costUsd;
155
+ if (s.costUsd > day.max)
156
+ day.max = s.costUsd;
157
+ }
158
+ }
159
+ // The account's two windows, read through the same parser the gauges are drawn from, so a
160
+ // percentage this refuses is a percentage the live view refuses too. The clock is only
161
+ // used for how long a window has left, which nothing here asks.
162
+ for (const gauge of readLimits(record.rateLimits, record.t)) {
163
+ if (gauge.pct === null)
164
+ continue;
165
+ const before = previous.get(gauge.key);
166
+ if (before !== undefined && before.pct - gauge.pct > RESET_DROP_POINTS) {
167
+ resets.push({ limit: gauge.key, t: record.t, from: before.pct, to: gauge.pct, sinceMs: record.t - before.t });
168
+ }
169
+ previous.set(gauge.key, { pct: gauge.pct, t: record.t });
170
+ if (gauge.key === 'five_hour' && (acc.five === null || gauge.pct > acc.five))
171
+ acc.five = gauge.pct;
172
+ if (gauge.key === 'seven_day' && (acc.seven === null || gauge.pct > acc.seven))
173
+ acc.seven = gauge.pct;
174
+ }
175
+ }
176
+ }
177
+ return {
178
+ range,
179
+ hours: [...hours.entries()]
180
+ .sort(([a], [b]) => a - b)
181
+ .map(([t, acc]) => ({
182
+ t,
183
+ sessions: [...acc.sessions.entries()]
184
+ .sort(([a], [b]) => compare(a, b))
185
+ .map(([sid, s]) => ({
186
+ sid,
187
+ project: s.project,
188
+ kind: s.kind,
189
+ ctxPct: s.ctxPct,
190
+ costUsd: s.costUsd,
191
+ state: s.state,
192
+ })),
193
+ n: acc.n,
194
+ rateLimits: { five_hour: acc.five, seven_day: acc.seven },
195
+ })),
196
+ // Already oldest first: the map is filled in the order `calendarDays` walks, and a day is
197
+ // read once. A sort here would be a line nothing could ever put in the wrong order.
198
+ days: [...days.entries()].map(([date, spent]) => ({ date, byProject: byProject(spent) })),
199
+ resets,
200
+ coverage,
201
+ };
202
+ }
203
+ /**
204
+ * What each project spent on a day: for every session id, the highest cost it was read at less
205
+ * the lowest, summed.
206
+ *
207
+ * A difference rather than a total, because a total is wrong twice. A session recycled at three
208
+ * in the morning is two ids under one project, and adding their final costs counts the night's
209
+ * work twice; a session left open across midnight starts the new day carrying everything it
210
+ * spent on the old one, and charging that to the new day bills yesterday again. What each id
211
+ * spent WITHIN the day is the only quantity both cases agree on.
212
+ *
213
+ * Highest less lowest, and deliberately not last less first. Cost comes off the statusline
214
+ * payload, which nobody promised would only ever climb: a counter that drops mid-day would then
215
+ * make a NEGATIVE day, and negative money in a total is worse than a day billed as though it had
216
+ * only climbed. It is a floor either way: whatever was spent between the last reading of one day
217
+ * and the first of the next belongs to neither, which is a minute of drift and never a session.
218
+ */
219
+ function byProject(spent) {
220
+ const totals = new Map();
221
+ for (const { project, min, max } of spent.values()) {
222
+ // Keyed on a string so a project nobody could name has a bucket of its own rather than
223
+ // sharing one with the first project whose name happens to be missing too. NUL is the
224
+ // sentinel because it is the one string a project name cannot be: `path.basename('/')` is
225
+ // the empty string, so `''` would be a real bucket. Written as an escape, never as the byte
226
+ // itself, which turns this file and the `dist/` it compiles to into `file`-classified data.
227
+ const key = project ?? '\u0000';
228
+ const held = totals.get(key) ?? { project, costUsd: 0 };
229
+ held.costUsd += max - min;
230
+ totals.set(key, held);
231
+ }
232
+ // Ties broken by name, and by code point rather than by locale: `localeCompare` answers out of
233
+ // the ICU data the process happens to have, so two serves on one machine could order the same
234
+ // two projects differently. Nothing else in this file sorts strings any other way.
235
+ return [...totals.values()].sort((a, b) => b.costUsd - a.costUsd || compare(a.project ?? '', b.project ?? ''));
236
+ }
237
+ /** The local days a range covers, oldest first, today included. */
238
+ function calendarDays(now, days) {
239
+ const out = [];
240
+ for (let i = days - 1; i >= 0; i--)
241
+ out.push(dayOf(startOfDay(now, i)));
242
+ return out;
243
+ }
244
+ /**
245
+ * Midnight `back` local days before the day `now` falls on. `back: -1` is the midnight that ends
246
+ * today, which is what bounds the range at the near end.
247
+ *
248
+ * Calendar arithmetic, never 24-hour blocks: two of those cross a DST boundary as 47 or 49 hours
249
+ * and move the oldest day by one, on the one morning a year a clock shifted. This is the store's
250
+ * own rule for its retention, and the two have to name the same files.
251
+ */
252
+ function startOfDay(now, back) {
253
+ const d = new Date(now);
254
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - back).getTime();
255
+ }
256
+ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
257
+ const pad = (n) => String(n).padStart(2, '0');
258
+ const dayOf = (t) => {
259
+ const d = new Date(t);
260
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
261
+ };
262
+ /** The start of the local hour a moment falls in. Local, so a bucket is an hour of someone's day. */
263
+ function hourOf(t) {
264
+ const d = new Date(t);
265
+ d.setMinutes(0, 0, 0);
266
+ return d.getTime();
267
+ }
268
+ /**
269
+ * One line, checked for the shape a reading has, or `null` for the caller to count.
270
+ *
271
+ * Strict about the frame and forgiving inside it. A record has to have a clock that is a number
272
+ * and a list of sessions, because everything downstream buckets on those two; a session has to
273
+ * be an object, and every field it carries is taken if it is the right type and dropped if it is
274
+ * not. That is the same discipline the live views apply to the payload: a value that will not
275
+ * read is absent, never a zero, and never a reason to throw away the reading around it.
276
+ */
277
+ function recordOf(line) {
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(line);
281
+ }
282
+ catch {
283
+ // The torn line of #134, and anything else a filesystem did to this file.
284
+ return null;
285
+ }
286
+ if (!isObject(parsed))
287
+ return null;
288
+ const t = parsed.t;
289
+ if (typeof t !== 'number' || !Number.isFinite(t))
290
+ return null;
291
+ if (!Array.isArray(parsed.sessions))
292
+ return null;
293
+ // Not a reason to refuse the line. `rate_limits: []` is legal JSON, `limits.ts` names it as a
294
+ // shape the source sends, and `snapshots.ts` lets it through because `typeof [] === 'object'`,
295
+ // so it can be in the file. The live gauges read it as two windows nobody measured; refusing
296
+ // it here would blank every cost, context and project of every minute it appears in.
297
+ const rateLimits = isObject(parsed.rateLimits) ? parsed.rateLimits : null;
298
+ const sessions = [];
299
+ let dropped = 0;
300
+ for (const entry of parsed.sessions) {
301
+ // Same rule one level down: what cannot be read is dropped, never the readings beside it.
302
+ if (!isObject(entry)) {
303
+ dropped += 1;
304
+ continue;
305
+ }
306
+ sessions.push({
307
+ // The empty string is not an id. It is what a payload writes where a session had none,
308
+ // and one bucket named `''` would fold every nameless reading into a single session.
309
+ sid: typeof entry.sid === 'string' && entry.sid !== '' ? entry.sid : null,
310
+ project: typeof entry.project === 'string' ? entry.project : null,
311
+ kind: typeof entry.kind === 'string' ? entry.kind : null,
312
+ ctxPct: number(entry.ctxPct),
313
+ costUsd: number(entry.costUsd),
314
+ state: typeof entry.state === 'string' ? entry.state : null,
315
+ });
316
+ }
317
+ return { t, sessions, rateLimits, dropped };
318
+ }
319
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
320
+ const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);