@adrrr/tarmac 0.7.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 +47 -10
- package/dist/args.js +9 -3
- package/dist/cli.js +62 -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 +27 -7
- 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:
|
|
@@ -1055,7 +1073,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
1055
1073
|
td[data-label="Effort"] .v:has(.dim)::before { content:'· effort '; }
|
|
1056
1074
|
td[data-label="Cost"] .v:has(.dim)::before { content:'· cost '; }
|
|
1057
1075
|
.bar { display:none; }
|
|
1058
|
-
}
|
|
1076
|
+
${HISTORY_PHONE_CSS} }
|
|
1059
1077
|
</style>
|
|
1060
1078
|
</head><body data-view="${view}">
|
|
1061
1079
|
<header>
|
|
@@ -1067,6 +1085,7 @@ export function renderPage(fleet, view = 'table') {
|
|
|
1067
1085
|
<nav>
|
|
1068
1086
|
<a href="/"${view === 'table' ? ' aria-current="page"' : ''}>Table</a>
|
|
1069
1087
|
<a href="/map"${view === 'map' ? ' aria-current="page"' : ''}>Map</a>
|
|
1088
|
+
<a href="/history"${view === 'history' ? ' aria-current="page"' : ''}>History</a>
|
|
1070
1089
|
</nav>
|
|
1071
1090
|
<!-- The account's two windows, page-level because that is what they are: a limit belongs to
|
|
1072
1091
|
the account every session below is spending from, not to any one of them. Their VALUES
|
|
@@ -1118,7 +1137,8 @@ export function renderPage(fleet, view = 'table') {
|
|
|
1118
1137
|
<input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
|
|
1119
1138
|
<div class="covers" id="covers"></div>
|
|
1120
1139
|
</div>
|
|
1121
|
-
|
|
1140
|
+
${view === 'history' ? renderHistoryView({ historyEnabled }) : ''}
|
|
1141
|
+
<script>${pageScript(view)}</script>${view === 'history' ? `\n<script>${historyScript()}</script>` : ''}
|
|
1122
1142
|
</body></html>
|
|
1123
1143
|
`;
|
|
1124
1144
|
}
|
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
|