@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 CHANGED
@@ -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 week 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 week 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 week 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
 
@@ -115,6 +123,12 @@ The third tab draws what moved rather than what is: context per session, cost pe
115
123
  the account's two windows, over the last 24 hours out of the ring or over 7 and 30 days out of
116
124
  the journal, if you keep one. `<canvas>` and the page's own script, no library.
117
125
 
126
+ <picture>
127
+ <source media="(prefers-color-scheme: dark)" srcset="docs/media/curves-dark.png">
128
+ <img src="docs/media/curves-light.png" width="1100"
129
+ 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 line per session, each a sawtooth that climbs through the day and breaks where the session was recycled, the two still climbing labelled with an arrow. Cost is one stacked bar per day in a fixed project order, the current day shorter than the six complete ones. 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.">
130
+ </picture>
131
+
118
132
  A context line breaks where its session did, so a recycle at three in the morning reads as a
119
133
  break and not as a cliff, and a minute nobody read is a hole rather than a fall to zero. The
120
134
  lines that gained fifteen points or more in the last three hours are drawn full and labelled,
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,184 @@
1
+ // The week 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 the last day of the journal and
22
+ // the ring behind the scrubber are the same readings, minute for minute.
23
+ import { demoFleetAt, DEMO_HOME, DEMO_MINUTES } from './demo.js';
24
+ import { createHistory } from './history.js';
25
+ import { readRange } from './history-range.js';
26
+ import { journalRecordOf } from './history-store.js';
27
+ /**
28
+ * How many local days of it there are, today included.
29
+ *
30
+ * Seven and not thirty. A month is four times the work for a page that says what it covers
31
+ * anyway, and a `30d` that answers the week it has is a range showing what exists — which beats
32
+ * one invented badly, and is what a real journal younger than its retention already does.
33
+ */
34
+ export const DEMO_JOURNAL_DAYS = 7;
35
+ /**
36
+ * Where the journal would live if there were one. Named because a store names its directory,
37
+ * never opened by anything here — and under the invented home, so a capture of a demo carries
38
+ * no path off the machine that took it.
39
+ */
40
+ export const DEMO_HISTORY_DIR = `${DEMO_HOME}/.local/state/tarmac/history`;
41
+ const MINUTE = 60_000;
42
+ /**
43
+ * How long the invented day is, as a span rather than as a slot count.
44
+ *
45
+ * `DEMO_MINUTES` is one more than the ring holds, so that the record dates itself by its own
46
+ * oldest reading. The DAY it plays is the 24 hours those minutes cover, and this is the period
47
+ * the past repeats on.
48
+ *
49
+ * It decides the SHAPE of the past and never its density — a record is written every minute
50
+ * whatever this says. A period shorter than the day plays the fleet arriving and going home
51
+ * more than once between two midnights, which charges a fraction of a day's work to each of
52
+ * them and can leave an actor born late in the day out of the week entirely. The suite holds
53
+ * the consequence rather than the number: every full day of the invented week costs the same
54
+ * and carries all five projects, which is true of a whole day repeated and of nothing else.
55
+ *
56
+ * What is NOT held, because it cannot be seen: whether an older cycle replays the day's last
57
+ * minute or stops one short of it. Both tile the past exactly and both leave every day the same
58
+ * length, the same cost and the same fleet. This is the day the ring covers, which makes it the
59
+ * defensible choice rather than the pinned one.
60
+ */
61
+ const CYCLE_MS = (DEMO_MINUTES - 1) * MINUTE;
62
+ const pad = (n) => String(n).padStart(2, '0');
63
+ /** The local day a moment falls on, `YYYY-MM-DD` — the name a journal file carries. */
64
+ const dayOf = (t) => {
65
+ const d = new Date(t);
66
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
67
+ };
68
+ /** Local midnight of a named day, which is the inverse of the name above. */
69
+ function midnightOf(date) {
70
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
71
+ if (m === null)
72
+ return null;
73
+ const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime();
74
+ return Number.isFinite(t) ? t : null;
75
+ }
76
+ /**
77
+ * The oldest day this journal answers for, today included: the same calendar arithmetic a real
78
+ * retention uses, so the demo's week is a week the way a reader's is and not 168 hours.
79
+ */
80
+ const oldestDay = (now, days) => {
81
+ const d = new Date(now);
82
+ return dayOf(new Date(d.getFullYear(), d.getMonth(), d.getDate() - (days - 1)).getTime());
83
+ };
84
+ /**
85
+ * The newest minute this journal has, which is the newest minute the RING has: the moment the
86
+ * serve started, and the fleet the live view shows for as long as it is open.
87
+ *
88
+ * The journal stops there rather than at the clock that asked, for the reason the demo runs no
89
+ * sampler at all — the record it was handed is the record it keeps. Bounded by `now` instead, a
90
+ * serve open for three hours invented three hours of minutes past the end of its own day: past
91
+ * the last segment every actor has, so their costs climbed for ever, and past the newest minute
92
+ * of the ring, so the two stopped telling one story an hour in. It also made the answer depend
93
+ * on when it was asked, which is the one thing a demo may not do.
94
+ */
95
+ const endOfPast = (dayStart) => dayStart + (DEMO_MINUTES - 1) * MINUTE;
96
+ /**
97
+ * Which repetition of the invented day a moment falls in, given the day the ring is anchored on.
98
+ *
99
+ * The last one is the ring's own, unshifted, which is what makes the newest journal day and the
100
+ * record behind the scrubber the same readings rather than two accounts of one fleet. Anything
101
+ * earlier is that same day again, a whole number of days back — a fleet that arrives, works and
102
+ * goes home, five projects at a time, for a week.
103
+ *
104
+ * The floor at zero is the ring's own last minute, `DEMO_MINUTES - 1`, which is a whole cycle
105
+ * after `dayStart` and belongs to the cycle that started there rather than to the next one.
106
+ * Nothing is ever asked past it: `endOfPast` above is where the journal stops.
107
+ */
108
+ const cycleStartFor = (t, dayStart) => dayStart - Math.max(0, Math.ceil((dayStart - t) / CYCLE_MS)) * CYCLE_MS;
109
+ /**
110
+ * One local day of the invented journal, as the file of that name would have read, or `null`
111
+ * for a day this journal does not cover.
112
+ *
113
+ * The readings sit on the RING's minute grid rather than on the hour, so the day that overlaps
114
+ * the ring carries the ring's own minutes and the two can be compared reading for reading.
115
+ *
116
+ * A function of `dayStart` and nothing else, the clock that asks included: a demo whose past
117
+ * grew while somebody looked at it would be a screenshot nobody could re-take.
118
+ */
119
+ export function demoJournalDay(date, dayStart) {
120
+ const midnight = midnightOf(date);
121
+ if (midnight === null)
122
+ return null;
123
+ const last = endOfPast(dayStart);
124
+ if (date < oldestDay(last, DEMO_JOURNAL_DAYS) || date > dayOf(last))
125
+ return null;
126
+ // The next local midnight, which is 23, 24 or 25 hours along — calendar arithmetic, never a
127
+ // 24-hour block, so the morning a clock shifts does not lose or double an hour of the day.
128
+ const d = new Date(midnight);
129
+ const end = Math.min(new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() - 1, last);
130
+ const from = Math.ceil((midnight - dayStart) / MINUTE);
131
+ const to = Math.floor((end - dayStart) / MINUTE);
132
+ if (to < from)
133
+ return null;
134
+ // The sampler's own path: `record` is what a real serve calls once a minute, and the sample it
135
+ // hands back is what the journal writes. The ring it fills on the way is thrown away with this
136
+ // call — what is wanted is the reduction, and a second copy of that reduction here would be
137
+ // the one thing this module exists not to be.
138
+ const ring = createHistory({ since: dayStart, cadence: MINUTE });
139
+ let text = '';
140
+ for (let k = from; k <= to; k++) {
141
+ const t = dayStart + k * MINUTE;
142
+ const cycleStart = cycleStartFor(t, dayStart);
143
+ const minute = Math.round((t - cycleStart) / MINUTE);
144
+ // How many cycles before the newest day this reading belongs to — what lets the seven-day
145
+ // window climb across the whole invented week instead of replaying one day's ramp.
146
+ const cyclesBack = Math.round((dayStart - cycleStart) / CYCLE_MS);
147
+ text += `${JSON.stringify(journalRecordOf(ring.record(demoFleetAt(minute, cycleStart, t, undefined, cyclesBack))))}\n`;
148
+ }
149
+ return text;
150
+ }
151
+ /**
152
+ * The store `serve --demo` hands the server in place of a real journal.
153
+ *
154
+ * Everything a writing store does is a no-op here, and none of them is ever called: a demo runs
155
+ * no sampler, so nothing appends, and it takes no directory, so nothing sweeps. They are written
156
+ * out rather than thrown from, because a store that threw would turn a tick into a 500 the day
157
+ * something did call one.
158
+ */
159
+ export function createDemoHistoryStore({ dayStart }) {
160
+ return {
161
+ dir: DEMO_HISTORY_DIR,
162
+ days: DEMO_JOURNAL_DAYS,
163
+ heartbeat() {
164
+ // No directory, no lock, nothing to keep alive.
165
+ },
166
+ append() {
167
+ // A demo serve records nothing, and this is the guarantee rather than the consequence.
168
+ },
169
+ prune: () => ({ removed: 0, failed: 0 }),
170
+ // Zeroes that are measurements: there is no file and no byte, which is a fact and not an
171
+ // absence. `capped` is false for the same reason — nothing here can fill.
172
+ stats: () => ({ files: 0, bytes: 0, misses: 0, stopped: null, capped: false }),
173
+ read: (range, now) =>
174
+ // `now` decides which calendar days the range covers, as it does for a real journal; what
175
+ // is IN each of them is the serve's own frozen past. So a demo left open loses a day off
176
+ // the front at each midnight — six of seven the next morning, and nothing at all a week
177
+ // in, which is the empty page this exists to remove, reached quietly. The alternative is
178
+ // to date the range on the frozen past too, and then a serve open for three days draws a
179
+ // week that ended three days ago under a live view dated now. Neither is right for a
180
+ // process meant to be opened, looked at and closed; this one at least degrades the way a
181
+ // real journal that stopped being written does.
182
+ readRange({ dir: DEMO_HISTORY_DIR, range, now, readDay: async (date) => demoJournalDay(date, dayStart) }),
183
+ };
184
+ }