@adrrr/tarmac 0.1.2 → 0.3.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 +109 -47
- package/dist/cli.js +20 -5
- package/dist/collect.js +17 -9
- package/dist/fleet.js +11 -0
- package/dist/install.js +349 -15
- package/dist/map.js +110 -0
- package/dist/reap.js +9 -4
- package/dist/render.js +285 -18
- package/dist/server.js +12 -2
- package/dist/sessions.js +21 -1
- package/dist/snapshots.js +19 -2
- package/dist/wrapper.js +108 -14
- package/package.json +9 -1
package/dist/wrapper.js
CHANGED
|
@@ -16,13 +16,16 @@
|
|
|
16
16
|
// below is in the POSIX shell command language, and `test/portability.test.ts` runs this
|
|
17
17
|
// script under every POSIX shell present on the machine to keep it that way.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
19
|
+
// Three invariants, all tested by running the real script:
|
|
20
20
|
// RULE 1 — never break the display. Missing chain, failing chain, unwritable directory:
|
|
21
21
|
// the status line still renders and the exit code is still 0. Telemetry loses,
|
|
22
22
|
// display wins, always.
|
|
23
23
|
// RULE 2 — never write outside the snapshot directory. `session_id` is external input
|
|
24
24
|
// that becomes a filename, so anything that is not UUID-shaped is REFUSED, not
|
|
25
25
|
// sanitised: a guessed name would be read back later as if it were certain.
|
|
26
|
+
// RULE 3 — the sweep may remove exactly what the writer may write, no more and no less.
|
|
27
|
+
// One rule, `SID_GLOB`, read by both — because when those two sets are merely
|
|
28
|
+
// written to agree, they stop agreeing quietly, in both directions at once (#7).
|
|
26
29
|
/**
|
|
27
30
|
* Prefix of every temp file the wrapper writes, and the ONLY thing that proves tarmac
|
|
28
31
|
* wrote one. `.<sid>.<pid>.tmp` — what this used to emit — is a convention, not a
|
|
@@ -54,6 +57,55 @@ export const PRUNE_EVERY_MIN = 60;
|
|
|
54
57
|
* so nothing was reading that file as current anyway.
|
|
55
58
|
*/
|
|
56
59
|
export const SNAPSHOT_TTL_MIN = 48 * 60;
|
|
60
|
+
/**
|
|
61
|
+
* A session id — ONE rule, and the only one the writer below, the sweep below and the
|
|
62
|
+
* TypeScript that reads this directory are allowed to know. Written as a shell pattern
|
|
63
|
+
* because two of the three consumers are shell; the third derives its regex from it.
|
|
64
|
+
*
|
|
65
|
+
* It is the canonical UUID, 8-4-4-4-12 hex: every fixture in this repo, every file in the
|
|
66
|
+
* snapshot directory of the fleet this was built for, every transcript file observed. The
|
|
67
|
+
* statusline payload documents `session_id` only as a "unique session identifier", so that
|
|
68
|
+
* is an observation and not a promise — and the direction of the bet is deliberate. An id
|
|
69
|
+
* that is not a UUID is refused at write time, which surfaces as a live session with
|
|
70
|
+
* `absent` telemetry: a state `fleet.ts` already names and shows. The other bet — file
|
|
71
|
+
* whatever arrives, and widen the deleters to match — would put every stem of 8..64
|
|
72
|
+
* characters of `[0-9A-Za-z-]` within reach of `rm`, in a directory whose location comes
|
|
73
|
+
* from `XDG_STATE_HOME` and can therefore be `~/.claude` itself, where the legacy purge
|
|
74
|
+
* already deletes and where people keep a git repository. A missing row is recoverable.
|
|
75
|
+
*
|
|
76
|
+
* Bracket expressions, not `?`: `?` matches a leading dot (fnmatch without FNM_PERIOD), so
|
|
77
|
+
* the old glob reached dotfiles the writer's own charset forbids it to produce (#7).
|
|
78
|
+
*
|
|
79
|
+
* And an ENUMERATION, not the range `[0-9a-fA-F]`: a range is collated by the locale, which
|
|
80
|
+
* for a status line is whatever the TUI that spawned it carries. Under `en_US.UTF-8` — the
|
|
81
|
+
* ordinary case on macOS, where `/bin/sh` is bash — `a-f` reaches `é`, `ç` and fullwidth `a`
|
|
82
|
+
* in bash, in ksh and in BSD `find`, while the regex below is ASCII code points and always
|
|
83
|
+
* will be. That is one string meaning two different sets depending on `LANG`, which is this
|
|
84
|
+
* whole rule undone: a sid filed in a Terminal, refused under `LC_ALL=C`, and a file written
|
|
85
|
+
* by the first frame that no TypeScript consumer here can ever recognise. Sixteen digits
|
|
86
|
+
* spelled out roughly doubles the pattern — 356 characters to 772 — and costs nothing
|
|
87
|
+
* measurable per frame.
|
|
88
|
+
*/
|
|
89
|
+
const HEX = '[0123456789abcdefABCDEF]';
|
|
90
|
+
export const SID_GLOB = [8, 4, 4, 4, 12].map((n) => HEX.repeat(n)).join('-');
|
|
91
|
+
/**
|
|
92
|
+
* The names the sweep below is allowed to remove: the sid rule, and a `.json`. NOT `*.json` —
|
|
93
|
+
* that would take a `settings.json` or a `fleet.json` sitting next to them, data this script
|
|
94
|
+
* never wrote, deleted from inside a status line.
|
|
95
|
+
*/
|
|
96
|
+
export const SNAPSHOT_GLOB = `${SID_GLOB}.json`;
|
|
97
|
+
/**
|
|
98
|
+
* The same rule, in Node — the pattern goes in RAW, because a bracket expression means the
|
|
99
|
+
* same set in both languages and `-` is literal in both. Only the extension is spelled twice,
|
|
100
|
+
* once per language, which is the one thing a translation could get wrong and so the one
|
|
101
|
+
* thing that is written out rather than derived.
|
|
102
|
+
*
|
|
103
|
+
* `SID_NAME` is the sid alone: `fleet.ts` asks it whether a LIVE session's id is one the
|
|
104
|
+
* wrapper would ever file, which is the difference between "no frame drawn yet" and "no frame
|
|
105
|
+
* will ever help" — two states that look identical on a row.
|
|
106
|
+
*/
|
|
107
|
+
export const SID_NAME = new RegExp(`^${SID_GLOB}$`);
|
|
108
|
+
export const SNAPSHOT_NAME = new RegExp(`^${SID_GLOB}\\.json$`);
|
|
57
109
|
/** Single-quotes a string for POSIX sh. */
|
|
58
110
|
function shQuote(s) {
|
|
59
111
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
@@ -92,16 +144,15 @@ case "$payload" in
|
|
|
92
144
|
esac
|
|
93
145
|
;;
|
|
94
146
|
esac
|
|
95
|
-
#
|
|
147
|
+
# Refuse anything that is not a session id — this value becomes a filename, and it is the
|
|
148
|
+
# same rule the sweep below deletes by: what this line declines to write, that one cannot
|
|
149
|
+
# unlink, and the reverse. An empty sid matches nothing here, so it stays empty.
|
|
96
150
|
case "$sid" in
|
|
97
|
-
|
|
151
|
+
${SID_GLOB}) ;;
|
|
152
|
+
*) sid='' ;;
|
|
98
153
|
esac
|
|
99
154
|
# refuse an ambiguous payload outright
|
|
100
155
|
[ "$payload_has_two_ids" = 1 ] && sid=''
|
|
101
|
-
if [ -n "$sid" ]; then
|
|
102
|
-
len=\${#sid}
|
|
103
|
-
if [ "$len" -lt 8 ] || [ "$len" -gt 64 ]; then sid=''; fi
|
|
104
|
-
fi
|
|
105
156
|
|
|
106
157
|
# --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
|
|
107
158
|
if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
|
|
@@ -133,6 +184,53 @@ fi
|
|
|
133
184
|
# frame. (A directory where the stamp itself keeps failing does pay one \`touch\` per frame,
|
|
134
185
|
# forever — a fork, not a directory walk, and it means nothing can be written there anyway.)
|
|
135
186
|
#
|
|
187
|
+
# And DETACHED, because amortized is an average and the average was never the problem: on an
|
|
188
|
+
# install that has never pruned, the one frame that sweeps pays for the entire backlog at
|
|
189
|
+
# once — a directory walk plus ten thousand unlinks on 20 000 snapshots, measured at 0.5 s in
|
|
190
|
+
# the report and at 0.6-0.9 s by \`test/sweep-perf.test.ts\`, in front of the status line (#8).
|
|
191
|
+
# Bounding the work per sweep instead would only spread that cost, at one bounded batch an
|
|
192
|
+
# hour, over weeks of frames that each still stop to walk the
|
|
193
|
+
# same directory; the frame has no business waiting for any of it. So the sweep is handed to a
|
|
194
|
+
# child and the frame goes on to the chain: the frame's cost becomes one fork, whatever the
|
|
195
|
+
# directory holds, and NOTHING on the nominal path — a frame with no sweep due — changes at all.
|
|
196
|
+
# What that costs, all of it on the sweep's side of the fork:
|
|
197
|
+
# • the child outlives the frame. It is orphaned when the shell exits and reaped by init;
|
|
198
|
+
# nothing waits for it, so no zombie can accumulate in the TUI's process tree.
|
|
199
|
+
# • if the session dies mid-sweep, the sweep normally finishes on its own: it holds no state
|
|
200
|
+
# but the marker, already stamped, and an orphan is not interrupted by the death of its
|
|
201
|
+
# parent. It does NOT go away with a Ctrl-C either — POSIX has the shell set SIGINT and
|
|
202
|
+
# SIGQUIT to ignored in an asynchronous list where job control is off, which is here. Only
|
|
203
|
+
# a signal aimed at the process group (a SIGHUP or a SIGTERM from whatever supervises the
|
|
204
|
+
# TUI) takes it, and then what is left is what the next hour's sweep will find, which is
|
|
205
|
+
# where it was heading anyway.
|
|
206
|
+
# • the redirections are not hygiene, they are the point. A child that inherits the frame's
|
|
207
|
+
# stdout puts whatever it prints INTO the status line, and holds the pipe open after this
|
|
208
|
+
# shell has exited — the reader waits on EOF, so the frame would still block, having only
|
|
209
|
+
# moved where. \`</dev/null\` is the belt to those braces: POSIX already hands an
|
|
210
|
+
# asynchronous list \`/dev/null\` for stdin wherever job control is off — every shell that
|
|
211
|
+
# will ever run this — and stdin was drained by \`\$(cat)\` at the top anyway.
|
|
212
|
+
# • two frames that reach the marker check together both sweep, as they always could. The
|
|
213
|
+
# window is not the \`touch\` — it is the \`find\` that reads the marker's age, a whole
|
|
214
|
+
# process, which is why two frames drawn simultaneously fork two sweeps almost every time.
|
|
215
|
+
# Detaching improves that case rather than widening it: what used to be two frozen frames
|
|
216
|
+
# is now two detached walks. What matters is that a frame drawn AFTER a sweep has started
|
|
217
|
+
# sees a stamped marker and starts nothing, which is the ordinary case and the reason the
|
|
218
|
+
# stamping stayed in the frame rather than moving into the child. Across HOURS they can
|
|
219
|
+
# overlap — a sweep slower than the window is joined by the
|
|
220
|
+
# next one — and that is harmless: \`rm -f\` on a name another sweep already unlinked is
|
|
221
|
+
# not an error, and the marker is the only state either of them writes.
|
|
222
|
+
# • a \`find\` that HANGS (a stale network mount, a directory that never answers) is no longer
|
|
223
|
+
# one frozen frame; it is one orphaned process per hour that nothing here reaps. The trade
|
|
224
|
+
# is deliberate — a frozen frame breaks RULE 1, an idle process does not — but it is
|
|
225
|
+
# unbounded over time in a way the blocking shape was not.
|
|
226
|
+
# • the chain now runs BESIDE the sweep instead of after it, so anything reading this same
|
|
227
|
+
# directory can watch a file vanish under it mid-frame. Nothing here breaks on that — the
|
|
228
|
+
# display is untouched and no data is lost — but it is not free either: \`snapshots.ts\`
|
|
229
|
+
# counts a file that disappears between its \`readdir\` and its \`readFile\` as UNREADABLE,
|
|
230
|
+
# and \`list\`/\`serve\` report that as "the schema may have moved". Same window on the
|
|
231
|
+
# blocking shape (a sweep and a reader have always been able to overlap), so this is not
|
|
232
|
+
# a regression, and it is the READER's to fix: #17.
|
|
233
|
+
#
|
|
136
234
|
# Two known holes, both of them the safe way round, and both inherited from the fleet script
|
|
137
235
|
# this transposes:
|
|
138
236
|
# • a marker dated in the FUTURE (clock skew, a restored backup, a network mount) is never
|
|
@@ -171,13 +269,9 @@ if [ -d "$TARMAC_DIR" ]; then
|
|
|
171
269
|
# keeps a directory or a symlink wearing a session id's name out of it.
|
|
172
270
|
#
|
|
173
271
|
# The glob is the sid SHAPE, not \`*.json\`, and that is the same rule \`reap.ts\` states
|
|
174
|
-
# for the temp files: only what we wrote
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
# fleet directory). The wrapper accepts wider ids than that because it refuses to guess
|
|
178
|
-
# what an id may look like; a sid outside this shape is therefore written and never
|
|
179
|
-
# pruned, which is the direction this trade has to fail in.
|
|
180
|
-
find "$TARMAC_DIR"/. ! -name . -prune -name '????????-????-????-????-????????????.json' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + 2>/dev/null
|
|
272
|
+
# for the temp files: only what we wrote — see SNAPSHOT_GLOB, which the legacy purge in
|
|
273
|
+
# \`install.ts\` reads from the same constant.
|
|
274
|
+
find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + >/dev/null 2>&1 </dev/null &
|
|
181
275
|
fi
|
|
182
276
|
fi
|
|
183
277
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrrr/tarmac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
],
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"author": "Adrien Leboeuf",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/adrrr/tarmac.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/adrrr/tarmac#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/adrrr/tarmac/issues"
|
|
24
|
+
},
|
|
17
25
|
"type": "module",
|
|
18
26
|
"engines": {
|
|
19
27
|
"node": ">=20"
|