@adrrr/tarmac 0.8.0 → 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/LICENSE +1 -1
- package/README.md +14 -0
- package/dist/args.js +26 -8
- package/dist/cli.js +65 -11
- package/dist/collect.js +2 -1
- package/dist/demo-history.js +184 -0
- package/dist/demo.js +392 -0
- package/dist/history-range.js +54 -9
- package/dist/history-store.js +12 -2
- package/dist/history-view.js +80 -7
- package/dist/render.js +25 -4
- package/dist/server.js +83 -22
- package/dist/sessions.js +8 -2
- package/dist/snapshots.js +27 -11
- package/package.json +2 -2
package/LICENSE
CHANGED
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
|
-
|
|
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,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
|
+
}
|