@gravitylabsllc/porthole 0.1.0 → 0.2.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.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-BzqwnvoU.js +70 -0
  52. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
@@ -0,0 +1,704 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { createReadStream } from "node:fs";
5
+ import { createInterface } from "node:readline";
6
+ import path from "node:path";
7
+ import { profileFromEvent } from "./trace.js";
8
+ /** Stand-in for `deviceId` when a `hello` did not carry one. */
9
+ export const UNKNOWN_DEVICE_ID = "unknown-device";
10
+ export function sessionIdentity(hello) {
11
+ return {
12
+ packageName: hello.packageName,
13
+ deviceId: hello.deviceId ?? UNKNOWN_DEVICE_ID,
14
+ startedAt: hello.startedAt,
15
+ };
16
+ }
17
+ /**
18
+ * A filesystem-safe fragment: anything outside a conservative allowlist
19
+ * becomes `_`. `packageName` is always dotted-identifier-shaped in practice,
20
+ * but `deviceId` is whatever a future device sends, and this directory name
21
+ * doubles as `findSessionsForIdentity`'s prefix filter — an unsanitised
22
+ * separator character in either value could make one identity's directory
23
+ * look like a prefix of another's.
24
+ */
25
+ function sanitize(value) {
26
+ const cleaned = value.replace(/[^A-Za-z0-9_.-]/g, "_");
27
+ return cleaned.length > 0 ? cleaned : "_";
28
+ }
29
+ export function sessionDirName(identity) {
30
+ return `${sanitize(identity.packageName)}_${sanitize(identity.deviceId)}_${identity.startedAt}`;
31
+ }
32
+ export function sessionsRoot(projectRoot) {
33
+ return path.join(projectRoot, ".porthole", "sessions");
34
+ }
35
+ function metaPath(dir) {
36
+ return path.join(dir, "meta.json");
37
+ }
38
+ function eventsPath(dir) {
39
+ return path.join(dir, "events.ndjson");
40
+ }
41
+ /**
42
+ * This directory holds redacted-but-real app data (logcat lines, SQL bind
43
+ * values, HTTP headers) sitting on disk for up to [DEFAULT_RETENTION]'s
44
+ * `maxAgeMs`, rather than in a process's memory that dies with it — a
45
+ * different promise to a user than the in-memory ring ever made, per the
46
+ * ticket's own security note. Owner-only permissions are the cheap half of
47
+ * making that true.
48
+ *
49
+ * POSIX only. `fs`'s `mode` option is Unix permission bits; Windows/NTFS has
50
+ * no such concept (it uses ACLs instead), and Node's own docs say `mode` is
51
+ * "Not supported on Windows" — passing one there is not wrong, just inert,
52
+ * so every call site below gates on this rather than silently no-op'ing on
53
+ * one platform without saying so.
54
+ */
55
+ const isPosix = process.platform !== "win32";
56
+ /** Owner rwx only — the sessions root and every session directory under it (recursive `mkdir` applies this to each level it creates). */
57
+ const SESSION_DIR_MODE = 0o700;
58
+ /** Owner rw only — `meta.json` and `events.ndjson`. Applied at file creation; an already-existing file keeps whatever mode it was created with. */
59
+ const SESSION_FILE_MODE = 0o600;
60
+ function dirOptions() {
61
+ return isPosix ? { recursive: true, mode: SESSION_DIR_MODE } : { recursive: true };
62
+ }
63
+ function fileOptions() {
64
+ return isPosix ? { mode: SESSION_FILE_MODE } : {};
65
+ }
66
+ async function readMeta(dir) {
67
+ try {
68
+ return JSON.parse(await readFile(metaPath(dir), "utf8"));
69
+ }
70
+ catch {
71
+ // Absent, unreadable or corrupt are all the same to a caller: there is no
72
+ // meta to report, so callers that need one (retention, cross-session
73
+ // lookup) skip the directory rather than guessing at its contents.
74
+ return null;
75
+ }
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // the writer
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * Appends one session's events to disk, off the socket callback and on an
82
+ * interval — never a synchronous write per event, which is what "must not
83
+ * touch the socket read path" (AC4) means concretely: `append()` only ever
84
+ * pushes to an in-memory array and arms a timer; the actual `fs` write
85
+ * happens later, on `flush()`, awaited by nothing on the hot path.
86
+ *
87
+ * One writer instance is meant to live as long as one `DeviceClient` — see
88
+ * that file's `#session-writer` section once it is wired up — so `open()` is
89
+ * called once per `hello` and `append()` once per event in between.
90
+ */
91
+ export class SessionWriter {
92
+ root;
93
+ flushIntervalMs;
94
+ dir = null;
95
+ meta = null;
96
+ queue = [];
97
+ flushTimer = null;
98
+ /** Chains flushes so an interval tick and an explicit flush() never interleave two appendFile calls on the same file. */
99
+ flushing = Promise.resolve();
100
+ /**
101
+ * GRA-191: true for the whole span of an in-flight `open()` call — from
102
+ * before its first `await` to its `finally`, success or failure. This,
103
+ * not `dir`, is what lets `append()` tell "a session is being opened,
104
+ * queue this" apart from "nothing will ever open here, drop this": `dir`
105
+ * itself is not set until partway through `open()` (see below), so an
106
+ * event arriving in the gap before that line runs would otherwise look
107
+ * identical to one arriving with persistence off entirely.
108
+ */
109
+ opening = false;
110
+ /**
111
+ * GRA-191: true once `close()` has run and no `open()` has run since.
112
+ * `DeviceClient.stop()` calls `close()` so nothing here outlives the
113
+ * caller (see that method); `append()` drops silently while this is true,
114
+ * the same "nowhere to put this" shape as never having opened at all,
115
+ * rather than queuing into a writer its owner has already said it is done
116
+ * with. `open()` clears it unconditionally, including on the idempotent
117
+ * "same identity" path, because a reconnect (stop() closed this writer; a
118
+ * fresh hello reopens it) must resume appending, not stay silently closed
119
+ * forever just because the identity did not change.
120
+ */
121
+ closed = false;
122
+ constructor(root, flushIntervalMs = 250) {
123
+ this.root = root;
124
+ this.flushIntervalMs = flushIntervalMs;
125
+ }
126
+ /**
127
+ * Begins (or resumes) the session named by `hello`'s identity.
128
+ *
129
+ * Idempotent for an unchanged identity — a reconnect that gets the same
130
+ * `hello` back (GRA-163: the same process, socket dropped and reattached)
131
+ * must not re-open anything or lose queued events. A *new* identity first
132
+ * flushes whatever the previous session still owed: `device.ts` only calls
133
+ * `open()` from the `hello` handler, and a new `hello` always means a new
134
+ * process (or a distinct device) — nothing more will ever arrive for the
135
+ * one just left behind, so this is the only chance to flush it promptly
136
+ * rather than waiting on a timer that a new session's own events would
137
+ * otherwise keep resetting.
138
+ *
139
+ * "A second MCP server attaching to the same app appends to the same
140
+ * session" (the ticket's own words): the directory name is a pure function
141
+ * of identity, so a second writer computes the same path and finds the
142
+ * directory (and `meta.json`) already there — this reads it back rather
143
+ * than overwriting it, so the counts and `firstT` it reports are the whole
144
+ * session's, not just what this instance has seen.
145
+ */
146
+ async open(hello) {
147
+ // PORTHOLE_SESSIONS=0 is the off switch: `dir` is left null, exactly the
148
+ // "no root configured" shape `append()` already treats as a silent
149
+ // no-op, and neither `mkdir` nor a retention sweep ever touches the
150
+ // sessions root. Checked first, and every time — not cached at
151
+ // construction — so it stays cheap to reason about (one env read, one
152
+ // branch) rather than a second piece of state that could drift from the
153
+ // environment it mirrors.
154
+ if (!sessionsEnabled())
155
+ return;
156
+ const identity = sessionIdentity(hello);
157
+ const dir = path.join(this.root, sessionDirName(identity));
158
+ // GRA-191: un-closes the writer even on the idempotent "same identity"
159
+ // path below — see `closed`'s own comment on why a reconnect must not
160
+ // stay closed just because nothing about the identity changed.
161
+ this.closed = false;
162
+ if (this.dir === dir)
163
+ return;
164
+ this.opening = true;
165
+ try {
166
+ await this.flush();
167
+ this.dir = dir;
168
+ await mkdir(dir, dirOptions());
169
+ this.meta = (await readMeta(dir)) ?? {
170
+ packageName: identity.packageName,
171
+ deviceId: identity.deviceId,
172
+ startedAt: identity.startedAt,
173
+ device: hello.device,
174
+ sdkInt: hello.sdkInt,
175
+ versionName: hello.versionName,
176
+ firstT: null,
177
+ lastT: null,
178
+ eventCounts: {},
179
+ createdAt: Date.now(),
180
+ updatedAt: Date.now(),
181
+ };
182
+ await writeFile(metaPath(dir), JSON.stringify(this.meta, null, 2), fileOptions());
183
+ // Every new (or resumed) session is a natural, cheap point to sweep:
184
+ // it is already the moment this writer is about to grow the directory
185
+ // it would prune from, and it means retention runs on the same
186
+ // cadence a long-lived MCP server actually sees `hello`s, not on a
187
+ // separate timer this ticket does not need. `activeDir` is *this*
188
+ // session, just opened above — never the one about to be pruned, no
189
+ // matter its age or size.
190
+ await enforceRetention(this.root, retentionOptionsFromEnv(), this.currentDir());
191
+ // GRA-191: `device.ts` now emits its own "hello" — and lets a caller
192
+ // start sending events — before this method is even called, not just
193
+ // before it resolves (see that file's `connect()`). Anything
194
+ // `append()`ed during the disk I/O above was queued rather than
195
+ // dropped (see `append()`'s own comment); flush it now that `dir`
196
+ // exists, rather than waiting on `append()`'s own 250ms timer, so a
197
+ // caller that awaits `open()` and immediately reads the session back
198
+ // sees everything that arrived during the wait.
199
+ await this.flush();
200
+ }
201
+ finally {
202
+ this.opening = false;
203
+ }
204
+ }
205
+ /**
206
+ * Queues an event. The actual disk write happens on the next `flush()`,
207
+ * arranged on a timer here — never inline, which is the whole point (AC4).
208
+ *
209
+ * Silently does nothing with no session that will ever open to receive it:
210
+ * persistence being off (no root configured — see `device.ts`), a writer
211
+ * `close()` has already closed, or `open()` never having been called at
212
+ * all all look like this from the caller's side, and none of them is an
213
+ * error.
214
+ *
215
+ * GRA-191: does *not* require `dir` to already be set. `device.ts` now
216
+ * emits "hello" — and lets a caller start sending events — before it even
217
+ * calls `open()`, not just before `open()` resolves, so an event can
218
+ * legitimately arrive before `dir` exists yet. `opening` is what tells
219
+ * that apart from "no session will ever open here": queue in the former
220
+ * case (open()'s own trailing flush — see above — writes it out once
221
+ * `dir` exists), drop in the latter (nothing will ever flush a queue that
222
+ * never has an open directory behind it).
223
+ */
224
+ append(event) {
225
+ if (this.closed)
226
+ return;
227
+ if (!this.dir && !this.opening)
228
+ return;
229
+ this.queue.push(event);
230
+ if (!this.flushTimer) {
231
+ this.flushTimer = setTimeout(() => {
232
+ this.flushTimer = null;
233
+ void this.flush();
234
+ }, this.flushIntervalMs);
235
+ // Node-only guard: a raw `setTimeout` return value in a browser bundle
236
+ // has no `.unref`. This module never runs in a browser, but `.unref?.()`
237
+ // costs nothing and stops a lingering timer from being the reason a
238
+ // short-lived script (a test, a one-shot CLI invocation) hangs on exit.
239
+ this.flushTimer.unref?.();
240
+ }
241
+ }
242
+ /** Writes whatever is queued. Safe to call at any time; a no-op with nothing queued. */
243
+ flush() {
244
+ this.flushing = this.flushing.then(() => this.doFlush());
245
+ return this.flushing;
246
+ }
247
+ async doFlush() {
248
+ if (this.flushTimer) {
249
+ clearTimeout(this.flushTimer);
250
+ this.flushTimer = null;
251
+ }
252
+ if (!this.dir || this.queue.length === 0)
253
+ return;
254
+ const dir = this.dir;
255
+ const batch = this.queue;
256
+ this.queue = [];
257
+ try {
258
+ const lines = batch.map((event) => JSON.stringify(event)).join("\n") + "\n";
259
+ await mkdir(dir, dirOptions());
260
+ await appendFile(eventsPath(dir), lines, { encoding: "utf8", ...fileOptions() });
261
+ const meta = this.meta ?? (await readMeta(dir));
262
+ if (meta) {
263
+ for (const event of batch) {
264
+ meta.eventCounts[event.event] = (meta.eventCounts[event.event] ?? 0) + 1;
265
+ meta.firstT = meta.firstT === null ? event.t : Math.min(meta.firstT, event.t);
266
+ meta.lastT = meta.lastT === null ? event.t : Math.max(meta.lastT, event.t);
267
+ // GRA-191: moved here from `append()`. `append()` can now queue an
268
+ // event before `this.meta` exists yet — the window `open()` is
269
+ // still in its own `mkdir`/`readMeta` (see `append()`'s comment) —
270
+ // so capturing the profile at append() time could silently miss
271
+ // it for whichever event happened to land in that window. Every
272
+ // event reaches this loop only once `meta` is known to exist (or
273
+ // there is genuinely nothing to capture into), so this is the one
274
+ // place the capture can never be skipped for lack of a meta
275
+ // object to put it in.
276
+ const profile = profileFromEvent(event);
277
+ if (profile)
278
+ meta.profile = profile;
279
+ }
280
+ meta.updatedAt = Date.now();
281
+ this.meta = meta;
282
+ await writeFile(metaPath(dir), JSON.stringify(meta, null, 2), fileOptions());
283
+ }
284
+ }
285
+ catch (error) {
286
+ // GRA-191: the session root can be removed out from under a still-
287
+ // armed flush — a test's teardown (or a real short-lived process)
288
+ // deleting `sessionsRoot` while an event was queued and the flush
289
+ // timer was still pending is the repro GRA-183 found. There is
290
+ // nowhere left to write, and `batch` (already spliced out of
291
+ // `this.queue` above) is not coming back — the same loss a killed
292
+ // process before any flush at all would already produce, not a new
293
+ // one this introduces, so treating it as "nothing to flush" rather
294
+ // than surfacing an unhandled rejection is honest, not a cover-up.
295
+ // Anything else (a permissions error, a full disk) is a real problem
296
+ // and must still surface — checking the code, rather than swallowing
297
+ // every error this method can raise, is what keeps that true.
298
+ if (error.code !== "ENOENT")
299
+ throw error;
300
+ }
301
+ }
302
+ currentDir() {
303
+ return this.dir;
304
+ }
305
+ currentMeta() {
306
+ return this.meta;
307
+ }
308
+ /**
309
+ * GRA-191: called from `DeviceClient.stop()` so nothing here outlives the
310
+ * caller. Flushes whatever is queued — which also cancels the pending
311
+ * flush timer, since `doFlush()`'s own first lines clear it unconditionally
312
+ * — rather than leaving a still-armed timer to fire later, potentially
313
+ * well after whatever root this writer lives under has been torn down (a
314
+ * test's `afterEach`, a real process exit). Also marks the writer closed
315
+ * (see `closed`'s own comment) so a straggling `append()` after this —
316
+ * `stop()` destroying the socket and the "close" handler both reacting to
317
+ * the same teardown — drops silently instead of queuing into a writer
318
+ * nothing will flush again until a new `open()`.
319
+ */
320
+ close() {
321
+ this.closed = true;
322
+ void this.flush();
323
+ }
324
+ }
325
+ // ---------------------------------------------------------------------------
326
+ // fallback read — GRA-53 Q1: a scan, not an index
327
+ // ---------------------------------------------------------------------------
328
+ /**
329
+ * Streams `events.ndjson` and returns the events whose `t` falls in
330
+ * `[from, to]`, oldest first.
331
+ *
332
+ * **Q1, answered by measurement, not assumption**: does the fallback read
333
+ * need an index, or is a per-minute byte-offset scan enough for a 30-minute
334
+ * session? `sessions.test.ts` writes a real ~30-minute-equivalent NDJSON file
335
+ * (54,000 lines at 30 events/sec, this module's own estimate of "a busy
336
+ * app" — see `EventRing.DEFAULT_CAPACITY`'s comment for where that rate
337
+ * comes from) and asserts a window read completes well inside a second on
338
+ * ordinary hardware. A plain sequential scan was fast enough that building
339
+ * and maintaining a byte-offset index — one more structure that can disagree
340
+ * with the file it indexes — was not worth the ticket's own warning: "an
341
+ * index you did not need is cost you cannot remove later."
342
+ *
343
+ * Streams rather than reading the whole file into memory (the device AC
344
+ * asks for exactly this): `createReadStream` plus `readline` never holds
345
+ * more than one line at a time, so this scales past whatever `--max-old-
346
+ * space-size` a long session's file would otherwise threaten.
347
+ */
348
+ export async function readSessionWindow(dir, from, to) {
349
+ const out = [];
350
+ await new Promise((resolve, reject) => {
351
+ const stream = createReadStream(eventsPath(dir), { encoding: "utf8" });
352
+ let settled = false;
353
+ const onError = (error) => {
354
+ if (settled)
355
+ return;
356
+ settled = true;
357
+ // No file at all (a session directory with nothing flushed yet, or one
358
+ // that never existed) is not a caller error — it just has nothing to
359
+ // contribute to the window.
360
+ if (error.code === "ENOENT")
361
+ resolve();
362
+ else
363
+ reject(error);
364
+ };
365
+ // Both the stream and the readline interface built on it can be the one
366
+ // to actually emit "error" depending on Node's version and exactly when
367
+ // the open() failure lands relative to readline wiring itself up — an
368
+ // ENOENT on a missing session directory reliably surfaced as an
369
+ // *uncaught* exception here until both were listened to, because an
370
+ // EventEmitter with no "error" listener throws rather than swallowing.
371
+ stream.on("error", onError);
372
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
373
+ rl.on("error", onError);
374
+ rl.on("line", (line) => {
375
+ if (!line)
376
+ return;
377
+ try {
378
+ const event = JSON.parse(line);
379
+ if (event.t >= from && event.t <= to)
380
+ out.push(event);
381
+ }
382
+ catch {
383
+ // A torn last line — a flush that was killed mid-write — is a
384
+ // possibility this format accepts by design (the ticket's own
385
+ // reasoning for NDJSON: "crash-safe by construction"). Skipping it
386
+ // loses at most one event, never the read.
387
+ }
388
+ });
389
+ rl.on("close", () => {
390
+ if (settled)
391
+ return;
392
+ settled = true;
393
+ resolve();
394
+ });
395
+ });
396
+ return out;
397
+ }
398
+ /**
399
+ * Every session on disk for one `(packageName, deviceId)`, oldest first.
400
+ *
401
+ * A directory scan plus one small `meta.json` read per session — not an
402
+ * index, and deliberately not: a working app accumulates a handful of
403
+ * sessions between retention sweeps, not thousands, so this is cheap without
404
+ * needing to be clever. Out of scope (the ticket's own words): "any query
405
+ * language over sessions." This is the one lookup the window-fallback tools
406
+ * need — "which session(s) could this window's data be sitting in" — and no
407
+ * more.
408
+ */
409
+ export async function findSessionsForIdentity(root, packageName, deviceId) {
410
+ let names;
411
+ try {
412
+ names = await readdir(root);
413
+ }
414
+ catch {
415
+ return [];
416
+ }
417
+ const prefix = `${sanitize(packageName)}_${sanitize(deviceId)}_`;
418
+ const out = [];
419
+ for (const name of names) {
420
+ if (!name.startsWith(prefix))
421
+ continue;
422
+ const dir = path.join(root, name);
423
+ const meta = await readMeta(dir);
424
+ if (meta)
425
+ out.push({ ...meta, dir });
426
+ }
427
+ out.sort((a, b) => a.startedAt - b.startedAt);
428
+ return out;
429
+ }
430
+ /**
431
+ * GRA-54: every session on disk, for every app and every device — not
432
+ * scoped to one identity the way `findSessionsForIdentity` is, because
433
+ * `porthole sessions` exists to answer "which of yesterday's runs was the
434
+ * one on the Pixel" for someone who does not already know the id scheme,
435
+ * and `save_moment`/`porthole save` need to pick a session to act on before
436
+ * an identity is known at all. Newest-started first, matching the CLI's own
437
+ * "newest first" requirement — a separate question from *which* session is
438
+ * "current" (the caller's job: this only reads what is on disk).
439
+ */
440
+ export async function listAllSessions(root) {
441
+ let names;
442
+ try {
443
+ names = await readdir(root);
444
+ }
445
+ catch {
446
+ return [];
447
+ }
448
+ const out = [];
449
+ for (const name of names) {
450
+ const dir = path.join(root, name);
451
+ const meta = await readMeta(dir);
452
+ if (meta)
453
+ out.push({ ...meta, dir });
454
+ }
455
+ out.sort((a, b) => b.startedAt - a.startedAt);
456
+ return out;
457
+ }
458
+ /**
459
+ * Bytes on disk for one session directory: `events.ndjson` plus `meta.json`.
460
+ * Shared with `enforceRetention`'s own sizing (`sessionDirInfo` below) only
461
+ * in spirit, not in code — that function also needs `updatedAt` and treats a
462
+ * missing `events.ndjson` as "not a session directory at all" (returns
463
+ * null), a distinction retention cares about and `porthole sessions` does
464
+ * not: a session `open()`ed but never yet flushed is still a real row in
465
+ * that listing, just a 0-or-small-byte one.
466
+ */
467
+ export async function sessionSizeBytes(dir) {
468
+ let bytes = 0;
469
+ try {
470
+ bytes += (await stat(eventsPath(dir))).size;
471
+ }
472
+ catch {
473
+ // No events flushed yet — a real, if small, state for a just-opened session.
474
+ }
475
+ try {
476
+ bytes += (await stat(metaPath(dir))).size;
477
+ }
478
+ catch {
479
+ // Missing meta.json is odd but not fatal for sizing.
480
+ }
481
+ return bytes;
482
+ }
483
+ /**
484
+ * How much of `[from, to]` fell outside what a merged view (`WindowFill`'s
485
+ * `coveredFrom`/`coveredTo`) actually covers — the same `clippedMs`
486
+ * vocabulary `findings` has always reported, now the one function both
487
+ * `findings` (index.ts) and `save_moment`/`porthole save` (save.ts) call,
488
+ * rather than two hand-rolled copies that agree until the day one of them
489
+ * changes and the disagreement is silent (GRA-163's history, repeatedly).
490
+ *
491
+ * Deliberately not derived from which events matched — see
492
+ * `fillWindowFromDisk`'s own doc comment above on why a quiet stretch inside
493
+ * a fully-recorded session must read as covered, not clipped.
494
+ */
495
+ export function clippedMsOf(from, to, coveredFrom, coveredTo) {
496
+ const ms = Math.max(0, to - from);
497
+ if (coveredFrom === null || coveredTo === null)
498
+ return { start: ms, end: 0 };
499
+ return {
500
+ start: from < coveredFrom ? coveredFrom - from : 0,
501
+ end: to > coveredTo ? to - coveredTo : 0,
502
+ };
503
+ }
504
+ /**
505
+ * The one place "what actually happened in this window" is answered from
506
+ * both the live in-memory buffer and whatever sessions on disk overlap it —
507
+ * so `findings`, `what_was_happening` and `timeline` (once `index.ts`'s
508
+ * `#window-fallback` section calls this) all agree, instead of three
509
+ * separately hand-rolled merges that drift the way GRA-163's history warns
510
+ * about.
511
+ *
512
+ * **The restart-boundary case, built deliberately with the boundary inside
513
+ * the window rather than adjacent to it** (this ticket's own instruction):
514
+ * `sessions.test.ts` asks for a window that starts before an old session's
515
+ * last event and ends after a new session's first one, with both sessions
516
+ * present on disk and a live buffer holding only the new session's events —
517
+ * exactly what "the MCP server was killed and restarted mid-session" (AC1)
518
+ * or "the app was reinstalled" (the ticket's own motivating scenario)
519
+ * produce. The assertion is on the *merged* list: both sides present, in
520
+ * `t` order, no event twice.
521
+ *
522
+ * Deduplication key is `(sessionDir, seq)`, not `seq` alone — `seq` is a
523
+ * monotonic counter that restarts at zero in every process (`EventRing.kt`),
524
+ * so two different sessions' events can carry the same `seq` and are not
525
+ * the same event; an event already in `buffered` and *also* already flushed
526
+ * to the current session's own file (a flush landing between the two reads)
527
+ * shares both the directory and the `seq`, and is the same event, so it is
528
+ * kept once. `currentSessionDir` supplies that directory for in-memory
529
+ * events — pass `device.sessions?.currentDir() ?? null`.
530
+ */
531
+ export async function fillWindowFromDisk(params) {
532
+ const seen = new Set();
533
+ const merged = [];
534
+ const keyOf = (dir, seq) => `${dir ?? "?"}:${seq}`;
535
+ const take = (event, dir) => {
536
+ const key = keyOf(dir, event.seq);
537
+ if (seen.has(key))
538
+ return;
539
+ seen.add(key);
540
+ merged.push(event);
541
+ };
542
+ let coveredFrom = null;
543
+ let coveredTo = null;
544
+ const widen = (rangeFrom, rangeTo) => {
545
+ const from = Math.max(params.from, rangeFrom);
546
+ const to = Math.min(params.to, rangeTo);
547
+ if (from > to)
548
+ return; // this range does not actually touch the window
549
+ coveredFrom = coveredFrom === null ? from : Math.min(coveredFrom, from);
550
+ coveredTo = coveredTo === null ? to : Math.max(coveredTo, to);
551
+ };
552
+ for (const event of params.buffered) {
553
+ if (event.t < params.from || event.t > params.to)
554
+ continue;
555
+ take(event, params.currentSessionDir);
556
+ }
557
+ if (params.buffered.length > 0) {
558
+ widen(params.buffered[0].t, params.buffered[params.buffered.length - 1].t);
559
+ }
560
+ if (params.identity) {
561
+ const sessions = await findSessionsForIdentity(params.root, params.identity.packageName, params.identity.deviceId);
562
+ for (const session of sessions) {
563
+ if (session.firstT === null || session.lastT === null)
564
+ continue;
565
+ if (session.lastT < params.from || session.firstT > params.to)
566
+ continue;
567
+ widen(session.firstT, session.lastT);
568
+ const fromDisk = await readSessionWindow(session.dir, params.from, params.to);
569
+ for (const event of fromDisk)
570
+ take(event, session.dir);
571
+ }
572
+ }
573
+ merged.sort((a, b) => a.t - b.t || a.seq - b.seq);
574
+ return {
575
+ events: merged,
576
+ oldest: merged.length > 0 ? merged[0].t : null,
577
+ newest: merged.length > 0 ? merged[merged.length - 1].t : null,
578
+ coveredFrom,
579
+ coveredTo,
580
+ };
581
+ }
582
+ /** ~500MB / 7 days: the ticket's own defaults. Configurable via env and the `porthole {}` block once GRA-119 lands a way to carry Gradle config to the MCP server; both are plain numbers here so that wiring is a call-site change, not a rewrite. */
583
+ export const DEFAULT_RETENTION = {
584
+ maxBytes: 500 * 1024 * 1024,
585
+ maxAgeMs: 7 * 24 * 60 * 60 * 1000,
586
+ };
587
+ /**
588
+ * The env half of retention config — `SessionWriter.open()`'s only caller of
589
+ * `enforceRetention`, so this is where the override actually lands. The
590
+ * `porthole {}` DSL half (a Gradle-side setting reaching the MCP server) is
591
+ * explicitly **not** part of this ticket; env vars are the whole mechanism
592
+ * for now, and are not superseded by the DSL arriving later — that would be
593
+ * a second source of truth for the same two numbers.
594
+ *
595
+ * Missing, empty or malformed values (`""`, `"abc"`, a negative number, `0`)
596
+ * all fall back to [DEFAULT_RETENTION] rather than producing a retention
597
+ * policy that prunes everything or nothing by accident — a typo in an env
598
+ * var should degrade to "the documented default", not to undefined
599
+ * behaviour.
600
+ */
601
+ export function retentionOptionsFromEnv() {
602
+ const maxBytes = parsePositiveInt(process.env.PORTHOLE_SESSIONS_MAX_BYTES);
603
+ const maxAgeDays = parsePositiveInt(process.env.PORTHOLE_SESSIONS_MAX_AGE_DAYS);
604
+ return {
605
+ maxBytes: maxBytes ?? DEFAULT_RETENTION.maxBytes,
606
+ maxAgeMs: maxAgeDays !== undefined ? maxAgeDays * 24 * 60 * 60 * 1000 : DEFAULT_RETENTION.maxAgeMs,
607
+ };
608
+ }
609
+ /** A positive integer, or `undefined` for anything that is not one — missing, empty, `NaN`, zero, negative or fractional-but-non-finite input all collapse to "no override". */
610
+ function parsePositiveInt(value) {
611
+ if (value === undefined || value.trim() === "")
612
+ return undefined;
613
+ const n = Number(value);
614
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
615
+ }
616
+ /**
617
+ * `PORTHOLE_SESSIONS=0` is the documented off switch (README's "Sessions on
618
+ * disk" section): any other value, including unset, leaves writing on. This
619
+ * is read fresh on every `open()` call rather than cached, so a test (or a
620
+ * host that changes its own environment) never has to worry about import
621
+ * order.
622
+ */
623
+ export function sessionsEnabled() {
624
+ return process.env.PORTHOLE_SESSIONS !== "0";
625
+ }
626
+ async function sessionDirInfo(dir) {
627
+ let bytes = 0;
628
+ let updatedAt = 0;
629
+ try {
630
+ const eventsStat = await stat(eventsPath(dir));
631
+ bytes += eventsStat.size;
632
+ updatedAt = eventsStat.mtimeMs;
633
+ }
634
+ catch {
635
+ // No events file at all: not a session directory (or one that was never
636
+ // written to), either way nothing for retention to weigh.
637
+ return null;
638
+ }
639
+ try {
640
+ bytes += (await stat(metaPath(dir))).size;
641
+ }
642
+ catch {
643
+ // Missing meta.json is odd but not fatal for sizing.
644
+ }
645
+ const meta = await readMeta(dir);
646
+ // meta.updatedAt is the session's own account of when it was last written;
647
+ // preferred over the file's mtime because a session that was recreated
648
+ // (readMeta found nothing so a fresh meta.json was written, but the
649
+ // underlying events.ndjson survived from an earlier, much older run — not
650
+ // a real scenario today, but nothing here should quietly rely on it not
651
+ // happening) would otherwise inherit a misleading age from the wrong file.
652
+ return { dir, bytes, updatedAt: meta?.updatedAt ?? updatedAt };
653
+ }
654
+ /**
655
+ * Prunes sessions by both age and total size, oldest-updated first, and
656
+ * never touches `activeDir` — the session currently being written — no
657
+ * matter how old or how large it is. That guarantee is the point of taking
658
+ * `activeDir` as a parameter rather than inferring "in progress" from
659
+ * `updatedAt` being recent: a session that has been open for hours without a
660
+ * new event (an idle app) is still the one in progress, and recency of the
661
+ * last write is exactly the signal retention otherwise uses to decide what
662
+ * is safe to delete.
663
+ */
664
+ export async function enforceRetention(root, options = DEFAULT_RETENTION, activeDir = null) {
665
+ let names;
666
+ try {
667
+ names = await readdir(root);
668
+ }
669
+ catch {
670
+ return { prunedDirs: [] };
671
+ }
672
+ const infos = [];
673
+ for (const name of names) {
674
+ const info = await sessionDirInfo(path.join(root, name));
675
+ if (info)
676
+ infos.push(info);
677
+ }
678
+ const active = infos.filter((info) => info.dir === activeDir);
679
+ const prunable = infos.filter((info) => info.dir !== activeDir);
680
+ prunable.sort((a, b) => a.updatedAt - b.updatedAt);
681
+ const now = Date.now();
682
+ const survivors = [];
683
+ const pruned = [];
684
+ for (const info of prunable) {
685
+ if (now - info.updatedAt > options.maxAgeMs) {
686
+ pruned.push(info.dir);
687
+ }
688
+ else {
689
+ survivors.push(info);
690
+ }
691
+ }
692
+ let total = active.reduce((sum, info) => sum + info.bytes, 0) + survivors.reduce((sum, info) => sum + info.bytes, 0);
693
+ let i = 0;
694
+ while (total > options.maxBytes && i < survivors.length) {
695
+ total -= survivors[i].bytes;
696
+ pruned.push(survivors[i].dir);
697
+ i++;
698
+ }
699
+ for (const dir of pruned) {
700
+ await rm(dir, { recursive: true, force: true });
701
+ }
702
+ return { prunedDirs: pruned };
703
+ }
704
+ //# sourceMappingURL=sessions.js.map