@adrrr/tarmac 0.6.0 → 0.8.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 +103 -62
- package/dist/args.js +21 -9
- package/dist/cli.js +87 -10
- package/dist/config.js +49 -1
- package/dist/history-range.js +320 -0
- package/dist/history-store.js +381 -0
- package/dist/history-view.js +1264 -0
- package/dist/history.js +1 -0
- package/dist/reap.js +6 -0
- package/dist/render.js +57 -16
- package/dist/server.js +121 -3
- package/dist/wrapper.js +5 -0
- package/package.json +1 -1
package/dist/history.js
CHANGED
package/dist/reap.js
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// that same constant: writer and deleter cannot drift apart.
|
|
15
15
|
// • only what is finished. A frame takes milliseconds; anything recent may be a write
|
|
16
16
|
// in flight, and deleting it would be the reaper causing the corruption it prevents.
|
|
17
|
+
//
|
|
18
|
+
// `history/` is not ours. The fleet journal a reader may opt into lives in a SIBLING directory
|
|
19
|
+
// of the snapshots, and `history-store.ts` is its only writer and its only sweeper. Nothing
|
|
20
|
+
// here reaches outside the directory it is handed, and that is why the journal is beside the
|
|
21
|
+
// payloads rather than among them: three sweepers deciding by name over one directory is how a
|
|
22
|
+
// file comes to be deleted by whoever matched it last.
|
|
17
23
|
import fs from 'node:fs';
|
|
18
24
|
import path from 'node:path';
|
|
19
25
|
import { SID_GLOB, TEMP_PREFIX } from './wrapper.js';
|
package/dist/render.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// stays invisible for days. The two surfaces say the same things in their own words — so
|
|
8
8
|
// they are written side by side, and the suite can reach both.
|
|
9
9
|
import { formatDuration } from './config.js';
|
|
10
|
+
import { HISTORY_MAX_BYTES } from './history-store.js';
|
|
10
11
|
import { buildMap, INTERACTIVE, stateOf } from './map.js';
|
|
12
|
+
import { HISTORY_CSS, HISTORY_PHONE_CSS, HISTORY_PALETTE, HISTORY_TOUCH_CSS, historyScript, renderHistoryView, } from './history-view.js';
|
|
11
13
|
import { schemaNotice } from './schema.js';
|
|
12
14
|
import { LIMIT_WINDOWS, RESET_HORIZON_MS, readLimits } from './limits.js';
|
|
13
15
|
import { accountLimits, busyOnStaleFleet } from './fleet.js';
|
|
@@ -131,11 +133,24 @@ export const servingLine = ({ port, movedFrom }) => `tarmac serving http://127.0
|
|
|
131
133
|
* one nobody can go and correct — and pointing at an empty snapshots directory looks exactly
|
|
132
134
|
* like a fleet with no statusline chained.
|
|
133
135
|
*/
|
|
134
|
-
export function renderSettings(config, configFile) {
|
|
136
|
+
export function renderSettings(config, configFile, historyDir) {
|
|
137
|
+
const days = config.historyDays.value;
|
|
135
138
|
const rows = [
|
|
136
139
|
['freshness', formatDuration(config.staleAfterMs.value), config.staleAfterMs.source],
|
|
137
140
|
['port', String(config.port.value), config.port.source],
|
|
138
141
|
['snapshots', config.snapshotsDir.value, config.snapshotsDir.source],
|
|
142
|
+
// Unlike the trusted hosts below, this line is printed either way. Off is the default and
|
|
143
|
+
// the product, and a reader who came looking for their week of history has to be able to
|
|
144
|
+
// read, in the same block as everything else, that there is none and which key starts it.
|
|
145
|
+
// On, it is the only setting here that writes to their disk, so it says all of it at once:
|
|
146
|
+
// how long it keeps, the ceiling they did not set, and the directory to go and look in.
|
|
147
|
+
[
|
|
148
|
+
'history',
|
|
149
|
+
days === null
|
|
150
|
+
? 'off (set history.days to keep more than 24 h)'
|
|
151
|
+
: `${days} days (about 2 MB a day at 8 sessions, hard cap ${HISTORY_MAX_BYTES / (1024 * 1024)} MB) ${historyDir}`,
|
|
152
|
+
config.historyDays.source,
|
|
153
|
+
],
|
|
139
154
|
];
|
|
140
155
|
// Only when there are any. An empty list is what every other run has, chosen by nobody —
|
|
141
156
|
// a `(default)` line saying "none" on every serve is noise, and this line has to read as
|
|
@@ -561,7 +576,7 @@ function ago(ms) {
|
|
|
561
576
|
const m = Math.round(s / 60);
|
|
562
577
|
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
|
|
563
578
|
}
|
|
564
|
-
export function renderPage(fleet, view = 'table') {
|
|
579
|
+
export function renderPage(fleet, view = 'table', { historyEnabled = false } = {}) {
|
|
565
580
|
// The header's copy. `renderLive` below renders its own, out of this same fleet and through
|
|
566
581
|
// this same function — two calls of one pure renderer over one reading, which is what keeps
|
|
567
582
|
// the pair the reader sees and the pair the script will copy up from being two accounts.
|
|
@@ -576,6 +591,9 @@ export function renderPage(fleet, view = 'table') {
|
|
|
576
591
|
a fault, and painting it amber puts it in the same column as "tarmac cannot read this". */
|
|
577
592
|
:root { color-scheme: light dark; --fg:#111; --dim:#6b7280; --line:#e5e7eb; --bg:#fff; --warn:#b45309; --warnbg:#fffbeb; --busy:#047857; --wait:#1d4ed8; }
|
|
578
593
|
@media (prefers-color-scheme: dark) { :root { --fg:#e5e7eb; --dim:#9ca3af; --line:#374151; --bg:#0b0f14; --warn:#fbbf24; --warnbg:#231a06; --busy:#34d399; --wait:#93c5fd; } }
|
|
594
|
+
/* Eight categorical hues for the history view, kept apart from the four above: those four
|
|
595
|
+
say what a session is DOING, and a chart that borrowed one would be colouring a project
|
|
596
|
+
with the word for busy. */${HISTORY_PALETTE}
|
|
579
597
|
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
580
598
|
font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
|
|
581
599
|
header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
|
|
@@ -652,13 +670,13 @@ export function renderPage(fleet, view = 'table') {
|
|
|
652
670
|
Map, is 50), so a horizontal inset buys nothing — and at .3rem against a .15rem gap between
|
|
653
671
|
the tabs it made their two overlays overlap by 7px, where a tap meant for Table landed on
|
|
654
672
|
Map because Map's pseudo paints later. */
|
|
655
|
-
nav a, .replay button, .replaying-note button { position:relative; }
|
|
673
|
+
nav a, .replay button, .replaying-note button, .hist-range button, .to-now, .key { position:relative; }
|
|
656
674
|
@media (pointer: coarse) {
|
|
657
675
|
nav a::after, .replay button::after { content:''; position:absolute; inset:-.7rem 0; }
|
|
658
|
-
.replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }
|
|
659
|
-
}
|
|
676
|
+
.replaying-note button::after { content:''; position:absolute; inset:-.85rem 0; }${HISTORY_TOUCH_CSS} }
|
|
660
677
|
body[data-view="table"] .view-map { display:none; }
|
|
661
678
|
body[data-view="map"] .view-table { display:none; }
|
|
679
|
+
${HISTORY_CSS}
|
|
662
680
|
|
|
663
681
|
/* ── the account's two windows ───────────────────────────────────────────────────────
|
|
664
682
|
In the header, because a rate limit is the account's and not a node's. Slim on purpose:
|
|
@@ -988,7 +1006,19 @@ export function renderPage(fleet, view = 'table') {
|
|
|
988
1006
|
word. min-width:0 for the same reason the berths carry it: a flex item's automatic
|
|
989
1007
|
minimum is its min-content width, which without this is the whole unbroken string. */
|
|
990
1008
|
td[data-label="Project"] .v { order:1; font-weight:600; min-width:0; overflow-wrap:anywhere; }
|
|
991
|
-
|
|
1009
|
+
/* The basis is auto and not 0, which is what decides whether the name breaks INSIDE
|
|
1010
|
+
itself. A basis of 0 puts it on the first line however little is left of one: at 390px
|
|
1011
|
+
beside a 40-character project that is 44.6px of column, a session id cut across two
|
|
1012
|
+
lines, the pill dropped alone underneath and a 63px strip standing at 105.7 — on
|
|
1013
|
+
exactly the fleets whose checkout names are long. Its own width is the floor it wraps
|
|
1014
|
+
at instead: beside the project where it fits, on the next line whole where it does not.
|
|
1015
|
+
That floor is a trade, not a free fix: a name longer than one full line keeps a line of
|
|
1016
|
+
its own at EVERY width, where a basis of 0 let it compress back beside the project as
|
|
1017
|
+
the screen widened. The accepted side of #108: never cut a name mid-word, at the price
|
|
1018
|
+
of a taller strip for prompt-named agents.
|
|
1019
|
+
min-width:0 is what still lets it shrink once it is the widest thing on a line of its
|
|
1020
|
+
own, which is the wrap the rule above is written for. */
|
|
1021
|
+
td[data-label="Session"] .v { order:2; flex:1 1 auto; min-width:0; color:var(--dim);
|
|
992
1022
|
white-space:normal; overflow-wrap:anywhere; }
|
|
993
1023
|
/* The third value that can arrive as one unbroken token, and the one the two rules above
|
|
994
1024
|
missed: a waiting reason is FREE TEXT, so "permission prompt: /Users/…/foo.ts" is a path
|
|
@@ -1043,7 +1073,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
1043
1073
|
td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
|
|
1044
1074
|
td[data-label="Cost"] .v:has(.dim)::before { content:'· cost '; }
|
|
1045
1075
|
.bar { display:none; }
|
|
1046
|
-
}
|
|
1076
|
+
${HISTORY_PHONE_CSS} }
|
|
1047
1077
|
</style>
|
|
1048
1078
|
</head><body data-view="${view}">
|
|
1049
1079
|
<header>
|
|
@@ -1055,6 +1085,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
1055
1085
|
<nav>
|
|
1056
1086
|
<a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
|
|
1057
1087
|
<a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
|
|
1088
|
+
<a href="/history"${view === 'history' ? ' aria-current="page"' : ''}>History</a>
|
|
1058
1089
|
</nav>
|
|
1059
1090
|
<!-- The account's two windows, page-level because that is what they are: a limit belongs to
|
|
1060
1091
|
the account every session below is spending from, not to any one of them. Their VALUES
|
|
@@ -1106,7 +1137,8 @@ export function renderPage(fleet, view = 'table') {
|
|
|
1106
1137
|
<input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
|
|
1107
1138
|
<div class="covers" id="covers"></div>
|
|
1108
1139
|
</div>
|
|
1109
|
-
|
|
1140
|
+
${view === 'history' ? renderHistoryView({ historyEnabled }) : ''}
|
|
1141
|
+
<script>${pageScript(view)}</script>${view === 'history' ? `\n<script>${historyScript()}</script>` : ''}
|
|
1110
1142
|
</body></html>
|
|
1111
1143
|
`;
|
|
1112
1144
|
}
|
|
@@ -1321,12 +1353,20 @@ function pageScript(view) {
|
|
|
1321
1353
|
}
|
|
1322
1354
|
|
|
1323
1355
|
function two(n) { return (n < 10 ? '0' : '') + n; }
|
|
1324
|
-
|
|
1356
|
+
// One page, one clock, and it is the one the summary line already dates the fleet on: an ISO
|
|
1357
|
+
// instant in UTC. Spelled on the machine's own clock, these minutes and that stamp are the
|
|
1358
|
+
// same fleet apparently living in two time zones, the first time anything shows both — an
|
|
1359
|
+
// export, a log line, a screenshot with the header in it. Every minute below carries UTC
|
|
1360
|
+
// where a reader meets it, because an unlabelled one reads as the reader's own clock and is
|
|
1361
|
+
// the same lie facing the other way.
|
|
1362
|
+
function hhmm(t) { var d = new Date(t); return two(d.getUTCHours()) + ':' + two(d.getUTCMinutes()); }
|
|
1325
1363
|
|
|
1326
1364
|
// A day-long ring can straddle one midnight, and "09:14 – 08:59" reads as a span running
|
|
1327
|
-
// backwards until the older edge says which day it is.
|
|
1365
|
+
// backwards until the older edge says which day it is. Which midnight is UTC's, like the
|
|
1366
|
+
// minutes it separates: counted otherwise, "yesterday" turns up between two minutes of one
|
|
1367
|
+
// UTC day and goes missing across the midnight it exists for.
|
|
1328
1368
|
function edge(t, ref) {
|
|
1329
|
-
return hhmm(t) + (new Date(t).
|
|
1369
|
+
return hhmm(t) + (new Date(t).getUTCDate() === new Date(ref).getUTCDate() ? '' : ' yesterday');
|
|
1330
1370
|
}
|
|
1331
1371
|
|
|
1332
1372
|
// What the range covers, in the record's own terms. Never "a day": that is the size of the
|
|
@@ -1338,13 +1378,13 @@ function pageScript(view) {
|
|
|
1338
1378
|
// this was the one branch that threw that away: ten hours of a collector that could not
|
|
1339
1379
|
// run read exactly like a serve thirty seconds old.
|
|
1340
1380
|
return record.missed
|
|
1341
|
-
? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' and '
|
|
1381
|
+
? 'Nothing recorded — this serve started at ' + hhmm(record.since) + ' UTC and '
|
|
1342
1382
|
+ record.missed + ' minute' + (record.missed === 1 ? '' : 's') + ' were due and never read.'
|
|
1343
|
-
: 'Nothing recorded yet — this serve started at ' + hhmm(record.since)
|
|
1383
|
+
: 'Nothing recorded yet — this serve started at ' + hhmm(record.since) + ' UTC'
|
|
1344
1384
|
+ ' and takes a reading every ' + Math.round(record.cadence / 1000) + 's.';
|
|
1345
1385
|
}
|
|
1346
1386
|
var last = record.samples[n - 1].t;
|
|
1347
|
-
return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last)
|
|
1387
|
+
return 'Covering ' + edge(record.since, last) + ' – ' + hhmm(last) + ' UTC'
|
|
1348
1388
|
// Not "when the page loaded": the record is asked for again when a tab that has been
|
|
1349
1389
|
// away comes back, so the sentence names the last time this page asked rather than a
|
|
1350
1390
|
// moment it may be hours past.
|
|
@@ -1579,8 +1619,9 @@ function pageScript(view) {
|
|
|
1579
1619
|
scrub.value = String(i);
|
|
1580
1620
|
// The handle's own value is an index, so a reader who cannot see the banner would be read
|
|
1581
1621
|
// "3" while the fleet on screen is three hours old. The minute travels with the handle.
|
|
1582
|
-
|
|
1583
|
-
|
|
1622
|
+
var minute = hhmm(s.t) + ' UTC';
|
|
1623
|
+
scrub.setAttribute('aria-valuetext', minute);
|
|
1624
|
+
atEl.textContent = minute;
|
|
1584
1625
|
rmeta.textContent = metaOf(s);
|
|
1585
1626
|
rmap.innerHTML = nodesOf(s);
|
|
1586
1627
|
// The account of that minute, in the place the live pair occupies — which the body class
|
package/dist/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import http from 'node:http';
|
|
|
6
6
|
import { reason, renderLive, renderPage } from './render.js';
|
|
7
7
|
import { hostName, SOURCE_PHRASE } from './config.js';
|
|
8
8
|
import { createHistory, HISTORY_CADENCE_MS } from './history.js';
|
|
9
|
+
import { HISTORY_RANGES, readRange } from './history-range.js';
|
|
9
10
|
/**
|
|
10
11
|
* On every answer, including the refusals and the 500s. The page swaps what this port returns
|
|
11
12
|
* into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
|
|
@@ -19,8 +20,9 @@ const IDENTITY = { 'x-tarmac': '1' };
|
|
|
19
20
|
const PAGES = new Map([
|
|
20
21
|
['/', 'table'],
|
|
21
22
|
['/map', 'map'],
|
|
23
|
+
['/history', 'history'],
|
|
22
24
|
]);
|
|
23
|
-
export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [] }) {
|
|
25
|
+
export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [], store = null, rangeCacheMs = 60_000, report = (line) => console.error(line), }) {
|
|
24
26
|
// Normalised HERE rather than trusted to arrive that way. This is the last thing between a
|
|
25
27
|
// foreign origin and the fleet, so it owns both sides of its own comparison — the config
|
|
26
28
|
// parser cuts a name the same way, and neither leans on the other having done it.
|
|
@@ -46,14 +48,76 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
46
48
|
// than a slot would otherwise be answered with a queue of processes instead of one missed
|
|
47
49
|
// minute — the tick that finds a read still running counts the slot and stands down.
|
|
48
50
|
let reading = false;
|
|
51
|
+
// Said on the CHANGE, never on the tick. A read-only journal directory is a fact that holds
|
|
52
|
+
// for hours, and a line a minute about it is a log a reader learns to scroll past, which is
|
|
53
|
+
// the same as never having printed it. A failure that comes back after a run of good writes
|
|
54
|
+
// is a new fact and says so again, which is what the two flags below are for.
|
|
55
|
+
let misses = 0;
|
|
56
|
+
let failing = false;
|
|
57
|
+
let saidStopped = null;
|
|
58
|
+
const journal = (taken) => {
|
|
59
|
+
if (store === null)
|
|
60
|
+
return;
|
|
61
|
+
// Structurally best effort, not best effort by argument. The store swallows its own
|
|
62
|
+
// filesystem trouble, so nothing in here can throw today; but this is called from inside
|
|
63
|
+
// the sampler's try, where a throw would be caught as A FLEET READING THAT FAILED and
|
|
64
|
+
// counted as a missed minute in `/api/history` on top of the sample already pushed. The
|
|
65
|
+
// journal is not allowed to make the record of the fleet wrong, whatever it does to itself.
|
|
66
|
+
try {
|
|
67
|
+
store.append(taken);
|
|
68
|
+
const stats = store.stats();
|
|
69
|
+
if (stats.stopped !== saidStopped) {
|
|
70
|
+
saidStopped = stats.stopped;
|
|
71
|
+
if (stats.stopped !== null)
|
|
72
|
+
report(`tarmac: the fleet journal has stopped, ${stats.stopped}`);
|
|
73
|
+
}
|
|
74
|
+
const missed = stats.misses > misses;
|
|
75
|
+
misses = stats.misses;
|
|
76
|
+
if (missed && !failing) {
|
|
77
|
+
report(`tarmac: could not write to the fleet journal in ${store.dir}; that reading is lost`);
|
|
78
|
+
}
|
|
79
|
+
failing = missed;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Including a `report` a caller wrote that throws: this one is not ours to be right.
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const ranges = new Map();
|
|
86
|
+
const rangeOf = (store, range) => {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const held = ranges.get(range);
|
|
89
|
+
if (held !== undefined && (held.at === null || now - held.at < rangeCacheMs))
|
|
90
|
+
return held.reading;
|
|
91
|
+
const entry = { at: null, reading: undefined };
|
|
92
|
+
// Read once per range per minute rather than per request: `stats()` walks the directory, and
|
|
93
|
+
// a journal that stopped at its cap stays stopped for hours, so a minute-old answer to that
|
|
94
|
+
// question is the same answer.
|
|
95
|
+
entry.reading = readRange({ dir: store.dir, range, now, capped: store.stats().capped }).then((answer) => {
|
|
96
|
+
entry.at = Date.now();
|
|
97
|
+
return answer;
|
|
98
|
+
}, (e) => {
|
|
99
|
+
// A read that failed leaves nothing behind to be served for the rest of the minute. Only
|
|
100
|
+
// its own entry, though: a slow failure that cleared the map would drop the good answer a
|
|
101
|
+
// later request had already put there.
|
|
102
|
+
if (ranges.get(range) === entry)
|
|
103
|
+
ranges.delete(range);
|
|
104
|
+
throw e;
|
|
105
|
+
});
|
|
106
|
+
ranges.set(range, entry);
|
|
107
|
+
return entry.reading;
|
|
108
|
+
};
|
|
49
109
|
const sample = async () => {
|
|
110
|
+
// First, and outside the try: the journal's lock says this process is alive, which is a
|
|
111
|
+
// fact about the tick and not about the reading. A collector that has been throwing for
|
|
112
|
+
// five minutes would otherwise leave the lock looking abandoned to the next serve.
|
|
113
|
+
store?.heartbeat();
|
|
50
114
|
if (reading) {
|
|
51
115
|
history.miss(Date.now());
|
|
52
116
|
return;
|
|
53
117
|
}
|
|
54
118
|
reading = true;
|
|
55
119
|
try {
|
|
56
|
-
history.record(await collect());
|
|
120
|
+
journal(history.record(await collect()));
|
|
57
121
|
}
|
|
58
122
|
catch {
|
|
59
123
|
// A collector that throws is the normal weather here: `claude` missing, a laptop that
|
|
@@ -106,6 +170,41 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
106
170
|
// Served out of the ring, above the collect below and never through it: this route is
|
|
107
171
|
// what the serve has ALREADY read, and one that collected would let a scrubber spawn
|
|
108
172
|
// `claude agents --json` on every drag of its handle.
|
|
173
|
+
// The same route, one question further back: `range` sends it to the journal on disk for the
|
|
174
|
+
// week or the month the ring cannot hold. No range is the ring, and `24h` is the name of
|
|
175
|
+
// that, so a page may say which one it wants without asking for a different route.
|
|
176
|
+
if (url.pathname === '/api/history' && url.searchParams.has('range')) {
|
|
177
|
+
const asked = url.searchParams.get('range');
|
|
178
|
+
if (asked !== '24h') {
|
|
179
|
+
if (!isRange(asked)) {
|
|
180
|
+
// The value is not quoted back, for the reason the refused Host is not: it is a string
|
|
181
|
+
// the caller wrote, and the page swaps a refusal's text into `innerHTML` as its reason.
|
|
182
|
+
res.writeHead(400, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
183
|
+
res.end(`tarmac serves /api/history for 24h, ${HISTORY_RANGES.join(' and ')}; no range is the last 24h\n`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Off is not the same answer as an empty week: a page that could not tell them apart
|
|
187
|
+
// would draw a flat line over a month the fleet was busy for.
|
|
188
|
+
let body;
|
|
189
|
+
try {
|
|
190
|
+
body = store === null ? { enabled: false, range: asked } : { enabled: true, ...(await rangeOf(store, asked)) };
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
// Nothing in `readRange` is allowed to throw, and this is the seam that keeps a day
|
|
194
|
+
// when something does from being a request that hangs instead of an answer.
|
|
195
|
+
res.writeHead(500, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
|
|
196
|
+
res.end(`tarmac could not read the fleet journal:\n${reason(e)}\n`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
res.writeHead(200, {
|
|
200
|
+
...IDENTITY,
|
|
201
|
+
'content-type': 'application/json; charset=utf-8',
|
|
202
|
+
'cache-control': 'no-store',
|
|
203
|
+
});
|
|
204
|
+
res.end(JSON.stringify(body));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
109
208
|
if (url.pathname === '/api/history') {
|
|
110
209
|
res.writeHead(200, {
|
|
111
210
|
...IDENTITY,
|
|
@@ -134,7 +233,13 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
134
233
|
}
|
|
135
234
|
else {
|
|
136
235
|
type = 'text/html; charset=utf-8';
|
|
137
|
-
|
|
236
|
+
// Whether there is a journal is the config's answer and the server is the one holding
|
|
237
|
+
// it, so the view ships knowing — rather than drawing three live ranges and taking two
|
|
238
|
+
// of them back once a fetch has been out and come home refused.
|
|
239
|
+
body =
|
|
240
|
+
url.pathname === '/live'
|
|
241
|
+
? renderLive(fleet)
|
|
242
|
+
: renderPage(fleet, PAGES.get(url.pathname), { historyEnabled: store !== null });
|
|
138
243
|
}
|
|
139
244
|
}
|
|
140
245
|
catch (e) {
|
|
@@ -161,6 +266,17 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
161
266
|
server.on('listening', () => {
|
|
162
267
|
if (sampler !== null)
|
|
163
268
|
return;
|
|
269
|
+
// The journal's retention, applied before the first line of this run is written and once a
|
|
270
|
+
// local day after that (the store keeps that half itself). Here rather than in the CLI so
|
|
271
|
+
// that the store `serve` prunes with is, provably, the store `serve` writes with: a `serve`
|
|
272
|
+
// that built one and forgot to hand it over would otherwise sweep and journal nothing.
|
|
273
|
+
if (store !== null) {
|
|
274
|
+
const { removed, failed } = store.prune();
|
|
275
|
+
if (removed > 0)
|
|
276
|
+
report(`tarmac: removed ${removed} journal file(s) older than ${store.days} days from ${store.dir}`);
|
|
277
|
+
if (failed > 0)
|
|
278
|
+
report(`tarmac: could not remove ${failed} journal file(s) under ${store.dir}`);
|
|
279
|
+
}
|
|
164
280
|
sampler = setInterval(() => void sample(), sampleEveryMs);
|
|
165
281
|
sampler.unref();
|
|
166
282
|
});
|
|
@@ -241,6 +357,8 @@ function attempt(server, port, host) {
|
|
|
241
357
|
server.listen(port, host);
|
|
242
358
|
});
|
|
243
359
|
}
|
|
360
|
+
/** One of the ranges the journal reader knows, matched whole. */
|
|
361
|
+
const isRange = (asked) => HISTORY_RANGES.includes(asked);
|
|
244
362
|
function isLoopbackHost(host) {
|
|
245
363
|
if (!host)
|
|
246
364
|
return false;
|
package/dist/wrapper.js
CHANGED
|
@@ -173,6 +173,11 @@ if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
|
|
|
173
173
|
fi
|
|
174
174
|
|
|
175
175
|
# --- prune the snapshots of sessions that stopped rendering (amortized) ---
|
|
176
|
+
# \`history/\` is not ours either: the journal a reader may opt into is a SIBLING directory of
|
|
177
|
+
# this one, swept by \`history-store.ts\` alone. The find below is bounded to \`\$TARMAC_DIR\` and
|
|
178
|
+
# matches the session-id shape, so it cannot reach it, and that is the point of the two
|
|
179
|
+
# directories being separate.
|
|
180
|
+
#
|
|
176
181
|
# Nothing else would ever remove them: \`reap.ts\` collects this script's own temp litter and
|
|
177
182
|
# refuses to touch a \`<sid>.json\`. A fleet that recycles its sessions nightly leaves one dead
|
|
178
183
|
# file behind per session per night, forever. A LIVE session restamps its own snapshot on
|