@adrrr/tarmac 0.8.1 → 0.10.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.
package/dist/demo.js ADDED
@@ -0,0 +1,392 @@
1
+ // P6 — the fleet `tarmac serve --demo` shows.
2
+ //
3
+ // Why it ships. A first run is one session and no record: a table of one row and three charts
4
+ // with nothing in them, which is the screen that sells this tool to everybody who has not
5
+ // installed it yet (#150). `--demo` puts a plausible fleet and a day of its history in front of
6
+ // that reader instead, and says on the page that it is invented.
7
+ //
8
+ // Where it plugs in, and why THERE. The product's pitch is that it parses no internal format:
9
+ // it reads `claude agents --json` and the statusline payload, both documented, and joins them.
10
+ // So this module feeds the structures those two are already PARSED into — `Session[]` and a map
11
+ // of `Snapshot` — and hands them to the same `buildFleet` the real collector calls. It writes no
12
+ // JSON for anyone to read back, prints no `claude agents --json` for anyone to parse, and there
13
+ // is therefore no second parsing path to keep in step with the first. A demo built the other way
14
+ // round — a fake CLI whose output the real reader re-parses — would be a copy of the one surface
15
+ // this repo cannot afford to have two of.
16
+ //
17
+ // What it may not do, held by `test/demo.test.ts`: read this machine's FLEET, spawn anything, or
18
+ // write a byte. Every path below hangs off an invented home, and the end-to-end checks run the
19
+ // real `serve --demo` with no `claude` on the box and no snapshot directory anywhere.
20
+ //
21
+ // "Its fleet", precisely, and not "nothing at all": `serve` still resolves its settings, so a
22
+ // config file naming a port or a trusted host is read under `--demo` exactly as it always was.
23
+ // What is never opened is the snapshot directory, `claude`, and the journal.
24
+ import { DEFAULT_STALE_AFTER_MS } from './config.js';
25
+ import { buildFleet } from './fleet.js';
26
+ import { createHistory, HISTORY_CADENCE_MS, HISTORY_SLOTS } from './history.js';
27
+ /**
28
+ * The invented home every path here hangs off. Never `os.homedir()`, ever — a demo that
29
+ * borrowed one string off the machine it runs on would put somebody's directory names into
30
+ * every screenshot taken of it.
31
+ */
32
+ export const DEMO_HOME = '/Users/jane';
33
+ /** Where the invented sessions are working. Ordinary names, belonging to nobody. */
34
+ const WORK = `${DEMO_HOME}/work`;
35
+ /** How many sessions the shown minute holds. Eight is a fleet; one is the screen #150 is about. */
36
+ export const DEMO_SESSIONS = 8;
37
+ /**
38
+ * How many minutes of fleet the demo plays. One more than the ring holds, so the record served
39
+ * to the scrubber has dropped a slot and dates itself by its own oldest reading rather than by
40
+ * the moment the process started — which would be a day later than the day it is showing.
41
+ */
42
+ export const DEMO_MINUTES = HISTORY_SLOTS + 1;
43
+ /** The Claude Code whose payload shapes this repo has fixtures for, so nothing reads unchecked. */
44
+ const CC_VERSION = '2.1.232';
45
+ /** The context window every invented session is filling. */
46
+ const CTX_WINDOW = 1_000_000;
47
+ /** The five-hour window, in minutes, and how full each successive one gets. */
48
+ const WINDOW_MINUTES = 300;
49
+ const WINDOW_PEAKS = [58, 84, 71, 49, 77];
50
+ /**
51
+ * Where the seven-day window stands at the newest minute, and what a day of this fleet costs
52
+ * it. Six points a day over the journal's seven days lands the oldest hour near 1% — a slope
53
+ * chosen against `DEMO_JOURNAL_DAYS`, because `readLimits` refuses a negative percentage and a
54
+ * steeper week would not draw a wrong line, it would draw nothing.
55
+ */
56
+ const SEVEN_DAY_TOP = 43;
57
+ const SEVEN_DAY_PER_DAY = 6;
58
+ const OPUS = { id: 'claude-opus-5', display_name: 'Opus 5' };
59
+ const FABLE = { id: 'claude-fable-5', display_name: 'Fable 5' };
60
+ /**
61
+ * The eight. Ordinary project names under an invented home, and the two background agents are
62
+ * named after their prompt, which is what `claude agents --json` calls one.
63
+ *
64
+ * They arrive across the day rather than all at midnight — three at the start, eight by the end
65
+ * — because a record whose every minute holds the same fleet is a scrubber with nothing behind
66
+ * it, and flat charts are the thing #150 is replacing.
67
+ */
68
+ const ACTORS = [
69
+ {
70
+ sid: '4e91c0a7-1d35-4b82-9f60-3a7c58d2e014',
71
+ cwd: `${WORK}/api-gateway`,
72
+ name: 'api-refactor',
73
+ kind: 'interactive',
74
+ pid: 30412,
75
+ model: OPUS,
76
+ effort: 'high',
77
+ born: 0,
78
+ ctxFrom: 8,
79
+ // Steep enough that this one ends the day near a compact. A fleet whose fullest window is
80
+ // two thirds is a fleet with nothing at stake, and the number most people install this to
81
+ // watch is the one that is about to run out.
82
+ ctxPerBusyMinute: 0.147,
83
+ costPerBusyMinute: 0.038,
84
+ ageMs: 2_000,
85
+ // The recycle at 642 is why its ramp restarts: a compacted session is a new window.
86
+ fresh: { from: 642, to: 654 },
87
+ segments: [
88
+ { from: 0, status: 'idle' },
89
+ { from: 126, status: 'busy' },
90
+ { from: 488, status: 'idle' },
91
+ { from: 654, status: 'busy' },
92
+ { from: 1088, status: 'idle' },
93
+ { from: 1297, status: 'busy' },
94
+ ],
95
+ },
96
+ {
97
+ sid: '9b2f47d1-6c08-4a53-8e19-2d4b70f6c385',
98
+ cwd: `${WORK}/docs-site`,
99
+ name: 'docs-site',
100
+ kind: 'interactive',
101
+ pid: 31877,
102
+ model: FABLE,
103
+ effort: 'medium',
104
+ born: 0,
105
+ ctxFrom: 5,
106
+ ctxPerBusyMinute: 0.07,
107
+ costPerBusyMinute: 0.019,
108
+ ageMs: 68_000,
109
+ segments: [
110
+ { from: 0, status: 'idle' },
111
+ { from: 318, status: 'busy' },
112
+ { from: 536, status: 'idle' },
113
+ { from: 1120, status: 'busy' },
114
+ { from: 1392, status: 'idle' },
115
+ ],
116
+ },
117
+ {
118
+ sid: '6a04d92e-8b17-4c60-95f3-0e2a6b48d1c7',
119
+ cwd: `${WORK}/data-pipeline`,
120
+ name: 'data-pipeline',
121
+ kind: 'interactive',
122
+ pid: 44120,
123
+ model: FABLE,
124
+ effort: 'max',
125
+ born: 0,
126
+ ctxFrom: 11,
127
+ ctxPerBusyMinute: 0.06,
128
+ costPerBusyMinute: 0.041,
129
+ ageMs: 3_000,
130
+ segments: [
131
+ { from: 0, status: 'busy' },
132
+ { from: 74, status: 'idle' },
133
+ { from: 412, status: 'busy' },
134
+ { from: 869, status: 'idle' },
135
+ { from: 1013, status: 'busy' },
136
+ ],
137
+ },
138
+ {
139
+ sid: '1c58e3b6-4f70-49a2-b3d8-6e015a9c7f24',
140
+ cwd: `${WORK}/auth-service`,
141
+ name: 'bugfix-auth',
142
+ kind: 'interactive',
143
+ pid: 42713,
144
+ model: OPUS,
145
+ effort: 'high',
146
+ born: 214,
147
+ ctxFrom: 4,
148
+ ctxPerBusyMinute: 0.09,
149
+ costPerBusyMinute: 0.033,
150
+ ageMs: 5_000,
151
+ segments: [
152
+ { from: 214, status: 'idle' },
153
+ { from: 266, status: 'busy' },
154
+ { from: 702, status: 'idle' },
155
+ { from: 1204, status: 'busy' },
156
+ // The one state that is work for the READER, and the only one that captions itself.
157
+ { from: 1398, status: 'waiting', waitingFor: 'permission prompt' },
158
+ ],
159
+ },
160
+ {
161
+ sid: '8e15fa03-7d62-4b91-83c0-5b9e14d6a07f',
162
+ cwd: `${WORK}/api-gateway`,
163
+ name: 're-run the payments suite until it is green',
164
+ kind: 'background',
165
+ pid: null,
166
+ model: FABLE,
167
+ effort: 'max',
168
+ born: 483,
169
+ ctxFrom: 4,
170
+ ctxPerBusyMinute: 0.05,
171
+ costPerBusyMinute: 0.017,
172
+ ageMs: 4_000,
173
+ segments: [{ from: 483, status: 'working' }],
174
+ },
175
+ {
176
+ sid: '3d76b18f-5029-4e74-a6c1-84f037b9e2d5',
177
+ cwd: `${WORK}/docs-site`,
178
+ name: 'release-notes',
179
+ kind: 'interactive',
180
+ pid: 46308,
181
+ model: OPUS,
182
+ effort: 'medium',
183
+ born: 908,
184
+ ctxFrom: 3,
185
+ ctxPerBusyMinute: 0.08,
186
+ costPerBusyMinute: 0.024,
187
+ ageMs: 9_000,
188
+ segments: [
189
+ { from: 908, status: 'idle' },
190
+ { from: 1004, status: 'busy' },
191
+ // A word `claude agents --json` prints and tarmac has no boolean for. It draws as
192
+ // `unknown`, never as idle, and the page names it above the fleet — which is the whole
193
+ // promise of the tool, so the demo shows it rather than a fleet with nothing to say.
194
+ { from: 1376, status: 'compacting' },
195
+ ],
196
+ },
197
+ {
198
+ sid: '2b90c47a-3e58-4fd6-9107-6ca85f2b0d93',
199
+ cwd: `${WORK}/data-pipeline`,
200
+ name: 'draft the migration notes',
201
+ kind: 'background',
202
+ pid: null,
203
+ model: OPUS,
204
+ effort: 'high',
205
+ born: 1017,
206
+ ctxFrom: 6,
207
+ ctxPerBusyMinute: 0.06,
208
+ costPerBusyMinute: 0.022,
209
+ ageMs: 7_000,
210
+ segments: [{ from: 1017, status: 'working' }],
211
+ },
212
+ {
213
+ sid: '5f8a63d0-9c14-4720-b8e5-71d3062fa4c8',
214
+ cwd: `${WORK}/storefront`,
215
+ name: 'checkout-flow',
216
+ kind: 'interactive',
217
+ pid: 47122,
218
+ model: FABLE,
219
+ effort: 'high',
220
+ born: 1183,
221
+ ctxFrom: 2,
222
+ ctxPerBusyMinute: 0.12,
223
+ costPerBusyMinute: 0.046,
224
+ ageMs: 47_000,
225
+ segments: [
226
+ { from: 1183, status: 'idle' },
227
+ { from: 1251, status: 'busy' },
228
+ { from: 1424, status: 'idle' },
229
+ ],
230
+ },
231
+ ];
232
+ /** Whether a status word means the session is spending tokens right now. */
233
+ const working = (status) => status === 'busy' || status === 'working';
234
+ /** Which segment an actor is in at `minute`. */
235
+ function segmentAt(actor, minute) {
236
+ let current = actor.segments[0];
237
+ for (const s of actor.segments)
238
+ if (s.from <= minute)
239
+ current = s;
240
+ return current;
241
+ }
242
+ /** How many minutes of work an actor has done by `minute` — what fills a window and costs money. */
243
+ function busyMinutes(actor, minute) {
244
+ let total = 0;
245
+ for (let i = 0; i < actor.segments.length; i++) {
246
+ const s = actor.segments[i];
247
+ if (!working(s.status))
248
+ continue;
249
+ const until = Math.min(actor.segments[i + 1]?.from ?? Infinity, minute + 1);
250
+ total += Math.max(0, until - s.from);
251
+ }
252
+ return total;
253
+ }
254
+ /**
255
+ * The account's two windows at `minute`. The five-hour one is what the replay is really about:
256
+ * it fills, rolls over at the boundary and fills again, four or five times in a day.
257
+ *
258
+ * Both resets are measured from `now`, the clock that took the reading, and not from the start
259
+ * of the invented day. For a seeded minute the two are the same instant and this changes
260
+ * nothing. For the live view they are not: it answers the last minute of the day for as long as
261
+ * the serve is open, so a reset pinned to `dayStart` fell into the past about an hour in, and
262
+ * the header then read "reset was due 4h ago" over a percentage that had not moved. That phrase
263
+ * is this codebase's own way of saying the number beside it belongs to a window that is gone,
264
+ * and a demo has no business showing it about an account nobody has.
265
+ */
266
+ function rateLimits(minute, now, cyclesBack = 0) {
267
+ const window = Math.floor(minute / WINDOW_MINUTES);
268
+ const elapsed = minute % WINDOW_MINUTES;
269
+ const peak = WINDOW_PEAKS[window % WINDOW_PEAKS.length];
270
+ return {
271
+ five_hour: {
272
+ used_percentage: Math.round((peak * elapsed) / WINDOW_MINUTES),
273
+ resets_at: Math.round((now + (WINDOW_MINUTES - elapsed) * 60_000) / 1000),
274
+ },
275
+ seven_day: {
276
+ // Climbing across the whole invented week, not the cycle: the day repeats, and a ramp
277
+ // relative to the cycle replayed itself — a 14-point fall at every boundary, drawn as a
278
+ // "7d reset" line no real account produces daily. `cyclesBack` is how many cycles before
279
+ // the newest day this reading belongs to, so consecutive cycles join to the point.
280
+ used_percentage: Math.max(0, Math.round(SEVEN_DAY_TOP - SEVEN_DAY_PER_DAY * (cyclesBack + 1 - minute / DEMO_MINUTES))),
281
+ // Mid-week: far enough out that the five-hour window above never rolls it over.
282
+ resets_at: Math.round((now + 4.5 * 24 * 3600 * 1000) / 1000),
283
+ },
284
+ };
285
+ }
286
+ /** How full an actor's window is at `minute`, and `null` for one that has taken no turn yet. */
287
+ function contextAt(actor, minute) {
288
+ if (actor.fresh !== undefined && minute >= actor.fresh.from && minute < actor.fresh.to)
289
+ return null;
290
+ // A recycle does not reset the WORK an actor has done, so the ramp is measured from the
291
+ // minute it was recycled rather than from midnight — otherwise a compacted session comes back
292
+ // fuller than it was before, which is the opposite of what compacting does.
293
+ const since = actor.fresh !== undefined && minute >= actor.fresh.from ? busyMinutes(actor, actor.fresh.from) : 0;
294
+ return Math.min(97, Math.round(actor.ctxFrom + actor.ctxPerBusyMinute * (busyMinutes(actor, minute) - since)));
295
+ }
296
+ /**
297
+ * One minute of the invented day, as the two sources would have been PARSED into it.
298
+ *
299
+ * `now` is what dates the reading, and it is separate from the minute being played: the live
300
+ * view always shows the last minute of the day, dated by the clock that asked for it, so two
301
+ * captures taken an hour apart are the same picture.
302
+ */
303
+ export function demoFleetAt(minute, dayStart, now = dayStart + minute * 60_000, staleAfterMs = DEFAULT_STALE_AFTER_MS, cyclesBack = 0) {
304
+ const live = ACTORS.filter((a) => minute >= a.born);
305
+ const sessions = live.map((a) => {
306
+ const segment = segmentAt(a, minute);
307
+ return {
308
+ sessionId: a.sid,
309
+ pid: a.pid,
310
+ cwd: a.cwd,
311
+ name: a.name,
312
+ kind: a.kind,
313
+ startedAt: dayStart + a.born * 60_000,
314
+ status: segment.status,
315
+ waitingFor: segment.waitingFor ?? null,
316
+ // The same rule the parser applies, spelled here rather than borrowed: a word it does not
317
+ // recognise is `null`, never `false`, and `compacting` below is exactly such a word.
318
+ busy: working(segment.status) ? true : segment.status === 'idle' || segment.status === 'done' ? false : null,
319
+ };
320
+ });
321
+ const limits = rateLimits(minute, now, cyclesBack);
322
+ const snapshots = new Map();
323
+ for (const a of live) {
324
+ const pct = contextAt(a, minute);
325
+ snapshots.set(a.sid, {
326
+ sessionId: a.sid,
327
+ // The discriminant is the PRESENCE of the key, never its value: a session that has taken
328
+ // no turn reads `fresh` and reports no percentage, which is not a session at zero.
329
+ ctxState: pct === null ? 'fresh' : 'ok',
330
+ ctxPct: pct,
331
+ ctxTokens: pct === null ? null : Math.round((pct / 100) * CTX_WINDOW),
332
+ ctxWindow: CTX_WINDOW,
333
+ model: a.model.display_name,
334
+ modelId: a.model.id,
335
+ effort: a.effort,
336
+ costUsd: round2(a.costPerBusyMinute * busyMinutes(a, minute)),
337
+ ccVersion: CC_VERSION,
338
+ rateLimits: limits,
339
+ ageMs: a.ageMs,
340
+ file: `${DEMO_HOME}/.local/state/tarmac/snapshots/${a.sid}.json`,
341
+ });
342
+ }
343
+ // Discovery's own health, which a shortcut here would drop: without it `discovered` and
344
+ // `noSessionId` are computed from the rows alone, and the page would report on a discovery
345
+ // that never happened rather than on the one the demo is standing in for.
346
+ const discovery = {
347
+ seen: sessions.length,
348
+ noSessionId: 0,
349
+ unknownStatus: sessions.filter((s) => s.busy === null && s.status !== 'waiting').length,
350
+ };
351
+ const fleet = buildFleet({ sessions, snapshots, now, staleAfterMs, discovery });
352
+ // What the collector fills in, filled in here for the same reason it does it there: these
353
+ // five are how the page tells "nothing to report" from "we could not look", and leaving them
354
+ // undefined would render the second as the first.
355
+ fleet.health.snapshotsError = null;
356
+ fleet.health.snapshotsUnreadable = 0;
357
+ fleet.health.snapshotsNotFiles = 0;
358
+ fleet.health.snapshotsDuplicates = 0;
359
+ fleet.health.snapshotsDir = `${DEMO_HOME}/.local/state/tarmac/snapshots`;
360
+ return fleet;
361
+ }
362
+ /** The day ends now, so its last minute is the fleet the live view shows. */
363
+ export const demoDayStart = (now = Date.now()) => now - (DEMO_MINUTES - 1) * 60_000;
364
+ /**
365
+ * The record behind the scrubber, played in whole at startup.
366
+ *
367
+ * The same fleets the live view is built from, through the same `record` a real sampler calls —
368
+ * so the replay is a reduction of the demo rather than a second account of it, kept in step by
369
+ * hand. Nothing here touches disk: this is the in-memory ring, which is where a real serve's
370
+ * last 24 hours live too.
371
+ */
372
+ export function demoHistory(dayStart, cadence = HISTORY_CADENCE_MS) {
373
+ const history = createHistory({ since: dayStart, cadence });
374
+ for (let minute = 0; minute < DEMO_MINUTES; minute++)
375
+ history.record(demoFleetAt(minute, dayStart));
376
+ return history;
377
+ }
378
+ /**
379
+ * What `serve --demo` reads instead of the machine.
380
+ *
381
+ * Always the last minute of the invented day, dated by the clock that asked: the fleet does not
382
+ * walk on while a serve is open, so a screenshot of it now and one taken in an hour show the
383
+ * same eight sessions doing the same things, with the account's two windows counting down from
384
+ * whenever they were read rather than from a reset that has since gone past.
385
+ *
386
+ * The record behind the scrubber holds still too: a demo serve runs no sampler, so the day
387
+ * seeded below is the day it keeps for as long as it is open.
388
+ */
389
+ export function demoCollector(dayStart, now = Date.now, staleAfterMs = DEFAULT_STALE_AFTER_MS) {
390
+ return () => Promise.resolve(demoFleetAt(DEMO_MINUTES - 1, dayStart, now(), staleAfterMs));
391
+ }
392
+ const round2 = (n) => Math.round(n * 100) / 100;
package/dist/fleet.js CHANGED
@@ -14,7 +14,7 @@ import path from 'node:path';
14
14
  import { DEFAULT_STALE_AFTER_MS } from './config.js';
15
15
  import { measured, windowsApart } from './limits.js';
16
16
  import { guardVersions } from './schema.js';
17
- import { isWaiting } from './sessions.js';
17
+ import { anchoredOnKind, isBackgroundAgent, isWaiting } from './sessions.js';
18
18
  import { SID_NAME } from './wrapper.js';
19
19
  export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_STALE_AFTER_MS, discovery = null, }) {
20
20
  const rows = sessions.map((s) => {
@@ -43,7 +43,14 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
43
43
  };
44
44
  });
45
45
  rows.sort((a, b) => rank(a) - rank(b) || (b.ctxPct ?? -1) - (a.ctxPct ?? -1));
46
- const covered = rows.filter((r) => r.ctxState !== 'absent').length;
46
+ // The population the coverage numbers are about. An agent has no TUI, so it never draws a
47
+ // frame and no statusline can ever file a snapshot for it: counted among the blind, one
48
+ // agent beside one chained terminal read as "chained on 1/2 sessions — run tarmac install",
49
+ // remediation that is already done and cannot work for the entry that raised it (#29). The
50
+ // map made the same call for the agent strip; this is the fleet-wide line catching up.
51
+ const anchored = anchoredOnKind(rows);
52
+ const chainable = rows.filter((r) => !isBackgroundAgent(r, anchored));
53
+ const covered = chainable.filter((r) => r.ctxState !== 'absent').length;
47
54
  // Blind AND unfilable, in that order — this number exists to say how many of the blind will
48
55
  // stay blind, and both renderers subtract it from them. A session can be unfilable and
49
56
  // covered at the same time: a snapshot written by a pre-upgrade wrapper under a non-UUID
@@ -51,8 +58,10 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
51
58
  // than on the filename. Counting that one would push this past the blind count and make the
52
59
  // renderers explain away someone else's missing telemetry.
53
60
  // A null id is `noSessionId`'s business — a discovery failure, not a naming one.
54
- const unfilable = rows.filter((r) => r.ctxState === 'absent' && r.sessionId !== null && !SID_NAME.test(r.sessionId)).length;
55
- const drift = rows.filter((r) => r.ctxState === 'drift').length;
61
+ const unfilable = chainable.filter((r) => r.ctxState === 'absent' && r.sessionId !== null && !SID_NAME.test(r.sessionId)).length;
62
+ // Over the same population as `covered`, because `schemaBroken` below compares the two and a
63
+ // comparison between two different populations answers a question nobody asked.
64
+ const drift = chainable.filter((r) => r.ctxState === 'drift').length;
56
65
  // Having a snapshot and having a cost are different facts, and only the second one is
57
66
  // allowed to feed the total.
58
67
  const costs = rows.map((r) => r.costUsd).filter((c) => typeof c === 'number');
@@ -67,6 +76,7 @@ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_ST
67
76
  rows,
68
77
  health: {
69
78
  sessions: rows.length,
79
+ chainable: chainable.length,
70
80
  covered,
71
81
  unfilable,
72
82
  drift,
@@ -42,9 +42,57 @@ const RESET_DROP_POINTS = 5;
42
42
  * the fleet on, and thirty files opened at once is thirty buffers of a day each in memory for
43
43
  * an answer that is a few hundred rows.
44
44
  */
45
- export async function readRange({ dir, range, now, capped = false }) {
45
+ export async function readRange({ dir, range, now, capped = false, readDay }) {
46
46
  const daysRequested = RANGE_DAYS[range];
47
47
  const coverage = { daysRequested, lines: 0, skipped: 0, outOfRange: 0, droppedSessions: 0, capped };
48
+ // The default reader, built here rather than in the signature because it counts into
49
+ // `coverage`: a day file refused by kind is a record this range could not read, which is the
50
+ // question `skipped` answers — and only the DISK path can meet one. An injected reader
51
+ // (#156's invented week) never opens a name, so the guard is the default's own.
52
+ //
53
+ // What is behind the name is not this reader's to assume. The store writes day files into
54
+ // a directory that belongs to the user, and anything can land in it: `fs.readFile` on a
55
+ // FIFO blocks until someone writes to the other end, which hung this read, the request
56
+ // waiting on it, and every request that joined the cached read behind it (#136). So the
57
+ // kind is asked BEFORE the open, and only a regular file is opened.
58
+ //
59
+ // `lstat`, not `stat`, and NOT for the pipe: `stat` reports the target's kind, so it
60
+ // refuses a link pointing at a FIFO exactly as this does. The one shape the two disagree
61
+ // about is a link to a regular file, and that is the whole of the choice. A journal file
62
+ // is a file this store appended to; a symbolic link is a name somebody else put there,
63
+ // aimed at something nobody told this reader about, and following it reads whatever it
64
+ // is aimed at today. Only what we wrote, which is `reap.ts`'s rule for the same reason.
65
+ //
66
+ // A HARD link to a regular file is the shape neither call can see — it carries the
67
+ // target's inode, so it reads as a plain file and its day lands in `days[]`. No hang can
68
+ // come of it (a hard link to a FIFO is still refused by kind), so what this guard holds
69
+ // is "nothing that can block", not "nothing somebody else named".
70
+ //
71
+ // The cost, and it is real: `history-store.ts:386` measures the directory with `statSync`,
72
+ // so a linked day file is charged to the 256 MB cap and never read. The two readings of
73
+ // one directory disagree by exactly that shape, and this is the side that refuses.
74
+ //
75
+ // A check before an open is a race, and it stays one: nothing stops the name being
76
+ // replaced between the two. What it costs is bounded — one hung read, once, on a
77
+ // directory somebody is racing — and `serve` answers 504 rather than waiting for it.
78
+ const readDayFromDisk = async (date) => {
79
+ const file = path.join(dir, `${date}.jsonl`);
80
+ try {
81
+ if (!(await fs.lstat(file)).isFile()) {
82
+ // Counted rather than passed over, and counted here: a day file that is not a file is
83
+ // a record this range could not read, which is the question `skipped` answers.
84
+ coverage.skipped += 1;
85
+ return null;
86
+ }
87
+ return await fs.readFile(file, 'utf8');
88
+ }
89
+ catch {
90
+ // A day with no file is the normal case: `serve` was not running. A day whose file cannot
91
+ // be read is the same answer for this reader, and `serve` is not the process that fixes it.
92
+ return null;
93
+ }
94
+ };
95
+ const read = readDay ?? readDayFromDisk;
48
96
  const hours = new Map();
49
97
  const days = new Map();
50
98
  const resets = [];
@@ -64,15 +112,12 @@ export async function readRange({ dir, range, now, capped = false }) {
64
112
  const windowStart = startOfDay(now, daysRequested - 1);
65
113
  const windowEnd = startOfDay(now, -1);
66
114
  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.
115
+ // A day with nothing for it is the normal case: `serve` was not running. A day whose file
116
+ // cannot be read is the same answer for this reader — both readers swallow their own
117
+ // failure and `serve` is not the process that fixes it.
118
+ const text = await read(date);
119
+ if (text === null)
74
120
  continue;
75
- }
76
121
  // The day a record is CHARGED to is the file it is in, and the hour it falls in is its own
77
122
  // clock. The file name is the day the writer decided on, so a reading taken a second before
78
123
  // midnight stays on the day it was journalled to rather than moving under the reader.
@@ -176,6 +221,8 @@ export async function readRange({ dir, range, now, capped = false }) {
176
221
  }
177
222
  return {
178
223
  range,
224
+ from: windowStart,
225
+ to: windowEnd,
179
226
  hours: [...hours.entries()]
180
227
  .sort(([a], [b]) => a - b)
181
228
  .map(([t, acc]) => ({
@@ -23,6 +23,7 @@
23
23
  // write (#133).
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
+ import { readRange } from './history-range.js';
26
27
  /**
27
28
  * The ceiling nobody can raise. The age limit is the reader's number and it is not a bound on
28
29
  * its own: it prices a fleet of eight sessions at about a megabyte a day, and a fleet of two
@@ -196,8 +197,11 @@ const oldestKept = (now, days) => {
196
197
  * added to `HistorySession` tomorrow does not compile here until somebody has decided whether
197
198
  * it belongs in a file that outlives the process. The omission that has to survive every future
198
199
  * edit cannot be written as a subtraction from a shape that is free to grow.
200
+ *
201
+ * Exported for the demo's invented journal, which is the one other thing that produces a line
202
+ * of this shape: a second allowlist written out by hand there is a second thing to keep true.
199
203
  */
200
- const lineOf = ({ t, sessions, rateLimits }) => ({
204
+ export const journalRecordOf = ({ t, sessions, rateLimits }) => ({
201
205
  t,
202
206
  sessions: sessions.map(({ sid, project, kind, state, ctxState, ctxPct, costUsd }) => ({
203
207
  sid,
@@ -350,7 +354,7 @@ export function createHistoryStore({ dir, days, now = Date.now, maxBytes = HISTO
350
354
  prunedDay = day;
351
355
  // One line, one reading, terminated: a reader tailing this file sees whole records, and a
352
356
  // process killed between two appends leaves the last one complete.
353
- const line = JSON.stringify(lineOf(sample)) + '\n';
357
+ const line = JSON.stringify(journalRecordOf(sample)) + '\n';
354
358
  const bytes = Buffer.byteLength(line);
355
359
  const onDisk = measure().bytes;
356
360
  if (onDisk + bytes > maxBytes) {
@@ -373,6 +377,12 @@ export function createHistoryStore({ dir, days, now = Date.now, maxBytes = HISTO
373
377
  }
374
378
  },
375
379
  prune,
380
+ read(range, at) {
381
+ // `capped` is read here rather than passed in by the caller: it is a fact about this
382
+ // writer, and a journal at its ceiling looks from the disk alone exactly like a fleet
383
+ // that went quiet.
384
+ return readRange({ dir, range, now: at, capped });
385
+ },
376
386
  stats() {
377
387
  const { files, bytes } = measure();
378
388
  return { files, bytes, misses, stopped, capped };