@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/README.md +27 -9
- package/dist/args.js +26 -8
- package/dist/cli.js +65 -11
- package/dist/collect.js +2 -1
- package/dist/demo-history.js +244 -0
- package/dist/demo.js +392 -0
- package/dist/fleet.js +14 -4
- package/dist/history-range.js +56 -9
- package/dist/history-store.js +12 -2
- package/dist/history-view.js +317 -63
- package/dist/map.js +6 -22
- package/dist/render.js +633 -114
- package/dist/schema.js +12 -3
- package/dist/server.js +83 -22
- package/dist/sessions.js +33 -2
- package/dist/snapshots.js +27 -11
- package/dist/wrapper.js +27 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ documented surfaces only, never an internal format.
|
|
|
13
13
|
<picture>
|
|
14
14
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/replay-dark.gif">
|
|
15
15
|
<img src="docs/media/replay-light.gif" width="1100"
|
|
16
|
-
alt="The tarmac map, live and then replayed. It opens on the live fleet
|
|
16
|
+
alt="The tarmac map, live and then replayed. It opens on the live fleet: nodes grouped into a frame per working directory, each frame labelled with its project, sessions drawn as dials whose arc is the context window, background agents docked under the cards as strips of text, and the account's five-hour and seven-day gauges in the header. As the scrubber under the fleet is dragged the frames give way to the flat record, every node in a cell of one size, sessions still dials and agents still strips, each with its project and what it has cost. Sessions appear as the day walks past and the grid gains a cell for each, the arcs fill, the five-hour gauge fills and rolls over to zero, and a banner names the minute being replayed until Back to live is pressed and the frames come back.">
|
|
17
17
|
</picture>
|
|
18
18
|
|
|
19
19
|
[Quickstart](#quickstart) · [The map](#the-map) · [The curves](#the-curves) · [Install](#install) ·
|
|
@@ -26,10 +26,18 @@ documented surfaces only, never an internal format.
|
|
|
26
26
|
npx @adrrr/tarmac # one-shot fleet table
|
|
27
27
|
npx @adrrr/tarmac --watch # the same table, redrawn every 5s until ^C
|
|
28
28
|
npx @adrrr/tarmac serve # the same fleet in the browser
|
|
29
|
+
npx @adrrr/tarmac serve --demo # an invented fleet with a month of history, to see it full
|
|
29
30
|
npx @adrrr/tarmac install # chain the status line: unlocks ctx, model, effort and cost
|
|
30
31
|
npx @adrrr/tarmac uninstall # hand your status line back
|
|
31
32
|
```
|
|
32
33
|
|
|
34
|
+
On a fresh machine the first three of those are one session and no history, which is not much
|
|
35
|
+
of a dashboard. `serve --demo` is the same dashboard over eight invented sessions, a day of
|
|
36
|
+
their record and a month of journal under that, so the map, the replay and every range of the
|
|
37
|
+
curves have something in them. It reads no fleet and writes nothing — the month it shows is
|
|
38
|
+
invented in memory, like the rest of it — and the page it serves is badged `demo data`
|
|
39
|
+
throughout.
|
|
40
|
+
|
|
33
41
|
```
|
|
34
42
|
$ npx @adrrr/tarmac
|
|
35
43
|
|
|
@@ -76,7 +84,7 @@ listening rules are [there too](docs/MANUAL.md#what-serve-listens-on).
|
|
|
76
84
|
<picture>
|
|
77
85
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/map-dark.png">
|
|
78
86
|
<img src="docs/media/map-light.png" width="1100"
|
|
79
|
-
alt="The tarmac map, live.
|
|
87
|
+
alt="The tarmac map, live. Five framed groups, one per working directory and labelled with its project: auth-service, holding a session halted on a permission prompt at 61%; api-gateway, holding a session busy with its context arc at 93% and a background agent docked under the cards of that frame as a strip named after its prompt; data-pipeline, busy at 69% with an agent of its own; docs-site, holding one session reporting a status tarmac does not know and one idle at 39%; and storefront, idle at 23%. A frame says only that its nodes were read in the same directory. Nothing inside one claims that any node dispatched another. The account's five-hour and seven-day gauges sit in the header, one warning above the fleet names the unrecognised status rather than filing it as idle, and the scrubber for the replay sits under the fleet.">
|
|
80
88
|
</picture>
|
|
81
89
|
|
|
82
90
|
The tabs in the header swap the table for the same fleet drawn as nodes: one per session, the
|
|
@@ -95,10 +103,13 @@ session gets its own frame rather than somebody else's.
|
|
|
95
103
|
Above the fleet are the account's five-hour and seven-day
|
|
96
104
|
[gauges](docs/MANUAL.md#the-accounts-two-windows). Under it is a scrubber over the day this
|
|
97
105
|
serve has seen. Drag it and the nodes render the fleet as it was at that minute. Press play
|
|
98
|
-
and the day walks past. Replayed, the nodes are drawn flat and unframed
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
and the day walks past. Replayed, the nodes are drawn flat and unframed, every one of them in a
|
|
107
|
+
cell of one size: a session that appears between two minutes of a scrub moves nothing but the
|
|
108
|
+
nodes after it in the sample's own order, and the banner naming the minute stands in a place the
|
|
109
|
+
page keeps for it. A row the grid gains still pushes what is under it, and on a phone the banner
|
|
110
|
+
wraps where the live line does not. The record keeps a project name and never the directory it
|
|
111
|
+
was read in, and a basename is not a directory. Two checkouts of `atlas` answer to the same
|
|
112
|
+
word, so a frame drawn on it would claim a shared directory nobody can check.
|
|
102
113
|
|
|
103
114
|
The map follows the rules the table follows. A reading past the freshness threshold is drawn
|
|
104
115
|
thin, amber and dated. A percentage nobody measured is an empty dotted dial, never a ring at
|
|
@@ -115,6 +126,12 @@ The third tab draws what moved rather than what is: context per session, cost pe
|
|
|
115
126
|
the account's two windows, over the last 24 hours out of the ring or over 7 and 30 days out of
|
|
116
127
|
the journal, if you keep one. `<canvas>` and the page's own script, no library.
|
|
117
128
|
|
|
129
|
+
<picture>
|
|
130
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/media/curves-dark.png">
|
|
131
|
+
<img src="docs/media/curves-light.png" width="1100"
|
|
132
|
+
alt="The curves page of the demo fleet, on the 7d range out of the invented journal. Context is a row of small multiples, one band per project: four sawtooths that climb through the day and break where the session was recycled, and storefront, a short stroke a day near the baseline. None carries the arrow that marks a climbing line, since the panel counts nothing climbing over this range. Cost is one stacked bar per day in a fixed project order, seven days between $128 and $221. Quota is the seven-day line climbing over the five-hour window's highs. A banner above names one unrecognised status, and the header is badged demo data.">
|
|
133
|
+
</picture>
|
|
134
|
+
|
|
118
135
|
A context line breaks where its session did, so a recycle at three in the morning reads as a
|
|
119
136
|
break and not as a cliff, and a minute nobody read is a hole rather than a fall to zero. The
|
|
120
137
|
lines that gained fifteen points or more in the last three hours are drawn full and labelled,
|
|
@@ -220,11 +237,12 @@ and what would have worked. Spellings, edge cases and the two health fields
|
|
|
220
237
|
```bash
|
|
221
238
|
npm test # typecheck (src + test + scripts), then run the suite
|
|
222
239
|
npm run build # flat JavaScript into dist/
|
|
223
|
-
node scripts/demo-fleet.ts #
|
|
240
|
+
node scripts/demo-fleet.ts # a second invented fleet, served on a port
|
|
224
241
|
```
|
|
225
242
|
|
|
226
|
-
Every capture on this page is taken of a fleet that does not exist. A
|
|
227
|
-
machine carries working directories, prompts and costs, and nothing real
|
|
243
|
+
Every capture on this page is taken of `serve --demo`, a fleet that does not exist. A
|
|
244
|
+
screenshot of a real machine carries working directories, prompts and costs, and nothing real
|
|
245
|
+
enters this repo.
|
|
228
246
|
What CI covers, which Node version develops and which one ships:
|
|
229
247
|
[the manual](docs/MANUAL.md#developing). Capturing the fixtures for a new Claude Code build:
|
|
230
248
|
[the manual](docs/MANUAL.md#capturing-a-new-claude-code-version).
|
package/dist/args.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// Unknown options and unknown commands are ERRORS. A typo silently ignored is how someone
|
|
5
5
|
// ends up believing they pointed tarmac at a directory it never read.
|
|
6
6
|
import { parseHistoryDays, parsePort, parseTrustHost } from './config.js';
|
|
7
|
-
const COMMANDS = new Set(['list', 'serve', 'install', 'uninstall', 'help']);
|
|
8
7
|
// `| undefined` is load-bearing, not decoration: without it the compiler types the lookup
|
|
9
8
|
// below as always-present and the `if (!key) throw` guard reads as dead code — which is how
|
|
10
9
|
// a future cleanup deletes the one thing standing between a typo and a silently ignored flag.
|
|
@@ -19,6 +18,7 @@ const OPTIONS = {
|
|
|
19
18
|
'--json': 'json',
|
|
20
19
|
'--watch': 'watch',
|
|
21
20
|
'--yes': 'yes',
|
|
21
|
+
'--demo': 'demo',
|
|
22
22
|
'--help': 'help',
|
|
23
23
|
'--version': 'version',
|
|
24
24
|
// The one short spelling this parser knows, and it is here because it is what people type
|
|
@@ -26,7 +26,7 @@ const OPTIONS = {
|
|
|
26
26
|
// `-h` for help would be the next request and the one after that.
|
|
27
27
|
'-v': 'version',
|
|
28
28
|
};
|
|
29
|
-
const FLAGS = new Set(['json', 'watch', 'yes', 'help', 'version']);
|
|
29
|
+
const FLAGS = new Set(['json', 'watch', 'yes', 'demo', 'help', 'version']);
|
|
30
30
|
/** Options that ACCUMULATE rather than overwrite — passed twice, both values are kept. */
|
|
31
31
|
const LISTS = new Set(['trustHost']);
|
|
32
32
|
/**
|
|
@@ -40,7 +40,7 @@ const LISTS = new Set(['trustHost']);
|
|
|
40
40
|
*/
|
|
41
41
|
const ACCEPTS = {
|
|
42
42
|
list: new Set(['staleAfter', 'snapshotsDir', 'home', 'claudeBin', 'json', 'watch', 'help', 'version']),
|
|
43
|
-
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'historyDays', 'home', 'claudeBin', 'help', 'version']),
|
|
43
|
+
serve: new Set(['port', 'staleAfter', 'snapshotsDir', 'trustHost', 'historyDays', 'home', 'claudeBin', 'demo', 'help', 'version']),
|
|
44
44
|
install: new Set(['home', 'yes', 'help', 'version']),
|
|
45
45
|
uninstall: new Set(['home', 'yes', 'help', 'version']),
|
|
46
46
|
help: new Set(['help', 'version']),
|
|
@@ -51,6 +51,18 @@ function ownersOf(key) {
|
|
|
51
51
|
}
|
|
52
52
|
/** Every flag this parser knows, so a documentation check can enumerate rather than guess. */
|
|
53
53
|
export const OPTION_FLAGS = Object.keys(OPTIONS);
|
|
54
|
+
/** Every command it knows, from the same matrix, and for the same reason one level up. */
|
|
55
|
+
export const COMMAND_NAMES = Object.keys(ACCEPTS);
|
|
56
|
+
/**
|
|
57
|
+
* Is `name` a command? Asked of the matrix, because a second list written beside it is a list
|
|
58
|
+
* that drifts: a name in that one and not in this one was not refused, it was accepted with
|
|
59
|
+
* nothing behind it, and the next flag read `.has` off `undefined` (#149).
|
|
60
|
+
*
|
|
61
|
+
* Own keys only — `in` would make `toString` a command.
|
|
62
|
+
*/
|
|
63
|
+
function isCommand(name) {
|
|
64
|
+
return Object.hasOwn(ACCEPTS, name);
|
|
65
|
+
}
|
|
54
66
|
/**
|
|
55
67
|
* Does `command` read `flag`? Exported for the test that holds `--help` to this matrix:
|
|
56
68
|
* asking the parser is the only way to check that does not go through the wording of an
|
|
@@ -61,12 +73,13 @@ export function accepts(command, flag) {
|
|
|
61
73
|
return key !== undefined && ACCEPTS[command].has(key);
|
|
62
74
|
}
|
|
63
75
|
export function parseArgs(argv) {
|
|
64
|
-
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], historyDays: null, home: null, claudeBin: 'claude', json: false, watch: false, yes: false, help: false, version: false };
|
|
76
|
+
const out = { command: 'list', port: null, staleAfter: null, snapshotsDir: null, trustHost: [], historyDays: null, home: null, claudeBin: 'claude', json: false, watch: false, yes: false, demo: false, help: false, version: false };
|
|
65
77
|
let i = 0;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
const first = argv[0];
|
|
79
|
+
if (first && !first.startsWith('-')) {
|
|
80
|
+
if (!isCommand(first))
|
|
81
|
+
throw new Error(`unknown command: ${first}`);
|
|
82
|
+
out.command = first;
|
|
70
83
|
i = 1;
|
|
71
84
|
}
|
|
72
85
|
for (; i < argv.length; i++) {
|
|
@@ -77,6 +90,11 @@ export function parseArgs(argv) {
|
|
|
77
90
|
if (!ACCEPTS[out.command].has(key))
|
|
78
91
|
throw new Error(`${flag} is not an option of \`tarmac ${out.command}\` — it belongs to: ${ownersOf(key)}`);
|
|
79
92
|
if (FLAGS.has(key)) {
|
|
93
|
+
// A boolean has no value to read, so it refuses one rather than deciding what it meant:
|
|
94
|
+
// `--yes=false` taken as `--yes` skips the confirmation the operator just declined, and
|
|
95
|
+
// a parser this strict about a typo has no business guessing at truthiness (#155).
|
|
96
|
+
if (inline !== null)
|
|
97
|
+
throw new Error(`${flag} takes no value`);
|
|
80
98
|
out[key] = true;
|
|
81
99
|
continue;
|
|
82
100
|
}
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,8 @@ import os from 'node:os';
|
|
|
12
12
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
13
13
|
import { parseArgs } from './args.js';
|
|
14
14
|
import { collectFleet } from './collect.js';
|
|
15
|
+
import { demoCollector, demoDayStart, demoHistory } from './demo.js';
|
|
16
|
+
import { createDemoHistoryStore } from './demo-history.js';
|
|
15
17
|
import { readConfigFile, resolveConfig } from './config.js';
|
|
16
18
|
import { createFleetServer, listenFleetServer } from './server.js';
|
|
17
19
|
import { install, uninstall, paths, planInstall, planUninstall, installedSnapshotsDir, wrapperIsOurs } from './install.js';
|
|
@@ -27,6 +29,7 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
27
29
|
one-shot fleet table — with --watch, redrawn every 5s until ^C
|
|
28
30
|
tarmac serve [--home DIR] [--port N] [--stale-after D] [--snapshots-dir DIR]
|
|
29
31
|
[--claude-bin PATH] [--trust-host HOST] [--history-days N]
|
|
32
|
+
[--demo]
|
|
30
33
|
local dashboard
|
|
31
34
|
tarmac install [--home DIR] [--yes]
|
|
32
35
|
chain the statusline
|
|
@@ -55,6 +58,12 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
55
58
|
which is the command that samples: one JSON line a minute, no session
|
|
56
59
|
name and no working directory, about 2 MB a day at eight sessions,
|
|
57
60
|
and writing stops at 256 MB whatever N says
|
|
61
|
+
--demo serve an invented fleet of eight sessions, with a day of history behind
|
|
62
|
+
it and a week of journal under that, instead of this machine's. On
|
|
63
|
+
\`serve\` only. This machine's fleet is never read and nothing is written:
|
|
64
|
+
no \`claude\`, no snapshots, no temp sweep, and no journal file whatever
|
|
65
|
+
--history-days says. The port and the trusted hosts are still resolved
|
|
66
|
+
as usual, and the page says on itself that it is a demo
|
|
58
67
|
|
|
59
68
|
Those five settings can also be set, in decreasing order of precedence, by the
|
|
60
69
|
environment (TARMAC_STALE_AFTER, TARMAC_PORT, TARMAC_SNAPSHOTS_DIR, TARMAC_TRUST_HOST,
|
|
@@ -135,7 +144,11 @@ try {
|
|
|
135
144
|
// writes, not where a reader's environment would have put it. Recomputing it here made
|
|
136
145
|
// `XDG_STATE_HOME` in one process and not the other a silent split.
|
|
137
146
|
const frozen = installedSnapshotsDir(p);
|
|
138
|
-
|
|
147
|
+
// Not under `--demo`, and for the reason the settings block is not: this line names the
|
|
148
|
+
// wrapper's path and the snapshot directory, which are two real paths out of a real home,
|
|
149
|
+
// and a terminal capture of a demo is exactly the artefact `--demo` exists to produce. It
|
|
150
|
+
// is also advice about a directory this run will never open.
|
|
151
|
+
if (frozen === null && wrapperIsOurs(p) && !args.demo)
|
|
139
152
|
console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
|
|
140
153
|
const config = resolveConfig({
|
|
141
154
|
flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir, trustHosts: args.trustHost, historyDays: args.historyDays },
|
|
@@ -152,13 +165,27 @@ try {
|
|
|
152
165
|
// Beside the snapshots, never among them: `reap.ts`, the wrapper's own sweep and the
|
|
153
166
|
// legacy purge in `install.ts` all decide by name inside that directory.
|
|
154
167
|
const historyDir = historyDirFor(snapshotsDir);
|
|
155
|
-
//
|
|
156
|
-
|
|
168
|
+
// `--demo` serves an invented fleet, and everything it SUPPRESSES is here: a settings
|
|
169
|
+
// block naming a snapshot directory it will never open, a journal whatever
|
|
170
|
+
// `--history-days` says, and a sweep of somebody's temp files. A demo that journalled
|
|
171
|
+
// would write an invented fleet into a real record of a real one; a demo that swept
|
|
172
|
+
// would delete a file to show a picture. What it ADDS is one collector and one ring,
|
|
173
|
+
// both below.
|
|
174
|
+
//
|
|
175
|
+
// Unattended for hours, so a real serve opens by saying what it decided and on whose
|
|
176
|
+
// authority.
|
|
177
|
+
if (!args.demo)
|
|
178
|
+
process.stdout.write(renderSettings(config, p.config, historyDir));
|
|
157
179
|
// One journal, one owner (#133). The retention is a property of the DIRECTORY and the
|
|
158
180
|
// process applying it was whichever `serve` started last, so a `--history-days 1` run to
|
|
159
181
|
// try the setting out swept the thirty days another serve was keeping. A second serve
|
|
160
182
|
// journals nothing now, and serves everything else exactly as it did.
|
|
161
|
-
const days = config.historyDays.value;
|
|
183
|
+
const days = args.demo ? null : config.historyDays.value;
|
|
184
|
+
// Said rather than dropped in silence. `serve` opens by naming what it decided and on
|
|
185
|
+
// whose authority, and a retention someone typed that this run is going to ignore is
|
|
186
|
+
// exactly the kind of thing that discipline exists for.
|
|
187
|
+
if (args.demo && config.historyDays.value !== null)
|
|
188
|
+
console.log(`tarmac: --demo writes no journal, so the ${config.historyDays.value}-day retention is ignored`);
|
|
162
189
|
const { lock, heldBy } = days === null ? { lock: null, heldBy: null } : acquireJournalLock({ dir: historyDir });
|
|
163
190
|
// On stdout, under the settings block it corrects: that block has just named a retention
|
|
164
191
|
// and a directory, and a reader piping it must not be left holding the half of it that
|
|
@@ -194,18 +221,38 @@ try {
|
|
|
194
221
|
// it says what it did rather than doing it quietly, this being the user's directory. It
|
|
195
222
|
// is no longer the only deletion a `serve` makes: a journal that was asked for applies
|
|
196
223
|
// its retention on `listening`, and says that too. Nothing else here removes anything.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
224
|
+
if (!args.demo) {
|
|
225
|
+
const { reaped, failed } = reapOrphanedTemps(snapshotsDir);
|
|
226
|
+
if (reaped > 0)
|
|
227
|
+
console.log(`tarmac: reaped ${reaped} orphaned snapshot temp file(s)`);
|
|
228
|
+
if (failed > 0)
|
|
229
|
+
console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
|
|
230
|
+
}
|
|
231
|
+
// The invented day ends now, so the last minute of it is the fleet the live view shows.
|
|
232
|
+
const demoDay = args.demo ? demoDayStart() : null;
|
|
202
233
|
const server = createFleetServer({
|
|
203
|
-
collect:
|
|
234
|
+
collect: demoDay === null
|
|
235
|
+
? () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null })
|
|
236
|
+
: demoCollector(demoDay, Date.now, staleAfterMs),
|
|
237
|
+
// A day of it, already in the ring: the flat charts a first run opens on are the whole
|
|
238
|
+
// of what #150 is about, and a demo whose record started a minute ago has the same
|
|
239
|
+
// ones. `undefined` is "keep your own", which is what every other serve gets.
|
|
240
|
+
history: demoDay === null ? undefined : demoHistory(demoDay),
|
|
241
|
+
demo: demoDay !== null,
|
|
204
242
|
trustedHosts: config.trustHosts.value,
|
|
205
243
|
// No key anywhere is no store, which is no directory and no file: the default is that
|
|
206
244
|
// nothing of this fleet is written down, and it is the default that is the product.
|
|
207
245
|
// No lock is no store either, and for the same reason: no store, no sweep, no line.
|
|
208
|
-
|
|
246
|
+
//
|
|
247
|
+
// A demo gets a store all the same, and it is the one thing about `--demo` that is not a
|
|
248
|
+
// suppression: a week of invented days, in memory, answering the range reads through the
|
|
249
|
+
// seam a real journal answers them through (#156). It writes nothing and opens nothing —
|
|
250
|
+
// `days` is null above, so no directory was taken and no lock was asked for.
|
|
251
|
+
store: demoDay !== null
|
|
252
|
+
? createDemoHistoryStore({ dayStart: demoDay })
|
|
253
|
+
: days === null || lock === null
|
|
254
|
+
? null
|
|
255
|
+
: createHistoryStore({ dir: historyDir, days, lock }),
|
|
209
256
|
});
|
|
210
257
|
// A port nobody chose is not worth failing over: this walks past a busy 4477 and says
|
|
211
258
|
// where it landed. A port that WAS chosen refuses instead, and the refusal leaves
|
|
@@ -218,6 +265,13 @@ try {
|
|
|
218
265
|
process.exit(1);
|
|
219
266
|
});
|
|
220
267
|
console.log(servingLine(bound));
|
|
268
|
+
// On stdout, under the line naming the port, and said in the terminal as well as on the
|
|
269
|
+
// page: whoever opens that URL sees the badge, and whoever left the process running in
|
|
270
|
+
// another window has only this.
|
|
271
|
+
// "No fleet", not "nothing": the settings this serve is running on were resolved the
|
|
272
|
+
// ordinary way, config file included. What was never opened is the fleet.
|
|
273
|
+
if (demoDay !== null)
|
|
274
|
+
console.log('tarmac: demo data — an invented fleet. No fleet on this machine was read.');
|
|
221
275
|
}
|
|
222
276
|
else {
|
|
223
277
|
const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null });
|
package/dist/collect.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readSnapshots } from './snapshots.js';
|
|
|
5
5
|
import { buildFleet } from './fleet.js';
|
|
6
6
|
export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', installed = false, }) {
|
|
7
7
|
const { sessions, health: discovery } = await discoverSessions({ claudeBin });
|
|
8
|
-
const { snapshots, dirError, unreadable, duplicates, dirMissing } = readSnapshots(snapshotsDir, { now });
|
|
8
|
+
const { snapshots, dirError, unreadable, notFiles, duplicates, dirMissing } = readSnapshots(snapshotsDir, { now });
|
|
9
9
|
const fleet = buildFleet({ sessions, snapshots, now, discovery, staleAfterMs });
|
|
10
10
|
// Both blind spots travel with the data: a directory we could not read and files we
|
|
11
11
|
// could not parse are OUR failures to report, not silence to render as "all clear".
|
|
@@ -28,6 +28,7 @@ export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(),
|
|
|
28
28
|
? `ENOENT: ${snapshotsDir} does not exist — the installed wrapper writes there`
|
|
29
29
|
: dirError;
|
|
30
30
|
fleet.health.snapshotsUnreadable = unreadable;
|
|
31
|
+
fleet.health.snapshotsNotFiles = notFiles;
|
|
31
32
|
fleet.health.snapshotsDuplicates = duplicates;
|
|
32
33
|
fleet.health.snapshotsDir = snapshotsDir;
|
|
33
34
|
return fleet;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The month of journal `serve --demo` shows, invented in memory.
|
|
2
|
+
//
|
|
3
|
+
// Why it ships. `/history` was the one page of the demo that showed the product switched off:
|
|
4
|
+
// "History is off.", the 7d and 30d pills greyed out, nothing for the range charts or the
|
|
5
|
+
// scrubber to draw — on exactly the surface they were built for (#156). A demo serve has a past
|
|
6
|
+
// now, and it is a past nothing wrote down.
|
|
7
|
+
//
|
|
8
|
+
// Where it plugs in, and why THERE. It is a `HistoryStore`, the same object a real journal hands
|
|
9
|
+
// the server, so the range route asks it the question it asks any store and never learns where
|
|
10
|
+
// the days came from. Underneath, the days go through `readRange` — the one place that turns
|
|
11
|
+
// records into hours, project costs and window turnovers — via the day seam that reader takes.
|
|
12
|
+
// So there is no second aggregation and no second rendering path: what the demo shows is what a
|
|
13
|
+
// real journal of the same records would show, arrived at by the same code.
|
|
14
|
+
//
|
|
15
|
+
// What it may not do, held by `test/demo-history.test.ts` and by the end-to-end check in
|
|
16
|
+
// `test/demo.test.ts`: touch a disk. Nothing here opens, creates or removes a file. The
|
|
17
|
+
// directory it names is under the invented home, for the same reason every other demo path is,
|
|
18
|
+
// and it exists to be reported and never to be opened.
|
|
19
|
+
//
|
|
20
|
+
// One story, not two. Every record is `demoFleetAt` played through the same `record` a real
|
|
21
|
+
// sampler calls and then through the store's own allowlist, so every minute the journal and the
|
|
22
|
+
// ring share is the same reading in both — which is the last TWO calendar days, the ring being
|
|
23
|
+
// twenty-four hours long and midnights being where they are. The days before those are that same
|
|
24
|
+
// day again, scaled: see `demoDayFactor`, which is where a month of columns stops being a month
|
|
25
|
+
// of one column.
|
|
26
|
+
import { demoFleetAt, DEMO_HOME, DEMO_MINUTES } from './demo.js';
|
|
27
|
+
import { createHistory } from './history.js';
|
|
28
|
+
import { readRange } from './history-range.js';
|
|
29
|
+
import { journalRecordOf } from './history-store.js';
|
|
30
|
+
/**
|
|
31
|
+
* How many local days of it there are, today included.
|
|
32
|
+
*
|
|
33
|
+
* Thirty, which is what the longest range asks for. A week answered both pills with the same
|
|
34
|
+
* seven columns, so `30d` and `7d` drew the identical picture on the one serve built to show
|
|
35
|
+
* them full — a range showing what exists is honest of a real journal younger than its
|
|
36
|
+
* retention, and pointless of a past that is invented anyway. Four times the records, and the
|
|
37
|
+
* whole month is built in about a third of a second.
|
|
38
|
+
*/
|
|
39
|
+
export const DEMO_JOURNAL_DAYS = 30;
|
|
40
|
+
/** A local day, in milliseconds. Used to count days apart, never to walk from one to the next. */
|
|
41
|
+
const DAY = 86_400_000;
|
|
42
|
+
/**
|
|
43
|
+
* What a day of the invented past cost, as a multiple of the day the ring holds.
|
|
44
|
+
*
|
|
45
|
+
* A month of identical columns at $147 is a chart that says "made up" before the reader has read
|
|
46
|
+
* the axis. A fleet's week has a shape — weekends nobody works, an ordinary run of weekdays, and
|
|
47
|
+
* now and then a day something big shipped — and that shape is the thing the cost chart exists
|
|
48
|
+
* to show, so the invented one has it too.
|
|
49
|
+
*
|
|
50
|
+
* Deterministic and stateless: a function of which day it is and nothing else, so two reads of
|
|
51
|
+
* one demo are one answer and a screenshot can be taken twice. The multiplier off Knuth's
|
|
52
|
+
* constant is a spread, not randomness — it just has to be unmemorable and stable.
|
|
53
|
+
*
|
|
54
|
+
* Never asked about a day the ring reaches into — `demoJournalDay` answers 1 for those without
|
|
55
|
+
* coming here, and there are two of them, the ring being twenty-four hours long. The one thing
|
|
56
|
+
* this journal may not do is disagree with the record behind the scrubber about a minute they
|
|
57
|
+
* both hold. What is left for this to scale is the past nobody can compare against.
|
|
58
|
+
*
|
|
59
|
+
* The long day is a weekday, which is what keeps the shape readable: the peaks are then above
|
|
60
|
+
* every ordinary day and the weekends below every one of them, rather than a Saturday at $220
|
|
61
|
+
* saying the opposite of what the chart is showing. Seventeen apart is at most two of them in a
|
|
62
|
+
* month and never none — seventeen days is three weekdays along, so the pair cannot both land on
|
|
63
|
+
* a weekend.
|
|
64
|
+
*/
|
|
65
|
+
export function demoDayFactor(back, weekday) {
|
|
66
|
+
if (back <= 0)
|
|
67
|
+
return 1;
|
|
68
|
+
const spread = ((back * 2_654_435_761) % 1000) / 1000;
|
|
69
|
+
if (weekday === 0 || weekday === 6)
|
|
70
|
+
return 0.55 + 0.12 * spread;
|
|
71
|
+
if (back % 17 === 6)
|
|
72
|
+
return 1.5;
|
|
73
|
+
return 0.85 + 0.38 * spread;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Where the journal would live if there were one. Named because a store names its directory,
|
|
77
|
+
* never opened by anything here — and under the invented home, so a capture of a demo carries
|
|
78
|
+
* no path off the machine that took it.
|
|
79
|
+
*/
|
|
80
|
+
export const DEMO_HISTORY_DIR = `${DEMO_HOME}/.local/state/tarmac/history`;
|
|
81
|
+
const MINUTE = 60_000;
|
|
82
|
+
/**
|
|
83
|
+
* How long the invented day is, as a span rather than as a slot count.
|
|
84
|
+
*
|
|
85
|
+
* `DEMO_MINUTES` is one more than the ring holds, so that the record dates itself by its own
|
|
86
|
+
* oldest reading. The DAY it plays is the 24 hours those minutes cover, and this is the period
|
|
87
|
+
* the past repeats on.
|
|
88
|
+
*
|
|
89
|
+
* It decides the SHAPE of the past and never its density — a record is written every minute
|
|
90
|
+
* whatever this says. A period shorter than the day plays the fleet arriving and going home
|
|
91
|
+
* more than once between two midnights, which charges a fraction of a day's work to each of
|
|
92
|
+
* them and can leave an actor born late in the day out of the week entirely. The suite holds
|
|
93
|
+
* the consequence rather than the number: every full day of the invented past carries all five
|
|
94
|
+
* projects, and each of them costs its own day's scale of one whole day's work — which is true
|
|
95
|
+
* of a whole day repeated and of nothing else.
|
|
96
|
+
*
|
|
97
|
+
* What is NOT held, because it cannot be seen: whether an older cycle replays the day's last
|
|
98
|
+
* minute or stops one short of it. Both tile the past exactly and both leave every day the same
|
|
99
|
+
* length, the same cost and the same fleet. This is the day the ring covers, which makes it the
|
|
100
|
+
* defensible choice rather than the pinned one.
|
|
101
|
+
*/
|
|
102
|
+
const CYCLE_MS = (DEMO_MINUTES - 1) * MINUTE;
|
|
103
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
104
|
+
/** The local day a moment falls on, `YYYY-MM-DD` — the name a journal file carries. */
|
|
105
|
+
const dayOf = (t) => {
|
|
106
|
+
const d = new Date(t);
|
|
107
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
108
|
+
};
|
|
109
|
+
/** Local midnight of a named day, which is the inverse of the name above. */
|
|
110
|
+
function midnightOf(date) {
|
|
111
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
112
|
+
if (m === null)
|
|
113
|
+
return null;
|
|
114
|
+
const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime();
|
|
115
|
+
return Number.isFinite(t) ? t : null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The oldest day this journal answers for, today included: the same calendar arithmetic a real
|
|
119
|
+
* retention uses, so the demo's week is a week the way a reader's is and not 168 hours.
|
|
120
|
+
*/
|
|
121
|
+
const oldestDay = (now, days) => {
|
|
122
|
+
const d = new Date(now);
|
|
123
|
+
return dayOf(new Date(d.getFullYear(), d.getMonth(), d.getDate() - (days - 1)).getTime());
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* The newest minute this journal has, which is the newest minute the RING has: the moment the
|
|
127
|
+
* serve started, and the fleet the live view shows for as long as it is open.
|
|
128
|
+
*
|
|
129
|
+
* The journal stops there rather than at the clock that asked, for the reason the demo runs no
|
|
130
|
+
* sampler at all — the record it was handed is the record it keeps. Bounded by `now` instead, a
|
|
131
|
+
* serve open for three hours invented three hours of minutes past the end of its own day: past
|
|
132
|
+
* the last segment every actor has, so their costs climbed for ever, and past the newest minute
|
|
133
|
+
* of the ring, so the two stopped telling one story an hour in. It also made the answer depend
|
|
134
|
+
* on when it was asked, which is the one thing a demo may not do.
|
|
135
|
+
*/
|
|
136
|
+
const endOfPast = (dayStart) => dayStart + (DEMO_MINUTES - 1) * MINUTE;
|
|
137
|
+
/**
|
|
138
|
+
* Which repetition of the invented day a moment falls in, given the day the ring is anchored on.
|
|
139
|
+
*
|
|
140
|
+
* The last one is the ring's own, unshifted, which is what makes the newest journal day and the
|
|
141
|
+
* record behind the scrubber the same readings rather than two accounts of one fleet. Anything
|
|
142
|
+
* earlier is that same day again, a whole number of days back — a fleet that arrives, works and
|
|
143
|
+
* goes home, five projects at a time, for a week.
|
|
144
|
+
*
|
|
145
|
+
* The floor at zero is the ring's own last minute, `DEMO_MINUTES - 1`, which is a whole cycle
|
|
146
|
+
* after `dayStart` and belongs to the cycle that started there rather than to the next one.
|
|
147
|
+
* Nothing is ever asked past it: `endOfPast` above is where the journal stops.
|
|
148
|
+
*/
|
|
149
|
+
const cycleStartFor = (t, dayStart) => dayStart - Math.max(0, Math.ceil((dayStart - t) / CYCLE_MS)) * CYCLE_MS;
|
|
150
|
+
/**
|
|
151
|
+
* One local day of the invented journal, as the file of that name would have read, or `null`
|
|
152
|
+
* for a day this journal does not cover.
|
|
153
|
+
*
|
|
154
|
+
* The readings sit on the RING's minute grid rather than on the hour, so the day that overlaps
|
|
155
|
+
* the ring carries the ring's own minutes and the two can be compared reading for reading.
|
|
156
|
+
*
|
|
157
|
+
* A function of `dayStart` and nothing else, the clock that asks included: a demo whose past
|
|
158
|
+
* grew while somebody looked at it would be a screenshot nobody could re-take.
|
|
159
|
+
*/
|
|
160
|
+
export function demoJournalDay(date, dayStart) {
|
|
161
|
+
const midnight = midnightOf(date);
|
|
162
|
+
if (midnight === null)
|
|
163
|
+
return null;
|
|
164
|
+
const last = endOfPast(dayStart);
|
|
165
|
+
if (date < oldestDay(last, DEMO_JOURNAL_DAYS) || date > dayOf(last))
|
|
166
|
+
return null;
|
|
167
|
+
// The next local midnight, which is 23, 24 or 25 hours along — calendar arithmetic, never a
|
|
168
|
+
// 24-hour block, so the morning a clock shifts does not lose or double an hour of the day.
|
|
169
|
+
const d = new Date(midnight);
|
|
170
|
+
const end = Math.min(new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() - 1, last);
|
|
171
|
+
const from = Math.ceil((midnight - dayStart) / MINUTE);
|
|
172
|
+
const to = Math.floor((end - dayStart) / MINUTE);
|
|
173
|
+
if (to < from)
|
|
174
|
+
return null;
|
|
175
|
+
// The sampler's own path: `record` is what a real serve calls once a minute, and the sample it
|
|
176
|
+
// hands back is what the journal writes. The ring it fills on the way is thrown away with this
|
|
177
|
+
// call — what is wanted is the reduction, and a second copy of that reduction here would be
|
|
178
|
+
// the one thing this module exists not to be.
|
|
179
|
+
const ring = createHistory({ since: dayStart, cadence: MINUTE });
|
|
180
|
+
// What this whole day cost, against the day the ring holds. Decided once for the day rather
|
|
181
|
+
// than per reading: the reader charges a day what a session's highest reading in it exceeds its
|
|
182
|
+
// lowest, and a multiplier that moved inside the day would be charged that movement as spending.
|
|
183
|
+
//
|
|
184
|
+
// A day the RING reaches into is never scaled, and that is two calendar days rather than one:
|
|
185
|
+
// `dayStart` is twenty-four hours back, so the ring straddles a midnight on every clock but
|
|
186
|
+
// midnight itself. Scaled on the older of the two, 719 of the ring's 1440 minutes went into the
|
|
187
|
+
// journal at another price — the cost chart billing yesterday 14% above what the scrubber shows
|
|
188
|
+
// for the same minutes, which is the one disagreement this module exists to prevent. `end` is
|
|
189
|
+
// this day's last moment, so a day the ring reaches into is one that ends at or after its first.
|
|
190
|
+
const factor = end >= dayStart ? 1 : demoDayFactor(Math.round((midnightOf(dayOf(last)) - midnight) / DAY), d.getDay());
|
|
191
|
+
let text = '';
|
|
192
|
+
for (let k = from; k <= to; k++) {
|
|
193
|
+
const t = dayStart + k * MINUTE;
|
|
194
|
+
const cycleStart = cycleStartFor(t, dayStart);
|
|
195
|
+
const minute = Math.round((t - cycleStart) / MINUTE);
|
|
196
|
+
// How many cycles before the newest day this reading belongs to — what lets the seven-day
|
|
197
|
+
// window climb across a whole invented week instead of replaying one day's ramp. Modulo the
|
|
198
|
+
// week, because that is how long the window IS: a ramp running the length of the journal put
|
|
199
|
+
// the account four times over its plan by the middle of the month and then flat on the floor
|
|
200
|
+
// for the three weeks before it, where a real one rolls over every seven days.
|
|
201
|
+
const cyclesBack = Math.round((dayStart - cycleStart) / CYCLE_MS) % 7;
|
|
202
|
+
const record = journalRecordOf(ring.record(demoFleetAt(minute, cycleStart, t, undefined, cyclesBack)));
|
|
203
|
+
if (factor !== 1)
|
|
204
|
+
for (const s of record.sessions)
|
|
205
|
+
if (s.costUsd !== null)
|
|
206
|
+
s.costUsd = Math.round(s.costUsd * factor * 100) / 100;
|
|
207
|
+
text += `${JSON.stringify(record)}\n`;
|
|
208
|
+
}
|
|
209
|
+
return text;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The store `serve --demo` hands the server in place of a real journal.
|
|
213
|
+
*
|
|
214
|
+
* Everything a writing store does is a no-op here, and none of them is ever called: a demo runs
|
|
215
|
+
* no sampler, so nothing appends, and it takes no directory, so nothing sweeps. They are written
|
|
216
|
+
* out rather than thrown from, because a store that threw would turn a tick into a 500 the day
|
|
217
|
+
* something did call one.
|
|
218
|
+
*/
|
|
219
|
+
export function createDemoHistoryStore({ dayStart }) {
|
|
220
|
+
return {
|
|
221
|
+
dir: DEMO_HISTORY_DIR,
|
|
222
|
+
days: DEMO_JOURNAL_DAYS,
|
|
223
|
+
heartbeat() {
|
|
224
|
+
// No directory, no lock, nothing to keep alive.
|
|
225
|
+
},
|
|
226
|
+
append() {
|
|
227
|
+
// A demo serve records nothing, and this is the guarantee rather than the consequence.
|
|
228
|
+
},
|
|
229
|
+
prune: () => ({ removed: 0, failed: 0 }),
|
|
230
|
+
// Zeroes that are measurements: there is no file and no byte, which is a fact and not an
|
|
231
|
+
// absence. `capped` is false for the same reason — nothing here can fill.
|
|
232
|
+
stats: () => ({ files: 0, bytes: 0, misses: 0, stopped: null, capped: false }),
|
|
233
|
+
read: (range, now) =>
|
|
234
|
+
// `now` decides which calendar days the range covers, as it does for a real journal; what
|
|
235
|
+
// is IN each of them is the serve's own frozen past. So a demo left open loses a day off
|
|
236
|
+
// the front at each midnight — six of seven the next morning, and nothing at all a week
|
|
237
|
+
// in, which is the empty page this exists to remove, reached quietly. The alternative is
|
|
238
|
+
// to date the range on the frozen past too, and then a serve open for three days draws a
|
|
239
|
+
// week that ended three days ago under a live view dated now. Neither is right for a
|
|
240
|
+
// process meant to be opened, looked at and closed; this one at least degrades the way a
|
|
241
|
+
// real journal that stopped being written does.
|
|
242
|
+
readRange({ dir: DEMO_HISTORY_DIR, range, now, readDay: async (date) => demoJournalDay(date, dayStart) }),
|
|
243
|
+
};
|
|
244
|
+
}
|