@joystack/cli 0.0.0-stage → 0.1.43
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/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// The dev recording store — where a session you just played is kept.
|
|
2
|
+
//
|
|
3
|
+
// In dev the room server, the game folder and the person who played are all on
|
|
4
|
+
// one machine, so a recording has no reason to make a round trip: the room
|
|
5
|
+
// posts each closed segment to the dev server (see sessionIngestUrlFor in
|
|
6
|
+
// shared/ui/room/session-ingest.js) and it lands here, under the game's
|
|
7
|
+
// `.joystack/replays/<stream>/`. Watching a session back is then just reading
|
|
8
|
+
// files that are already on disk — which is what makes `?replay=<id>` a URL
|
|
9
|
+
// you paste rather than a fetch with credentials behind it.
|
|
10
|
+
//
|
|
11
|
+
// A PUBLISHED game is the other case entirely: its room is a Worker, the only
|
|
12
|
+
// durable place is the platform, and segments go to /sessions/ingest (R2 +
|
|
13
|
+
// D1). Nothing here is involved in that lane.
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { BUILD_ID_RE } from './build-id.mjs';
|
|
18
|
+
import { canonicalFormat, stateDir } from './legacy-names.mjs';
|
|
19
|
+
|
|
20
|
+
// The stream id is minted room-side from a timestamp and a counter, and it is
|
|
21
|
+
// interpolated into a filesystem path — so its charset is pinned here rather
|
|
22
|
+
// than trusted. Anything with a separator, a dot segment, or nothing in it is
|
|
23
|
+
// refused outright instead of being sanitized into something plausible.
|
|
24
|
+
const STREAM_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
25
|
+
|
|
26
|
+
// Where a dev room posts its closed segments. The engine derives the same path
|
|
27
|
+
// in shared/ui/room/session-ingest.js (DEV_SEGMENT_PATH there) — the two ends
|
|
28
|
+
// of one wire, kept spelled out in both so neither can drift silently.
|
|
29
|
+
export const DEV_SEGMENT_PATH = '/__joystack/segments';
|
|
30
|
+
|
|
31
|
+
// Where the dev page asks what recordings exist, deletes one, or pins one
|
|
32
|
+
// against the prune — the read half of the same lane. Spelled out in the
|
|
33
|
+
// engine's Sessions tool too (shared/ui/sessions/sessions.js), for the same
|
|
34
|
+
// reason as above: two ends of one wire, neither able to drift silently.
|
|
35
|
+
export const DEV_REPLAYS_PATH = '/__joystack/replays';
|
|
36
|
+
|
|
37
|
+
export function replaysRoot(projectDir) {
|
|
38
|
+
return join(stateDir(projectDir), 'replays');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function streamDir(projectDir, stream) {
|
|
42
|
+
if (!STREAM_RE.test(stream ?? '')) throw new Error(`invalid stream id: ${JSON.stringify(stream)}`);
|
|
43
|
+
return join(replaysRoot(projectDir), stream);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Persist one closed segment. The address is (stream, seq), the same pair the
|
|
48
|
+
* production index uses, so a room's bounded retry rewrites the same file
|
|
49
|
+
* rather than appending a duplicate.
|
|
50
|
+
* @param {string} projectDir - the game folder
|
|
51
|
+
* @param {{ slug?: string, room?: string, segment: object }} body - the room's POST body
|
|
52
|
+
* @param {{ maxTrackBytes?: number, stamp?: () => object }} [opts] - the per-session
|
|
53
|
+
* track limit (tests), and the build stamp (see `stampBuild`)
|
|
54
|
+
* @returns {{ stored: true, stream: string, seq: number }}
|
|
55
|
+
*/
|
|
56
|
+
export function writeSegment(projectDir, body, opts = {}) {
|
|
57
|
+
const segment = body?.segment;
|
|
58
|
+
const header = segment?.header;
|
|
59
|
+
if (!header || typeof header !== 'object') throw new Error('not a segment: no header');
|
|
60
|
+
const format = canonicalFormat(header.format);
|
|
61
|
+
if (format === TRACK_SEGMENT_FORMAT) return writeTrackSegment(projectDir, segment, opts);
|
|
62
|
+
if (format !== 'joystack-replay-seg/1') {
|
|
63
|
+
throw new Error(`not a segment: format ${JSON.stringify(header.format)}`);
|
|
64
|
+
}
|
|
65
|
+
// Bounded above as well as below: `seq` names the file, and while no number's
|
|
66
|
+
// string form can contain a separator, an unbounded one lets a sender pick
|
|
67
|
+
// arbitrary names in the stream directory (`1e21`, `9007199254740992`) that
|
|
68
|
+
// the reader's lexical sort then misorders. A stream that reaches a million
|
|
69
|
+
// segments is a bug, not a session.
|
|
70
|
+
//
|
|
71
|
+
// The bound is EXCLUSIVE because the filename is six digits: an accepted
|
|
72
|
+
// 1000000 would write `1000000.json`, which SEGMENT_FILE_RE never matches, so
|
|
73
|
+
// the store would answer `stored: true` (stopping the room's retries) to a
|
|
74
|
+
// segment no reader can see and no retention cap can ever sweep.
|
|
75
|
+
if (!Number.isInteger(header.seq) || header.seq < 0 || header.seq >= 1e6) {
|
|
76
|
+
throw new Error('not a segment: bad seq');
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(segment.events)) throw new Error('not a segment: no events');
|
|
79
|
+
const dir = streamDir(projectDir, header.stream);
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
writeFileSync(join(dir, segmentFileName(header.seq)), JSON.stringify(segment));
|
|
82
|
+
updateIndex(dir, segment);
|
|
83
|
+
if (opts.stamp) stampBuild(dir, opts.stamp);
|
|
84
|
+
return { stored: true, stream: header.stream, seq: header.seq };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── the per-stream index ────────────────────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// The Sessions panel asks each recording when it was played, how long it ran
|
|
90
|
+
// and how many inputs it carries. Every one of those lives in the segments, so
|
|
91
|
+
// answering from the segments costs the whole recording — a five-minute session
|
|
92
|
+
// is megabytes, and the panel asks about twenty of them at once, on a phone.
|
|
93
|
+
// So each segment write also folds its own contribution into a small file
|
|
94
|
+
// beside the segments, and reading a stream's headline facts is then one small
|
|
95
|
+
// read instead of a full parse.
|
|
96
|
+
//
|
|
97
|
+
// The counts are held PER SEQ rather than as a running total because a segment
|
|
98
|
+
// can legitimately arrive twice: a room's flush retries are idempotent by
|
|
99
|
+
// (stream, seq) and rewrite the same file, so a running total would count that
|
|
100
|
+
// segment's inputs again every time the network hiccuped.
|
|
101
|
+
//
|
|
102
|
+
// Keyed as an OBJECT rather than indexed into an array, because `seq` comes off
|
|
103
|
+
// the wire from an endpoint nothing authenticates. An array indexed at seq
|
|
104
|
+
// 999999 serialises 999999 holes as `null` — so a 160-byte POST would write
|
|
105
|
+
// megabytes here, none of it visible to the retention caps, and every later
|
|
106
|
+
// listing would reduce over a million elements inside the dev server's one
|
|
107
|
+
// thread. An object costs one entry per segment that actually exists.
|
|
108
|
+
//
|
|
109
|
+
// The index is derived, never authoritative: it is rebuilt from the segments
|
|
110
|
+
// whenever it is missing or was written by an older shape, so deleting it is a
|
|
111
|
+
// safe thing for anyone to do and an older recording gains one on first read.
|
|
112
|
+
const INDEX_FILE = 'index.json';
|
|
113
|
+
const INDEX_VERSION = 2;
|
|
114
|
+
|
|
115
|
+
// Segments are the six-digit files; everything else in the directory (this
|
|
116
|
+
// index, the keep marker) is bookkeeping and is not a segment.
|
|
117
|
+
const SEGMENT_FILE_RE = /^\d{6}\.json$/;
|
|
118
|
+
|
|
119
|
+
// A TRACK segment (project note ofta45) is the cost half of a session: the
|
|
120
|
+
// same stream, written by a different producer — in multiplayer, by each
|
|
121
|
+
// player's browser rather than by the room. It lands FLAT in the stream's
|
|
122
|
+
// directory rather than in a subfolder, so every byte of it is visible to the
|
|
123
|
+
// retention caps and one `rm -rf` of the directory still takes the whole
|
|
124
|
+
// session with it.
|
|
125
|
+
//
|
|
126
|
+
// `<kind>.<producer>.<seq>.json`, with `.` as the separator precisely because
|
|
127
|
+
// neither field may contain one: the name parses back into its three parts
|
|
128
|
+
// with no ambiguity, and no value either field can hold reaches another
|
|
129
|
+
// directory.
|
|
130
|
+
const TRACK_SEGMENT_FORMAT = 'joystack-track-seg/1';
|
|
131
|
+
const TRACK_KIND_RE = /^[a-z0-9][a-z0-9-]{0,15}$/;
|
|
132
|
+
// Long enough for a whole connection id (a UUID is 36 characters): the
|
|
133
|
+
// producer is what joins a browser's frames to the recording's own record of
|
|
134
|
+
// that player, so a name cut short of the id joins nothing.
|
|
135
|
+
const TRACK_PRODUCER_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
136
|
+
const TRACK_FILE_RE = /^([a-z0-9][a-z0-9-]{0,15})\.([a-z0-9][a-z0-9-]{0,63})\.(\d{6})\.json$/;
|
|
137
|
+
|
|
138
|
+
function segmentFileName(seq) {
|
|
139
|
+
// Zero-padded so a directory listing is already in stream order — the same
|
|
140
|
+
// reason the R2 key is padded.
|
|
141
|
+
return `${String(seq).padStart(6, '0')}.json`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Persist one closed TRACK segment — the cost half of a session, written by
|
|
146
|
+
* the browser that measured it. The address is (stream, kind, producer, seq),
|
|
147
|
+
* so a browser's bounded retry rewrites the same file, and two players in one
|
|
148
|
+
* room never collide.
|
|
149
|
+
*
|
|
150
|
+
* A track is additive: the recording is complete without it, and a reader that
|
|
151
|
+
* finds none says so rather than failing. So nothing here touches the
|
|
152
|
+
* recording's index — a track segment is not a recording segment, and counting
|
|
153
|
+
* it as one would misreport how much of the session is on disk.
|
|
154
|
+
*/
|
|
155
|
+
// What one session's cost track may take up, and what one segment of it may
|
|
156
|
+
// be. A real track runs about 1 MB a minute, so the per-session ceiling is
|
|
157
|
+
// several hours of play: long enough that no session a person plays reaches
|
|
158
|
+
// it. It exists because the endpoint that accepts a track segment is the dev
|
|
159
|
+
// server's, open to anything that can reach it (a second player's browser is
|
|
160
|
+
// the intended case), and the bytes land in a folder the retention sweep
|
|
161
|
+
// measures: without a ceiling, one sender could push a session past the
|
|
162
|
+
// folder's byte cap (MAX_RECORDING_BYTES, several times this) and make the
|
|
163
|
+
// sweep delete OTHER sessions to get back under it.
|
|
164
|
+
export const MAX_TRACK_BYTES_PER_STREAM = 512 * 1024 * 1024;
|
|
165
|
+
export const MAX_TRACK_SEGMENT_BYTES = 2 * 1024 * 1024;
|
|
166
|
+
|
|
167
|
+
// What the track already occupies in this stream's folder.
|
|
168
|
+
function trackBytes(dir) {
|
|
169
|
+
let bytes = 0;
|
|
170
|
+
let names;
|
|
171
|
+
try { names = readdirSync(dir); } catch { return 0; }
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
if (!TRACK_FILE_RE.test(name)) continue;
|
|
174
|
+
try { bytes += statSync(join(dir, name)).size; } catch { /* vanished mid-scan */ }
|
|
175
|
+
}
|
|
176
|
+
return bytes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeTrackSegment(projectDir, segment, { maxTrackBytes = MAX_TRACK_BYTES_PER_STREAM } = {}) {
|
|
180
|
+
const h = segment.header;
|
|
181
|
+
if (!TRACK_KIND_RE.test(h.kind ?? '')) throw new Error(`not a track segment: bad kind ${JSON.stringify(h.kind)}`);
|
|
182
|
+
if (!TRACK_PRODUCER_RE.test(h.producer ?? '')) {
|
|
183
|
+
throw new Error(`not a track segment: bad producer ${JSON.stringify(h.producer)}`);
|
|
184
|
+
}
|
|
185
|
+
// Same bound and same reason as a recording segment's seq: the number names
|
|
186
|
+
// the file, and an unbounded one lets a sender pick names no reader matches.
|
|
187
|
+
if (!Number.isInteger(h.seq) || h.seq < 0 || h.seq >= 1e6) throw new Error('not a track segment: bad seq');
|
|
188
|
+
const dir = streamDir(projectDir, h.stream);
|
|
189
|
+
const body = JSON.stringify(segment);
|
|
190
|
+
const name = trackFileName(h.kind, h.producer, h.seq);
|
|
191
|
+
// Refused rather than thrown: a track is additive, so a browser that has
|
|
192
|
+
// written more than its share is told no and the session carries on. The
|
|
193
|
+
// ceilings are decided above; what matters here is that neither one is a
|
|
194
|
+
// reason to fail a POST that also carried a recording.
|
|
195
|
+
if (body.length > MAX_TRACK_SEGMENT_BYTES) {
|
|
196
|
+
return { stored: false, reason: 'too-large', stream: h.stream, seq: h.seq, kind: h.kind, producer: h.producer };
|
|
197
|
+
}
|
|
198
|
+
mkdirSync(dir, { recursive: true });
|
|
199
|
+
// The file being rewritten is a retry of a segment already counted, so its
|
|
200
|
+
// current size comes off the total rather than counting twice.
|
|
201
|
+
let already = trackBytes(dir);
|
|
202
|
+
try { already -= statSync(join(dir, name)).size; } catch { /* first write of this seq */ }
|
|
203
|
+
if (already + body.length > maxTrackBytes) {
|
|
204
|
+
noteTrackCut(dir, h, maxTrackBytes);
|
|
205
|
+
return { stored: false, reason: 'budget', stream: h.stream, seq: h.seq, kind: h.kind, producer: h.producer };
|
|
206
|
+
}
|
|
207
|
+
writeFileSync(join(dir, name), body);
|
|
208
|
+
return { stored: true, stream: h.stream, seq: h.seq, kind: h.kind, producer: h.producer };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Where the per-session limit cut each browser's track, kept beside the
|
|
212
|
+
// segments. A refused stretch is one nobody will ever read, and without this a
|
|
213
|
+
// reader reports it exactly as it reports measuring switched off — a false
|
|
214
|
+
// reason. One entry per (kind, producer), holding the earliest tick refused:
|
|
215
|
+
// segments can arrive out of order (a beacon, a retried post), and the cut is
|
|
216
|
+
// where refusing began. At most MAX_TRACK_CUTS entries, because the producer
|
|
217
|
+
// name is the sender's to choose and a stream already over its limit must not
|
|
218
|
+
// grow a second file without bound.
|
|
219
|
+
const TRACK_LIMIT_FILE = 'track-limit.json';
|
|
220
|
+
const MAX_TRACK_CUTS = 16;
|
|
221
|
+
|
|
222
|
+
function noteTrackCut(dir, header, limitBytes) {
|
|
223
|
+
const fromTick = Number.isFinite(header.startTick) ? header.startTick : null;
|
|
224
|
+
const file = join(dir, TRACK_LIMIT_FILE);
|
|
225
|
+
let record = null;
|
|
226
|
+
try { record = JSON.parse(readFileSync(file, 'utf8')); } catch { /* the first cut */ }
|
|
227
|
+
const cuts = Array.isArray(record?.cuts) ? record.cuts : [];
|
|
228
|
+
const at = cuts.find((c) => c?.kind === header.kind && c?.producer === header.producer);
|
|
229
|
+
if (at) {
|
|
230
|
+
if (fromTick === null || (at.fromTick !== null && at.fromTick <= fromTick)) return;
|
|
231
|
+
at.fromTick = fromTick;
|
|
232
|
+
} else {
|
|
233
|
+
if (cuts.length >= MAX_TRACK_CUTS) return;
|
|
234
|
+
cuts.push({ kind: header.kind, producer: header.producer, fromTick });
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
writeFileSync(file, JSON.stringify({ limitBytes, cuts }));
|
|
238
|
+
} catch {
|
|
239
|
+
// The note is for the reader; failing to write it costs the explanation,
|
|
240
|
+
// never the refusal it explains.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Where the per-session limit cut this stream's track, or null when it never
|
|
246
|
+
* did.
|
|
247
|
+
* @returns {{ limitBytes: number, cuts: { kind: string, producer: string, fromTick: number|null }[] }|null}
|
|
248
|
+
*/
|
|
249
|
+
export function readTrackLimits(projectDir, stream) {
|
|
250
|
+
let record;
|
|
251
|
+
try {
|
|
252
|
+
record = JSON.parse(readFileSync(join(streamDir(projectDir, stream), TRACK_LIMIT_FILE), 'utf8'));
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const cuts = (Array.isArray(record?.cuts) ? record.cuts : [])
|
|
257
|
+
.filter((c) => c && typeof c === 'object' && typeof c.producer === 'string' && typeof c.kind === 'string')
|
|
258
|
+
.map((c) => ({ kind: c.kind, producer: c.producer, fromTick: Number.isFinite(c.fromTick) ? c.fromTick : null }));
|
|
259
|
+
if (!cuts.length) return null;
|
|
260
|
+
return { limitBytes: Number.isFinite(record.limitBytes) ? record.limitBytes : null, cuts };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function trackFileName(kind, producer, seq) {
|
|
264
|
+
return `${kind}.${producer}.${String(seq).padStart(6, '0')}.json`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Every track segment of one kind in a stream, in producer then seq order.
|
|
269
|
+
*
|
|
270
|
+
* Empty when the session carries no track: a recording made with measuring
|
|
271
|
+
* switched off, or by an engine from before tracks existed. That is a
|
|
272
|
+
* different answer from "no such recording", and the caller reports it as a
|
|
273
|
+
* session whose cost was not recorded.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} projectDir - the game folder
|
|
276
|
+
* @param {string} stream - the recording's stream id
|
|
277
|
+
* @param {string} [kind] - which track, 'perf' by default
|
|
278
|
+
*/
|
|
279
|
+
export function readTrackSegments(projectDir, stream, kind = 'perf') {
|
|
280
|
+
const dir = streamDir(projectDir, stream);
|
|
281
|
+
let names;
|
|
282
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const name of names.sort()) {
|
|
285
|
+
const m = TRACK_FILE_RE.exec(name);
|
|
286
|
+
if (!m || m[1] !== kind) continue;
|
|
287
|
+
try {
|
|
288
|
+
out.push(JSON.parse(readFileSync(join(dir, name), 'utf8')));
|
|
289
|
+
} catch {
|
|
290
|
+
// A half-written file — the dev server was killed mid-POST. The same
|
|
291
|
+
// tolerance the recording's own rebuild has: one lost segment, not a
|
|
292
|
+
// lost session.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Which kinds of track a stream carries, sorted. */
|
|
299
|
+
function trackKinds(names) {
|
|
300
|
+
const kinds = new Set();
|
|
301
|
+
for (const name of names) {
|
|
302
|
+
const m = TRACK_FILE_RE.exec(name);
|
|
303
|
+
if (m) kinds.add(m[1]);
|
|
304
|
+
}
|
|
305
|
+
return [...kinds].sort();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function countInputs(segment) {
|
|
309
|
+
// A recorded INPUT is a `w` — an accepted wire send, the player doing
|
|
310
|
+
// something. Spawns, roster changes and checksums are the world's own
|
|
311
|
+
// bookkeeping and are not things anybody did.
|
|
312
|
+
return (segment?.events ?? []).reduce((n, e) => n + (e?.k === 'w' ? 1 : 0), 0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function foldSegment(index, segment) {
|
|
316
|
+
const h = segment.header;
|
|
317
|
+
const seq = h.seq;
|
|
318
|
+
// Anchored on the SEQ alone. `startedAt` is null in a runtime with no Date
|
|
319
|
+
// (recorder.js writes it conditionally), and keying on "we have no stamp yet"
|
|
320
|
+
// would make every later segment re-anchor — so `startTick` would track the
|
|
321
|
+
// last segment written and the duration would collapse to one segment's span,
|
|
322
|
+
// or go negative.
|
|
323
|
+
if (seq < (index.firstSeq ?? Infinity)) {
|
|
324
|
+
index.firstSeq = seq;
|
|
325
|
+
index.startedAt = h.startedAt ?? null;
|
|
326
|
+
index.startTick = h.startTick ?? 0;
|
|
327
|
+
}
|
|
328
|
+
if (h.tickRate) index.tickRate = h.tickRate;
|
|
329
|
+
// Whether this stream can be ENTERED. A replay starts at a keyframe, so a
|
|
330
|
+
// stream that has not cut one yet is on disk and not yet watchable — a
|
|
331
|
+
// not-yet, not a corruption, and a different answer from "no such recording".
|
|
332
|
+
if (h.keyframe) index.hasKeyframe = true;
|
|
333
|
+
const end = segment.endTick ?? h.startTick ?? 0;
|
|
334
|
+
index.endTick = Math.max(index.endTick ?? -Infinity, end);
|
|
335
|
+
index.inputsBySeq[String(seq)] = countInputs(segment);
|
|
336
|
+
return index;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function emptyIndex(stream) {
|
|
340
|
+
return { v: INDEX_VERSION, stream, startedAt: null, firstSeq: null, startTick: 0, endTick: null,
|
|
341
|
+
tickRate: null, hasKeyframe: false, inputsBySeq: {} };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Read every segment in a stream and fold them into a fresh index. The slow
|
|
345
|
+
// path, taken once per stream: on the first write, and on the first read of a
|
|
346
|
+
// recording made before this file existed.
|
|
347
|
+
function rebuildIndex(dir, stream) {
|
|
348
|
+
const index = emptyIndex(stream);
|
|
349
|
+
for (const name of readdirSync(dir).sort()) {
|
|
350
|
+
if (!SEGMENT_FILE_RE.test(name)) continue;
|
|
351
|
+
try {
|
|
352
|
+
const seg = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
353
|
+
if (canonicalFormat(seg?.header?.format) === 'joystack-replay-seg/1') foldSegment(index, seg);
|
|
354
|
+
} catch {
|
|
355
|
+
// A half-written file (the dev server was killed mid-POST) must not cost
|
|
356
|
+
// the rest of the session — the same tolerance stitching has.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return index;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function readIndex(dir) {
|
|
363
|
+
try {
|
|
364
|
+
const index = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'));
|
|
365
|
+
if (index?.v === INDEX_VERSION && index.inputsBySeq && typeof index.inputsBySeq === 'object'
|
|
366
|
+
&& !Array.isArray(index.inputsBySeq)) return index;
|
|
367
|
+
} catch {
|
|
368
|
+
// Missing, half-written, or an older shape — all one case: rebuild.
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function writeIndex(dir, index) {
|
|
374
|
+
try {
|
|
375
|
+
writeFileSync(join(dir, INDEX_FILE), JSON.stringify(index));
|
|
376
|
+
} catch {
|
|
377
|
+
// The index is a cache. A game folder that has gone read-only must not
|
|
378
|
+
// turn a recorded session into a failed POST.
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function updateIndex(dir, segment) {
|
|
383
|
+
// Absent means rebuild rather than start over: an index deleted mid-stream
|
|
384
|
+
// would otherwise silently lose every segment written before it went.
|
|
385
|
+
const existing = readIndex(dir);
|
|
386
|
+
writeIndex(dir, existing ? foldSegment(existing, segment) : rebuildIndex(dir, segment.header.stream));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── the read side ───────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
// Presence is the whole marker: a recording under triage must survive the
|
|
392
|
+
// prune, and the flag lives in its own FILE rather than in the index because
|
|
393
|
+
// the index is rewritten on every segment write — a keep toggled while that
|
|
394
|
+
// stream is still recording would lose the race. Deleting the directory still
|
|
395
|
+
// takes the marker with it.
|
|
396
|
+
const KEEP_FILE = 'keep';
|
|
397
|
+
|
|
398
|
+
// ── the build stamp ─────────────────────────────────────────────────────────
|
|
399
|
+
//
|
|
400
|
+
// Which code recorded the session: the game's build id (build-id.mjs), the
|
|
401
|
+
// commit its files came from and whether they differed from it, and the engine
|
|
402
|
+
// version the dev server was serving. A recording's own header cannot say
|
|
403
|
+
// this. The page and the room know neither the game's files nor its git, and
|
|
404
|
+
// a room served straight off `joystack dev` carries a placeholder where a
|
|
405
|
+
// published build carries its engine version.
|
|
406
|
+
//
|
|
407
|
+
// Taken once per session, when its first recording segment lands — which is
|
|
408
|
+
// within seconds of the page loading the code it records. Never taken for a
|
|
409
|
+
// track segment: a browser's cost track is not the recording, and a stream
|
|
410
|
+
// holding only tracks has no code to vouch for. A stamp that fails (no git, a
|
|
411
|
+
// file vanished mid-walk) is simply absent: the recording it describes is
|
|
412
|
+
// worth more than the description.
|
|
413
|
+
const BUILD_FILE = 'build.json';
|
|
414
|
+
|
|
415
|
+
// Sessions this process has already tried to stamp. A stamp that failed is
|
|
416
|
+
// not tried again on every later segment of the same session.
|
|
417
|
+
const stampTried = new Set();
|
|
418
|
+
|
|
419
|
+
function stampBuild(dir, take) {
|
|
420
|
+
const file = join(dir, BUILD_FILE);
|
|
421
|
+
if (stampTried.has(file) || existsSync(file)) return;
|
|
422
|
+
stampTried.add(file);
|
|
423
|
+
try {
|
|
424
|
+
writeFileSync(file, JSON.stringify({ ...take(), stampedAt: Date.now() }));
|
|
425
|
+
} catch {
|
|
426
|
+
// See above: the recording is stored either way.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const COMMIT_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
431
|
+
const ENGINE_RE = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/;
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* A session's build stamp, or null when it has none. The file sits in a folder
|
|
435
|
+
* anyone can copy a recording into, so each field is kept only when it is the
|
|
436
|
+
* shape it claims and is otherwise null — never sanitized into something that
|
|
437
|
+
* looks right.
|
|
438
|
+
*
|
|
439
|
+
* @returns {{ id: string|null, files: number|null, git: { commit: string, dirty: boolean|null }|null,
|
|
440
|
+
* engine: string|null, stampedAt: number|null } | null}
|
|
441
|
+
*/
|
|
442
|
+
export function readBuildStamp(projectDir, stream) {
|
|
443
|
+
let raw;
|
|
444
|
+
try { raw = JSON.parse(readFileSync(join(streamDir(projectDir, stream), BUILD_FILE), 'utf8')); } catch { return null; }
|
|
445
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
446
|
+
const is = (v, re) => typeof v === 'string' && re.test(v);
|
|
447
|
+
const git = raw.git && typeof raw.git === 'object' && is(raw.git.commit, COMMIT_RE)
|
|
448
|
+
? { commit: raw.git.commit, dirty: typeof raw.git.dirty === 'boolean' ? raw.git.dirty : null }
|
|
449
|
+
: null;
|
|
450
|
+
return {
|
|
451
|
+
id: is(raw.id, BUILD_ID_RE) ? raw.id : null,
|
|
452
|
+
files: Number.isSafeInteger(raw.files) && raw.files >= 0 ? raw.files : null,
|
|
453
|
+
git,
|
|
454
|
+
engine: typeof raw.engine === 'string' && ENGINE_RE.test(raw.engine) ? raw.engine : null,
|
|
455
|
+
stampedAt: Number.isFinite(raw.stampedAt) ? raw.stampedAt : null,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Every recording this game has on disk, newest first.
|
|
461
|
+
*
|
|
462
|
+
* Each entry carries what the Sessions panel shows: when it was played, how
|
|
463
|
+
* long it ran, how many inputs it holds, what it weighs, and whether it is
|
|
464
|
+
* pinned against the prune. Cost is one small read and a stat per file — never
|
|
465
|
+
* a parse of the recordings themselves (see the index above).
|
|
466
|
+
*
|
|
467
|
+
* @param {string} projectDir - the game folder
|
|
468
|
+
* @returns {Array<{ stream: string, startedAt: number|null, durationMs: number|null,
|
|
469
|
+
* inputs: number, segments: number, bytes: number, kept: boolean }>}
|
|
470
|
+
*/
|
|
471
|
+
export function listRecordings(projectDir) {
|
|
472
|
+
const root = replaysRoot(projectDir);
|
|
473
|
+
if (!existsSync(root)) return [];
|
|
474
|
+
const out = [];
|
|
475
|
+
for (const stream of readdirSync(root)) {
|
|
476
|
+
// Only names this store could have minted. A creator copying a teammate's
|
|
477
|
+
// recording in calls the directory whatever they like, and listing one made
|
|
478
|
+
// `deleteRecording` throw on a name `streamDir` refuses — inside a prune
|
|
479
|
+
// that runs before `joystack dev` binds a port, so the whole stack failed to
|
|
480
|
+
// start over a directory name. The two functions agree on the charset here.
|
|
481
|
+
if (!STREAM_RE.test(stream)) continue;
|
|
482
|
+
const dir = join(root, stream);
|
|
483
|
+
let names;
|
|
484
|
+
try {
|
|
485
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
486
|
+
names = readdirSync(dir);
|
|
487
|
+
} catch {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const segments = names.filter((n) => SEGMENT_FILE_RE.test(n));
|
|
491
|
+
const tracks = trackKinds(names);
|
|
492
|
+
// A folder with neither is not this store's.
|
|
493
|
+
if (!segments.length && !tracks.length) continue;
|
|
494
|
+
// A folder holding ONLY tracks is listed too, with nothing to watch. It is
|
|
495
|
+
// either a session whose recording never landed or bytes somebody posted
|
|
496
|
+
// at a stream id no recording ever used — and both are the store's to
|
|
497
|
+
// account for: the retention caps bound a folder, and anything left out of
|
|
498
|
+
// the inventory is something the prune can never reclaim however much of
|
|
499
|
+
// it there is. A track is written by a browser over the same open dev
|
|
500
|
+
// endpoint the recording uses, so "however much" is not hypothetical.
|
|
501
|
+
let index = segments.length ? readIndex(dir) : (readIndex(dir) ?? emptyIndex(stream));
|
|
502
|
+
if (!index) {
|
|
503
|
+
index = rebuildIndex(dir, stream);
|
|
504
|
+
writeIndex(dir, index);
|
|
505
|
+
}
|
|
506
|
+
// Every file, not only the segments: the caps exist to bound a folder, and
|
|
507
|
+
// anything left out of the measurement is something the prune can never
|
|
508
|
+
// reclaim however much of it there is.
|
|
509
|
+
let bytes = 0;
|
|
510
|
+
for (const name of names) {
|
|
511
|
+
try { bytes += statSync(join(dir, name)).size; } catch { /* the file vanished mid-scan */ }
|
|
512
|
+
}
|
|
513
|
+
const ticks = index.endTick == null ? null : index.endTick - (index.startTick ?? 0);
|
|
514
|
+
out.push({
|
|
515
|
+
stream,
|
|
516
|
+
startedAt: index.startedAt ?? null,
|
|
517
|
+
// The tick span, which is the axis a performance track joins on.
|
|
518
|
+
startTick: index.startTick ?? 0,
|
|
519
|
+
endTick: index.endTick ?? null,
|
|
520
|
+
durationMs: ticks != null && index.tickRate ? (ticks / index.tickRate) * 1000 : null,
|
|
521
|
+
tickRate: index.tickRate ?? null,
|
|
522
|
+
inputs: Object.values(index.inputsBySeq).reduce((n, v) => n + (v ?? 0), 0),
|
|
523
|
+
segments: segments.length,
|
|
524
|
+
bytes,
|
|
525
|
+
// A replay enters at a keyframe. A stream a few seconds old has not cut
|
|
526
|
+
// one yet, so it is on disk and not yet watchable.
|
|
527
|
+
watchable: index.hasKeyframe === true,
|
|
528
|
+
// Which cost tracks were recorded alongside it, if any (project note
|
|
529
|
+
// ofta45). Empty is the ordinary answer for a session played with
|
|
530
|
+
// measuring switched off.
|
|
531
|
+
tracks,
|
|
532
|
+
// Which code recorded it, when the dev server that stored it said so.
|
|
533
|
+
build: readBuildStamp(projectDir, stream),
|
|
534
|
+
kept: existsSync(join(dir, KEEP_FILE)),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
// Newest first, by the wall clock the room stamped rather than by mtime: a
|
|
538
|
+
// copied folder keeps its play order, and the list reads the way the person
|
|
539
|
+
// remembers playing. Streams with no stamp sort last rather than first.
|
|
540
|
+
return out.sort((a, b) => (b.startedAt ?? -Infinity) - (a.startedAt ?? -Infinity));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The most recent recording that can actually be WATCHED, or null.
|
|
545
|
+
*
|
|
546
|
+
* Newest-that-is-watchable rather than newest: a stream cuts its first keyframe
|
|
547
|
+
* a few seconds in, so the very newest recording is routinely not enterable
|
|
548
|
+
* yet — and `?replay=last` resolving to it would answer "no recording ... it is
|
|
549
|
+
* recorded on the machine that played it" while a perfectly watchable session
|
|
550
|
+
* sits right behind it, on the machine asking.
|
|
551
|
+
*/
|
|
552
|
+
export function latestRecording(projectDir) {
|
|
553
|
+
return listRecordings(projectDir).find((r) => r.watchable)?.stream ?? null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Delete one recording, whole. Returns false when there was nothing there —
|
|
558
|
+
* deleting a session twice is not an error, it is the second tap.
|
|
559
|
+
*/
|
|
560
|
+
export function deleteRecording(projectDir, stream) {
|
|
561
|
+
const dir = streamDir(projectDir, stream);
|
|
562
|
+
if (!existsSync(dir)) return false;
|
|
563
|
+
rmSync(dir, { recursive: true, force: true });
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Pin a recording against the prune, or unpin it. Returns false when there is
|
|
569
|
+
* no such recording.
|
|
570
|
+
*/
|
|
571
|
+
export function setKept(projectDir, stream, kept) {
|
|
572
|
+
const dir = streamDir(projectDir, stream);
|
|
573
|
+
if (!existsSync(dir)) return false;
|
|
574
|
+
const marker = join(dir, KEEP_FILE);
|
|
575
|
+
if (kept) writeFileSync(marker, '');
|
|
576
|
+
else rmSync(marker, { force: true });
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// What a game's recordings may take up before the oldest start going. Two caps
|
|
581
|
+
// because either one alone has a case it cannot see: a game with big worlds
|
|
582
|
+
// hits the byte cap after a handful of sessions, and a small game would sit at
|
|
583
|
+
// a few megabytes while accumulating hundreds of directories.
|
|
584
|
+
export const MAX_RECORDINGS = 20;
|
|
585
|
+
// A real session writes about 2.7 MB a minute of recording and 1 MB of track,
|
|
586
|
+
// so the byte cap holds well over ten hours of play; for most games the count
|
|
587
|
+
// is the cap that bites.
|
|
588
|
+
export const MAX_RECORDING_BYTES = 3 * 1024 * 1024 * 1024;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Bring a game's recordings back under the caps, oldest first.
|
|
592
|
+
*
|
|
593
|
+
* Pinned recordings are never swept, and they still COUNT against the caps —
|
|
594
|
+
* pinning is "do not delete this", not "do not charge me for it". Pin
|
|
595
|
+
* everything and the caps simply stop having anything to take, which is the
|
|
596
|
+
* honest outcome: the alternative silently deletes an unpinned session the
|
|
597
|
+
* creator only just made.
|
|
598
|
+
*
|
|
599
|
+
* @param {string} projectDir - the game folder
|
|
600
|
+
* @param {{ maxCount?: number, maxBytes?: number }} [caps]
|
|
601
|
+
* @returns {{ removed: string[], bytes: number, count: number, caps: { maxCount: number, maxBytes: number } }}
|
|
602
|
+
* what went, what is left, and the caps that decided it
|
|
603
|
+
*/
|
|
604
|
+
export function pruneRecordings(projectDir, { maxCount = MAX_RECORDINGS, maxBytes = MAX_RECORDING_BYTES } = {}) {
|
|
605
|
+
// Oldest first: the sweep order is the reverse of the reading order.
|
|
606
|
+
const all = listRecordings(projectDir).reverse();
|
|
607
|
+
let count = all.length;
|
|
608
|
+
let bytes = all.reduce((n, r) => n + r.bytes, 0);
|
|
609
|
+
const removed = [];
|
|
610
|
+
for (const rec of all) {
|
|
611
|
+
if (count <= maxCount && bytes <= maxBytes) break;
|
|
612
|
+
if (rec.kept) continue;
|
|
613
|
+
// A delete that fails for its own reason (a permission, a file held open)
|
|
614
|
+
// costs that one recording, never the startup this runs inside.
|
|
615
|
+
try {
|
|
616
|
+
if (!deleteRecording(projectDir, rec.stream)) continue;
|
|
617
|
+
} catch {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
removed.push(rec.stream);
|
|
621
|
+
count -= 1;
|
|
622
|
+
bytes -= rec.bytes;
|
|
623
|
+
}
|
|
624
|
+
return { removed, bytes, count, caps: { maxCount, maxBytes } };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Read a whole stream back as the single playable recording the replay
|
|
629
|
+
* transport consumes, or null when there is nothing enterable there.
|
|
630
|
+
*
|
|
631
|
+
* Stitching goes through the ENGINE's `stitchSegments` rather than a copy: it
|
|
632
|
+
* is the same reassembler the recorder's own export() uses, which is what
|
|
633
|
+
* keeps "segments are just a slicing of the recording" true by construction.
|
|
634
|
+
* It is imported from the resolved engine bundle, so a recording is always
|
|
635
|
+
* read back by the engine that wrote it — from `entity/stitch.js`, which is
|
|
636
|
+
* the reassembler alone, rather than through the recorder and its runtime
|
|
637
|
+
* dependencies.
|
|
638
|
+
*
|
|
639
|
+
* @param {string} projectDir - the game folder
|
|
640
|
+
* @param {string} stream
|
|
641
|
+
* @param {string} sharedDir - the engine bundle's shared/ tree
|
|
642
|
+
* @returns {Promise<string|null>} the recording as JSON text
|
|
643
|
+
*/
|
|
644
|
+
export async function stitchStream(projectDir, stream, sharedDir) {
|
|
645
|
+
const dir = streamDir(projectDir, stream);
|
|
646
|
+
if (!existsSync(dir)) return null;
|
|
647
|
+
const segments = [];
|
|
648
|
+
for (const name of readdirSync(dir).sort()) {
|
|
649
|
+
if (!SEGMENT_FILE_RE.test(name)) continue;
|
|
650
|
+
try {
|
|
651
|
+
const seg = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
652
|
+
if (canonicalFormat(seg?.header?.format) === 'joystack-replay-seg/1') segments.push(seg);
|
|
653
|
+
} catch {
|
|
654
|
+
// A half-written file (the dev server was killed mid-POST) must not
|
|
655
|
+
// cost the rest of the session.
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (!segments.length) return null;
|
|
659
|
+
// The stitcher belongs to the ENGINE, and the engine a game is pinned to may
|
|
660
|
+
// predate recordings entirely — a creator on a new CLI and an old engine can
|
|
661
|
+
// record perfectly well (the room is the new engine's; the segments are on
|
|
662
|
+
// disk) and then find nothing to read them with. Said plainly here, because
|
|
663
|
+
// the alternative is a module-resolution error surfacing to that creator as
|
|
664
|
+
// "no recording", with the files sitting in front of them.
|
|
665
|
+
const stitcher = join(sharedDir, 'ui', 'room', 'entity', 'stitch.js');
|
|
666
|
+
if (!existsSync(stitcher)) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
"this game's pinned engine predates session recordings, so it cannot read one back — "
|
|
669
|
+
+ 'run `joystack update` to watch this session',
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
const { stitchSegments } = await import(pathToFileURL(stitcher).href);
|
|
673
|
+
try {
|
|
674
|
+
return JSON.stringify(stitchSegments(segments));
|
|
675
|
+
} catch {
|
|
676
|
+
// The only way stitching refuses a contiguous range is "no keyframe
|
|
677
|
+
// segment in it" — a stream whose first cadence keyframe has not been cut
|
|
678
|
+
// yet. That is a not-yet, not a corruption.
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// A byte cap as a person reads it: whole gigabytes when it is one, else MB.
|
|
684
|
+
const GB = 1024 * 1024 * 1024;
|
|
685
|
+
const sizeLabel = (bytes) => (bytes >= GB && bytes % GB === 0
|
|
686
|
+
? `${bytes / GB} GB`
|
|
687
|
+
: `${Math.round(bytes / 1024 / 1024)} MB`);
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The prune `joystack dev` runs on the way up, and the line it prints.
|
|
691
|
+
*
|
|
692
|
+
* On start rather than on write: a sweep mid-session could delete the
|
|
693
|
+
* recording of the session the creator is in the middle of watching back, and
|
|
694
|
+
* the moment a stack starts is the one moment nobody is reading anything.
|
|
695
|
+
* Silent deletion is not an option either — a creator who goes looking for
|
|
696
|
+
* Tuesday's session deserves to have been told it went, so the line names the
|
|
697
|
+
* count and the caps that took it.
|
|
698
|
+
*/
|
|
699
|
+
export function pruneOnStart({ projectDir, log = () => {}, caps } = {}) {
|
|
700
|
+
// This runs before `joystack dev` has bound a port. Housekeeping over old
|
|
701
|
+
// recordings is never a reason for the stack not to start, so whatever the
|
|
702
|
+
// disk does here costs the sweep and nothing else.
|
|
703
|
+
let result;
|
|
704
|
+
try {
|
|
705
|
+
result = pruneRecordings(projectDir, caps);
|
|
706
|
+
} catch (e) {
|
|
707
|
+
log(`→ replays could not sweep old recordings: ${e?.message ?? e}`);
|
|
708
|
+
return { removed: [], bytes: 0, count: 0, caps: { maxCount: MAX_RECORDINGS, maxBytes: MAX_RECORDING_BYTES } };
|
|
709
|
+
}
|
|
710
|
+
if (result.removed.length) {
|
|
711
|
+
// The caps the sweep ACTUALLY applied, not the module defaults: a line that
|
|
712
|
+
// names limits a caller overrode would be telling the creator their session
|
|
713
|
+
// went for a reason that was not the reason.
|
|
714
|
+
log(`→ replays swept ${result.removed.length} old recording(s) — keeping the newest `
|
|
715
|
+
+ `${result.caps.maxCount} and up to ${sizeLabel(result.caps.maxBytes)} `
|
|
716
|
+
+ "(pin one in the toolbox's Sessions tool to keep it)");
|
|
717
|
+
}
|
|
718
|
+
return result;
|
|
719
|
+
}
|