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