@adrrr/tarmac 0.8.1 → 0.9.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/README.md +14 -0
- package/dist/args.js +26 -8
- package/dist/cli.js +65 -11
- package/dist/collect.js +2 -1
- package/dist/demo-history.js +184 -0
- package/dist/demo.js +392 -0
- package/dist/history-range.js +54 -9
- package/dist/history-store.js +12 -2
- package/dist/history-view.js +80 -7
- package/dist/render.js +25 -4
- package/dist/server.js +83 -22
- package/dist/sessions.js +8 -2
- package/dist/snapshots.js +27 -11
- package/package.json +1 -1
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/history-range.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
package/dist/history-store.js
CHANGED
|
@@ -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
|
|
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(
|
|
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 };
|
package/dist/history-view.js
CHANGED
|
@@ -620,7 +620,7 @@ export const HISTORY_CSS = `
|
|
|
620
620
|
two account-wide charts side by side under it; the phone block stacks them, one chart to
|
|
621
621
|
a screen. */
|
|
622
622
|
.view-history { display:grid; grid-template-columns:1fr 1fr; gap:1rem; align-items:start; }
|
|
623
|
-
#ctx, .hist-off, .view-history > .note { grid-column:1 / -1; }
|
|
623
|
+
#ctx, .hist-off, .hist-empty, .view-history > .note { grid-column:1 / -1; }
|
|
624
624
|
.view-history > .note { margin-top:0; }
|
|
625
625
|
.chart { border:1px solid var(--line); border-radius:12px; padding:.65rem .8rem .7rem; min-width:0; }
|
|
626
626
|
/* The margin is what the way-back-to-now's tap target is drawn into. That overlay reaches
|
|
@@ -679,7 +679,11 @@ export const HISTORY_CSS = `
|
|
|
679
679
|
/* Off is not a fault, so it is not a .warn: a framed sentence in the page's own ink, with
|
|
680
680
|
the one key that turns it on. */
|
|
681
681
|
.hist-off { border:1px solid var(--line); border-radius:8px; padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
|
|
682
|
-
|
|
682
|
+
/* Same frame as "off" above, and for the same reason: a serve that has been running a minute
|
|
683
|
+
is not a fault either. It sits directly over the charts it is about, because "where are my
|
|
684
|
+
curves" is a question asked while looking at the place they will be. */
|
|
685
|
+
.hist-empty { border:1px solid var(--line); border-radius:8px; padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
|
|
686
|
+
.hist-off code, .hist-empty code, .view-history .note code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.95em; }
|
|
683
687
|
/* The other two views are in the shell on every address — the tabs between them are meant to
|
|
684
688
|
cost nothing — so the one being read hides the pair it stands in front of. History is the
|
|
685
689
|
exception and ships only on its own address: it carries a script and three canvases, and a
|
|
@@ -739,12 +743,13 @@ const chart = (id, name, sub) => `<section class="chart" id="${id}" role="group"
|
|
|
739
743
|
* script after a round trip. A reader with no journal never sees a range flicker from live to
|
|
740
744
|
* refused, and a browser with no JavaScript still gets told why the page is empty.
|
|
741
745
|
*/
|
|
742
|
-
export function renderHistoryView({ historyEnabled }) {
|
|
746
|
+
export function renderHistoryView({ historyEnabled, demo = false }) {
|
|
743
747
|
const off = !historyEnabled;
|
|
744
748
|
return `<div class="view view-history">
|
|
745
749
|
${off
|
|
746
750
|
? ` <div class="hist-off" id="hist-off" role="status"><strong>History is off.</strong> The last 24h live in memory while <code>tarmac serve</code> runs and go when it stops; nothing is written to disk. To keep 7 and 30 days, add <code>{"history": {"days": 30}}</code> to <code>~/.claude/tarmac/config.json</code> and start <code>serve</code> again.</div>\n`
|
|
747
|
-
: ''}
|
|
751
|
+
: ''} <div class="hist-empty" id="hist-empty" role="status" hidden><strong>Nothing to draw yet.</strong> <code>tarmac serve</code> reads the fleet once a minute, so the context lines start within a minute or two, the cost bars fill an hour at a time, and the quota curve needs a few readings before it has a shape. A session has to be open for any of them to have a subject. Leave the serve running and come back. To see all three full right now, without waiting: <code>tarmac serve --demo</code>.</div>
|
|
752
|
+
${chart('ctx', 'Context', 'per session · 24h')}
|
|
748
753
|
${chart('cost', 'Cost', 'per project · hourly · 24h')}
|
|
749
754
|
${chart('quota', 'Quota', 'account · 24h')}
|
|
750
755
|
<p class="note">Recorded once a minute, only while <code>tarmac serve</code> runs. A minute it was not running is a minute with no reading, drawn as a gap and never as a zero. A session recycled overnight comes back as a new line from its first frame.${off ? '' : ' The journal keeps the same fields as the ring, no names and no paths.'}</p>
|
|
@@ -753,9 +758,11 @@ ${chart('quota', 'Quota', 'account · 24h')}
|
|
|
753
758
|
<button type="button" id="range-24h" data-range="24h" aria-pressed="true">24h</button>
|
|
754
759
|
<button type="button" id="range-7d" data-range="7d" aria-pressed="false"${off ? ' disabled' : ''}>7d</button>
|
|
755
760
|
<button type="button" id="range-30d" data-range="30d" aria-pressed="false"${off ? ' disabled' : ''}>30d</button>
|
|
756
|
-
<div class="covers" id="hist-covers">${off
|
|
761
|
+
<div class="covers" id="hist-covers"${demo ? ' data-days="days invented"' : ''}>${off
|
|
757
762
|
? '24h from memory · 7d and 30d need the journal, which is off'
|
|
758
|
-
:
|
|
763
|
+
: demo
|
|
764
|
+
? '24h from memory · 7d and 30d from a journal invented in memory'
|
|
765
|
+
: '24h from memory · 7d and 30d from the journal on disk'}</div>
|
|
759
766
|
</div>
|
|
760
767
|
</div>`;
|
|
761
768
|
}
|
|
@@ -790,6 +797,24 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
|
|
|
790
797
|
// and not this page's — and a copy of both, kept in step by hand, is how the two come to
|
|
791
798
|
// disagree.
|
|
792
799
|
var covers24 = covers.textContent;
|
|
800
|
+
// Whether this serve has a journal at all. It decides which of the two blocks a page with
|
|
801
|
+
// nothing in it raises: with no journal the server has already shipped the one that names that
|
|
802
|
+
// cause and its fix, and a second block under it offering a minute of patience is one screen
|
|
803
|
+
// saying two things (#157).
|
|
804
|
+
//
|
|
805
|
+
// Read off the page rather than asked for down the wire, for the reason the sentence above is:
|
|
806
|
+
// it is the config's answer, the server has it, and a round trip to be told it would leave the
|
|
807
|
+
// question open for as long as the request took. Off the PILL and not off the block it is
|
|
808
|
+
// really about, which would read better — the eighty-line DOM the suite executes this script
|
|
809
|
+
// on hands back an element for every id asked of it, so an assertion about a block the server
|
|
810
|
+
// did not ship cannot be written there, while a disabled attribute it did ship can. The two
|
|
811
|
+
// are the same fact: one flag in the view above writes both.
|
|
812
|
+
var journalOff = el('range-7d').disabled;
|
|
813
|
+
// What this serve calls the days behind its two long ranges. The sentence above is the
|
|
814
|
+
// server's and is kept rather than written again; this is the half of it the script rebuilds
|
|
815
|
+
// per range, and where a day came from is no more this page's answer there than it is here.
|
|
816
|
+
// Absent is what every serve that reads its journal off a disk says, which is most of them.
|
|
817
|
+
var daysWord = covers.getAttribute('data-days') || 'days on disk';
|
|
793
818
|
var state = { range: '24h', data: null, err: null, iso: {}, cursor: {}, loading: false, gen: 0 };
|
|
794
819
|
// Which series a chart is isolated on, or null. Read through a function and compared against
|
|
795
820
|
// null rather than tested for truth: path.basename('/') is the empty string, so a project
|
|
@@ -1161,9 +1186,57 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
|
|
|
1161
1186
|
el(id + '-legend').hidden = true;
|
|
1162
1187
|
}
|
|
1163
1188
|
|
|
1189
|
+
/*
|
|
1190
|
+
* Is there a single number in the ring? The three charts plot exactly three things, and this
|
|
1191
|
+
* asks whether the record holds any of them: a context percentage, a cost, or a window
|
|
1192
|
+
* reading. Nothing else counts as something to draw.
|
|
1193
|
+
*
|
|
1194
|
+
* A first cut asked whether the ring held any SAMPLE, and those two part company sixty
|
|
1195
|
+
* seconds in, which is the worst possible moment. A serve started before the statusline is
|
|
1196
|
+
* chained, or with no session open, records a sample a minute carrying nothing at all: the
|
|
1197
|
+
* sample count says "we are away" while the charts are still empty, and the block came down
|
|
1198
|
+
* over a page no more explanatory than it was at the start. Counting blank canvases does not
|
|
1199
|
+
* fix it either, because two of the three draw an empty grid rather than blanking whenever
|
|
1200
|
+
* the span is non-zero, however little is in it.
|
|
1201
|
+
*/
|
|
1202
|
+
function drawable() {
|
|
1203
|
+
var s = state.data && state.data.samples, i, j, ses, lim, k;
|
|
1204
|
+
if (!s) return false;
|
|
1205
|
+
for (i = 0; i < s.length; i++) {
|
|
1206
|
+
lim = s[i].rateLimits;
|
|
1207
|
+
for (k in lim) if (lim[k] && typeof lim[k].used_percentage === 'number') return true;
|
|
1208
|
+
ses = s[i].sessions || [];
|
|
1209
|
+
for (j = 0; j < ses.length; j++) {
|
|
1210
|
+
if (typeof ses[j].ctxPct === 'number' || typeof ses[j].costUsd === 'number') return true;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/*
|
|
1217
|
+
* Whether this is a serve with nothing to draw, which is what a first run looks like (#151).
|
|
1218
|
+
*
|
|
1219
|
+
* The ring only. An empty 7d is a journal that was not running for a week, and this block's
|
|
1220
|
+
* answer, wait a minute and the lines will come, is not true of that. The canvas goes on
|
|
1221
|
+
* saying "no readings in this range" there, which is the honest verdict.
|
|
1222
|
+
*
|
|
1223
|
+
* Neither loading nor an error counts: both are states in which nothing has been read yet,
|
|
1224
|
+
* and a block raised over a request still in flight would be answering a question the
|
|
1225
|
+
* record is about to answer itself. Same rule the blank canvas applies one function up.
|
|
1226
|
+
*
|
|
1227
|
+
* Nor does a serve with no journal, which is the FIRST default run and where both blocks
|
|
1228
|
+
* used to come up together (#157): the server has already raised the other one there.
|
|
1229
|
+
*/
|
|
1230
|
+
function firstRun() {
|
|
1231
|
+
if (journalOff) return false;
|
|
1232
|
+
if (state.range !== '24h' || state.loading || state.err !== null) return false;
|
|
1233
|
+
return !drawable();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1164
1236
|
var draw = { ctx: drawCtx, cost: drawCost, quota: drawQuota };
|
|
1165
1237
|
function redraw() {
|
|
1166
1238
|
covers.textContent = coversText();
|
|
1239
|
+
el('hist-empty').hidden = !firstRun();
|
|
1167
1240
|
if (!state.data) { ids.forEach(function (id) { blank(id, state.range); }); return; }
|
|
1168
1241
|
ids.forEach(function (id) { draw[id](); });
|
|
1169
1242
|
}
|
|
@@ -1202,7 +1275,7 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
|
|
|
1202
1275
|
if (state.loading) return 'reading ' + state.range + '…';
|
|
1203
1276
|
if (state.range === '24h') return covers24;
|
|
1204
1277
|
if (!d || !d.coverage) return state.range + ' from the journal';
|
|
1205
|
-
var c = d.coverage, said = state.range + ' from the journal · ' + d.days.length + ' of ' + c.daysRequested + '
|
|
1278
|
+
var c = d.coverage, said = state.range + ' from the journal · ' + d.days.length + ' of ' + c.daysRequested + ' ' + daysWord;
|
|
1206
1279
|
// Said once and quietly: a journal that stopped at its cap, and readings the reader could
|
|
1207
1280
|
// not use. Neither is a fault to shout about, and both change what the charts above mean.
|
|
1208
1281
|
if (c.capped) said += ' · journal capped';
|