@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,381 @@
1
+ // The journal, and the only file in this project that exists because someone asked for it.
2
+ //
3
+ // `history.ts` states the rule this module is the exception to: a fleet journal on disk would
4
+ // outlive the process that made it and sit in a home directory carrying session ids and costs,
5
+ // so `serve` keeps its day in memory and writes nothing. That default does not move. What moves
6
+ // is that a reader can now set `history.days` and get a journal on their own machine, in
7
+ // writing, having been told what goes in it and how big it gets.
8
+ //
9
+ // Three properties make that opt-in something other than the file the README refused:
10
+ // • the line is the RING's sample, produced by the ring's own serialiser. No name, no working
11
+ // directory, and no second shape to keep true. What `/api/history` serves is what lands.
12
+ // • it is bounded twice. By age, which the reader set, and by a hard cap they did not, so a
13
+ // fleet ten times the size of the one the startup line quoted cannot fill a disk quietly.
14
+ // • it is best effort. A write that fails is counted and never thrown: `serve` runs
15
+ // unattended for hours, and a throw out of the sampler is an unhandled rejection.
16
+ //
17
+ // One owner, and that is why the directory is a SIBLING of `snapshots/` rather than a file
18
+ // inside it. Three things already delete under the snapshots directory (`reap.ts`, the
19
+ // wrapper's own sweep, the legacy purge in `install.ts`) and each of them decides by name.
20
+ // A journal living among the payloads would be one glob away from being someone else's litter.
21
+ // That owner is literal as well as architectural: `acquireJournalLock` below gives the directory
22
+ // one writing `serve`, and a second one journals nothing rather than sweeping files it did not
23
+ // write (#133).
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ /**
27
+ * The ceiling nobody can raise. The age limit is the reader's number and it is not a bound on
28
+ * its own: it prices a fleet of eight sessions at about a megabyte a day, and a fleet of two
29
+ * hundred writes a hundred times that. This is what stands between a forgotten config key and
30
+ * a full disk, which is why it is a constant and not a second knob.
31
+ */
32
+ export const HISTORY_MAX_BYTES = 256 * 1024 * 1024;
33
+ /**
34
+ * A day this store wrote, and nothing else. Built from the name the writer uses rather than
35
+ * from `*.jsonl`: only what we wrote gets deleted, which is the rule `reap.ts` states for the
36
+ * temp files and the reason a journal in a directory of its own is worth the directory.
37
+ */
38
+ const DAY_FILE = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
39
+ /** The date half of that name, which is also the shape a computed cutoff has to have. */
40
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
41
+ /**
42
+ * Where the journal lives, given where the snapshots are read from: beside them, never among
43
+ * them. Same base, so a reader who moved the snapshots moved the journal with them, and #11
44
+ * (the installed path frozen at install time) covers both by covering one.
45
+ */
46
+ export const historyDirFor = (snapshotsDir) => path.join(path.dirname(snapshotsDir), 'history');
47
+ /** The lock, named so `DAY_FILE` cannot mistake it for a day and delete it. */
48
+ const LOCK_FILE = '.lock';
49
+ /**
50
+ * How long a lock may go without a heartbeat before the next `serve` takes the directory.
51
+ *
52
+ * The pid answers the ordinary death (a `kill -9`, a machine that lost power) and it cannot
53
+ * answer the other one: pids are REUSED, so the number in a file abandoned last week can name a
54
+ * stranger who is alive and has never heard of this directory. Five minutes is four missed
55
+ * heartbeats, which a laptop that slept or a fleet read that took its whole deadline can spend,
56
+ * and short enough that nobody who rebooted sits waiting for their journal to come back.
57
+ */
58
+ const LOCK_STALE_MS = 5 * 60_000;
59
+ /** The pid a lock file names, or `null` for one that is gone or says something else. */
60
+ const lockPid = (file) => {
61
+ try {
62
+ const text = fs.readFileSync(file, 'utf8').trim();
63
+ return /^\d+$/.test(text) ? Number(text) : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ };
69
+ /** Whether a pid is a process. `EPERM` is one this user may not signal, which is still one. */
70
+ const running = (pid) => {
71
+ try {
72
+ process.kill(pid, 0);
73
+ return true;
74
+ }
75
+ catch (e) {
76
+ return e.code === 'EPERM';
77
+ }
78
+ };
79
+ /**
80
+ * One owner for the directory, taken at startup and given back on the way out.
81
+ *
82
+ * Retention is a property of the DIRECTORY and the process applying it was whichever `serve`
83
+ * started last: a `serve --history-days 1` started to try the setting out swept twenty-nine days
84
+ * a thirty-day serve had kept, in four seconds, and both then wrote a line a minute into the
85
+ * same files. That is not a contract this ships, and a second serve is now told to journal
86
+ * nothing rather than arbitrated with (#133).
87
+ *
88
+ * `wx`, so taking it is one atomic filesystem operation rather than a read followed by a write
89
+ * that a second serve can land inside. Everything else here is about the locks nobody released:
90
+ * a dead pid, or five minutes of silence from a live one.
91
+ */
92
+ export function acquireJournalLock({ dir, now = Date.now }) {
93
+ const file = path.join(dir, LOCK_FILE);
94
+ const touch = () => {
95
+ if (lockPid(file) !== process.pid)
96
+ return false;
97
+ const t = now() / 1000;
98
+ try {
99
+ fs.utimesSync(file, t, t);
100
+ return true;
101
+ }
102
+ catch {
103
+ // Gone between the read and the write. Not ours either way, and the caller stops.
104
+ return false;
105
+ }
106
+ };
107
+ const release = () => {
108
+ // Ours only. A process the kernel stopped for five minutes has its lock legitimately taken
109
+ // off it; removing the new owner's file on the way out would hand the directory to a third.
110
+ try {
111
+ if (lockPid(file) === process.pid)
112
+ fs.unlinkSync(file);
113
+ }
114
+ catch {
115
+ // A directory already gone, or one we may no longer write. Either way it is not held.
116
+ }
117
+ };
118
+ const take = () => {
119
+ try {
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ fs.writeFileSync(file, `${process.pid}\n`, { flag: 'wx' });
122
+ return { file, touch, release };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ };
128
+ const taken = take();
129
+ if (taken !== null)
130
+ return { lock: taken, heldBy: null };
131
+ const pid = lockPid(file);
132
+ let beat;
133
+ try {
134
+ beat = fs.statSync(file).mtimeMs;
135
+ }
136
+ catch {
137
+ // No file to read a heartbeat off, so nothing holds this: it went between the two calls.
138
+ beat = null;
139
+ }
140
+ // A fresh heartbeat holds the directory whether or not the pid can be read, and that order
141
+ // matters: taking the lock is `open(O_CREAT|O_EXCL)` and THEN a write, so between the two the
142
+ // file exists and is empty. Reading no pid as "nothing holds this" let a second serve remove
143
+ // the lock of a serve in the middle of taking it, which is two owners on a window one syscall
144
+ // wide. An unreadable lock is not unbreakable either: with no heartbeat for five minutes it
145
+ // goes the same way a dead pid's does.
146
+ if (beat !== null && now() - beat <= LOCK_STALE_MS && (pid === null || running(pid)))
147
+ return { lock: null, heldBy: pid };
148
+ // Only the file that was JUDGED, never whatever is there now: two serves reclaiming the same
149
+ // abandoned lock together would otherwise both remove one and both create one, and the second
150
+ // create is the one on disk. Re-reading the pid and the heartbeat costs two syscalls and takes
151
+ // that from six rounds in twelve to none, measured on eight processes released off a barrier.
152
+ //
153
+ // It is a narrowing and not a proof: the read and the unlink are still two operations. What
154
+ // closes it is one level up, where every tick re-reads the lock before writing, so a directory
155
+ // that ended up with two owners has one again within the minute.
156
+ try {
157
+ if (lockPid(file) === pid && fs.statSync(file).mtimeMs === beat)
158
+ fs.unlinkSync(file);
159
+ }
160
+ catch {
161
+ // Already gone, or not ours to remove. The retry below is what decides either way.
162
+ }
163
+ const reclaimed = take();
164
+ // A lock we could not take and could not reclaim. `heldBy` is read again rather than
165
+ // remembered: the serve that won the race in between is the one worth naming.
166
+ return reclaimed !== null ? { lock: reclaimed, heldBy: null } : { lock: null, heldBy: lockPid(file) };
167
+ }
168
+ const pad = (n) => String(n).padStart(2, '0');
169
+ /**
170
+ * The local day a moment falls on, `YYYY-MM-DD`.
171
+ *
172
+ * Local and not UTC, because a file called `2026-08-07.jsonl` is read by a person who was awake
173
+ * on the 7th. It also makes the name sort as the date does, which is what lets the prune below
174
+ * compare file names instead of parsing them.
175
+ */
176
+ const dayOf = (t) => {
177
+ const d = new Date(t);
178
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
179
+ };
180
+ /**
181
+ * The oldest day a retention of `days` still covers, today included: `days: 1` keeps today
182
+ * alone, `days: 30` keeps today and the 29 before it.
183
+ *
184
+ * Counted on the CALENDAR rather than by subtracting 24-hour blocks. Two of those cross a DST
185
+ * boundary as 47 or 49 hours, which moves the answer by a day for anyone whose clock shifted
186
+ * that night: the one morning a year when the retention a reader set is not the one they get.
187
+ */
188
+ const oldestKept = (now, days) => {
189
+ const d = new Date(now);
190
+ return dayOf(new Date(d.getFullYear(), d.getMonth(), d.getDate() - (days - 1)).getTime());
191
+ };
192
+ /**
193
+ * The reading, reduced to what goes on disk.
194
+ *
195
+ * An ALLOWLIST, spelled field by field, and deliberately not a spread with a delete: a field
196
+ * added to `HistorySession` tomorrow does not compile here until somebody has decided whether
197
+ * it belongs in a file that outlives the process. The omission that has to survive every future
198
+ * edit cannot be written as a subtraction from a shape that is free to grow.
199
+ */
200
+ const lineOf = ({ t, sessions, rateLimits }) => ({
201
+ t,
202
+ sessions: sessions.map(({ sid, project, kind, state, ctxState, ctxPct, costUsd }) => ({
203
+ sid,
204
+ project,
205
+ kind,
206
+ state,
207
+ ctxState,
208
+ ctxPct,
209
+ costUsd,
210
+ })),
211
+ rateLimits,
212
+ });
213
+ /** Bytes as the cap is spoken about, so a refusal reads like the number that caused it. */
214
+ const size = (bytes) => bytes >= 1024 * 1024 ? `${Math.round(bytes / (1024 * 1024))} MB` : `${bytes} bytes`;
215
+ export function createHistoryStore({ dir, days, now = Date.now, maxBytes = HISTORY_MAX_BYTES, lock = null, }) {
216
+ let misses = 0;
217
+ let stopped = null;
218
+ // The cause behind `stopped`, when it is the ceiling. Kept apart from the sentence because one
219
+ // of them is read by a page and the other by a person.
220
+ let capped = false;
221
+ // Reassigned when the directory is erased under a running serve and this store takes its own
222
+ // lock back, which is the one case where losing it is not losing the journal.
223
+ let held = lock;
224
+ // The last local day a prune ran for, so the store keeps its own retention while `serve` runs
225
+ // for weeks. `null` until the first append or the startup prune: a store that has never
226
+ // written has nothing to prune, and inventing a day here would make the first append sweep.
227
+ let prunedDay = null;
228
+ /**
229
+ * Whether this store still owns its directory, and the heartbeat that says it is alive.
230
+ *
231
+ * Read on every tick rather than trusted from startup, because both ways of losing a directory
232
+ * are silent. Another `serve` reclaims a lock this one let go quiet, and a blind writer then
233
+ * appends into somebody else's journal and sweeps it with a retention nobody there set: #133,
234
+ * one heartbeat later. Or the reader erases the directory, which the manual invites them to do,
235
+ * and the lock goes with it; nobody took anything, so the lock is simply taken back. The
236
+ * acquire is what tells the two apart, since it refuses one a live process is touching.
237
+ */
238
+ const owns = () => {
239
+ // No lock is the suite, and a store nobody gave a directory to has none to lose.
240
+ if (held === null)
241
+ return true;
242
+ if (held.touch())
243
+ return true;
244
+ const { lock: again, heldBy } = acquireJournalLock({ dir, now });
245
+ if (again === null) {
246
+ stopped = heldBy === null ? `the lock in ${dir} could not be taken back` : `another serve (pid ${heldBy}) holds ${dir}`;
247
+ return false;
248
+ }
249
+ held = again;
250
+ return true;
251
+ };
252
+ /**
253
+ * What the directory holds right now, read rather than remembered.
254
+ *
255
+ * A running total would be faster and would be a second truth: this directory is also the
256
+ * one a reader is invited to `rm -rf` when they change their mind, and a cached size would
257
+ * then refuse to write into a directory that is empty. Once a minute, over the handful of
258
+ * files a retention allows, the readdir costs nothing worth a cache.
259
+ */
260
+ const measure = () => {
261
+ let entries;
262
+ try {
263
+ entries = fs.readdirSync(dir);
264
+ }
265
+ catch {
266
+ // No directory yet, or one we may not read. Either way there is no journal to report on,
267
+ // and `append` raises the alarm that matters by failing to write and counting it.
268
+ return { files: 0, bytes: 0 };
269
+ }
270
+ let files = 0;
271
+ let bytes = 0;
272
+ for (const name of entries) {
273
+ if (!DAY_FILE.test(name))
274
+ continue;
275
+ try {
276
+ const s = fs.statSync(path.join(dir, name));
277
+ if (!s.isFile())
278
+ continue;
279
+ files += 1;
280
+ bytes += s.size;
281
+ }
282
+ catch {
283
+ // A file that vanished between the readdir and the stat weighs nothing.
284
+ }
285
+ }
286
+ return { files, bytes };
287
+ };
288
+ const prune = () => {
289
+ // Here as well as in `append`, because this is the OTHER path to a deletion: the startup
290
+ // sweep runs on `listening`, before any tick, and it is the destructive half of #133.
291
+ if (!owns())
292
+ return { removed: 0, failed: 0 };
293
+ prunedDay = dayOf(now());
294
+ const keep = oldestKept(now(), days);
295
+ let removed = 0;
296
+ let failed = 0;
297
+ // A cutoff nothing can compute is a cutoff nothing is deleted by. `days` is only bounded
298
+ // below, and a retention past what the calendar can express (about 274 000 years) makes an
299
+ // Invalid Date, whose day reads `NaN-NaN-NaN`. Every real file name sorts BELOW that
300
+ // string, so the comparison further down turned "keep this for ever" into "delete all of
301
+ // it", silently. The absurd number is not the danger; the inversion is.
302
+ if (!DAY.test(keep))
303
+ return { removed, failed };
304
+ let entries;
305
+ try {
306
+ entries = fs.readdirSync(dir);
307
+ }
308
+ catch {
309
+ return { removed, failed };
310
+ }
311
+ for (const name of entries) {
312
+ if (!DAY_FILE.test(name))
313
+ continue;
314
+ // The name IS the date, zero-padded, so a string comparison is the date comparison.
315
+ if (name.slice(0, 10) >= keep)
316
+ continue;
317
+ try {
318
+ fs.unlinkSync(path.join(dir, name));
319
+ removed += 1;
320
+ }
321
+ catch {
322
+ failed += 1;
323
+ }
324
+ }
325
+ // The cap is a stop, not a verdict: the retention that made room is what lifts it, and a
326
+ // store that stayed stopped until the next restart would keep a promise nobody made.
327
+ if (stopped !== null && capped && measure().bytes < maxBytes) {
328
+ stopped = null;
329
+ capped = false;
330
+ }
331
+ return { removed, failed };
332
+ };
333
+ return {
334
+ dir,
335
+ days,
336
+ heartbeat() {
337
+ void owns();
338
+ },
339
+ append(sample) {
340
+ // Before the day-turn prune below and before any write: a store that no longer owns its
341
+ // directory may not sweep it, and the sweep is the destructive half of #133.
342
+ if (!owns())
343
+ return;
344
+ const day = dayOf(now());
345
+ // Its own retention, kept as the day turns rather than only at startup. A machine left up
346
+ // since March would otherwise hold every day since March under a config that says thirty.
347
+ if (prunedDay !== null && prunedDay !== day)
348
+ prune();
349
+ else if (prunedDay === null)
350
+ prunedDay = day;
351
+ // One line, one reading, terminated: a reader tailing this file sees whole records, and a
352
+ // process killed between two appends leaves the last one complete.
353
+ const line = JSON.stringify(lineOf(sample)) + '\n';
354
+ const bytes = Buffer.byteLength(line);
355
+ const onDisk = measure().bytes;
356
+ if (onDisk + bytes > maxBytes) {
357
+ stopped = `the journal is at its ${size(maxBytes)} cap, in ${dir}`;
358
+ capped = true;
359
+ return;
360
+ }
361
+ stopped = null;
362
+ capped = false;
363
+ try {
364
+ fs.mkdirSync(dir, { recursive: true });
365
+ // `a`, so two writers cannot interleave a partial line and a restart cannot truncate
366
+ // what is already there. This process is the only writer, and the flag says so anyway.
367
+ fs.appendFileSync(path.join(dir, `${day}.jsonl`), line, { flag: 'a' });
368
+ }
369
+ catch {
370
+ // A full disk, a read-only mount, a directory someone replaced with a file. None of
371
+ // them are worth a dead `serve`, and none of them may pass in silence either.
372
+ misses += 1;
373
+ }
374
+ },
375
+ prune,
376
+ stats() {
377
+ const { files, bytes } = measure();
378
+ return { files, bytes, misses, stopped, capped };
379
+ },
380
+ };
381
+ }