@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 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, whose nodes are grouped into a frame per working directory, each labelled with its project. As the scrubber is dragged the frames give way to the flat record: sessions drawn as dials, each arc a context window, background agents as strips of text, with the account's five-hour and seven-day gauges above them. Sessions appear and disappear, the arcs fill and reset, the five-hour window fills and rolls over, and a banner names the minute being replayed until Back to live is pressed and the frames come back.">
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. Four framed groups, one per working directory and labelled with its project: beacon, holding a session halted on a permission prompt; harbor, holding a session busy with its context arc at 90% and a background agent docked under the cards of that frame as a strip named after its prompt; quay, reporting a status tarmac does not know; and atlas, idle at 36%. 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 above them, and one warning above the fleet names the unrecognised status rather than filing it as idle.">
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. The record keeps a
99
- project name and never the directory it was read in, and a basename is not a directory. Two
100
- checkouts of `atlas` answer to the same word, so a frame drawn on it would claim a shared
101
- directory nobody can check.
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 # the invented fleet the captures above are taken of
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 screenshot of a real
227
- machine carries working directories, prompts and costs, and nothing real enters this repo.
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
- if (argv[0] && !argv[0].startsWith('-')) {
67
- if (!COMMANDS.has(argv[0]))
68
- throw new Error(`unknown command: ${argv[0]}`);
69
- out.command = argv[0];
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
- if (frozen === null && wrapperIsOurs(p))
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
- // Unattended for hours, so it opens by saying what it decided and on whose authority.
156
- process.stdout.write(renderSettings(config, p.config, historyDir));
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
- const { reaped, failed } = reapOrphanedTemps(snapshotsDir);
198
- if (reaped > 0)
199
- console.log(`tarmac: reaped ${reaped} orphaned snapshot temp file(s)`);
200
- if (failed > 0)
201
- console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
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: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
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
- store: days === null || lock === null ? null : createHistoryStore({ dir: historyDir, days, lock }),
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
+ }