@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.
- package/README.md +123 -0
- package/dist/adb.js +430 -21
- package/dist/adb.js.map +1 -1
- package/dist/args.js +144 -0
- package/dist/args.js.map +1 -0
- package/dist/capture.js +139 -30
- package/dist/capture.js.map +1 -1
- package/dist/cli.js +221 -62
- package/dist/cli.js.map +1 -1
- package/dist/device.js +337 -4
- package/dist/device.js.map +1 -1
- package/dist/index.js +2030 -377
- package/dist/index.js.map +1 -1
- package/dist/moment.js +240 -0
- package/dist/moment.js.map +1 -0
- package/dist/perfetto.js +826 -0
- package/dist/perfetto.js.map +1 -0
- package/dist/report.js +68 -7
- package/dist/report.js.map +1 -1
- package/dist/save.js +252 -0
- package/dist/save.js.map +1 -0
- package/dist/sessions.js +704 -0
- package/dist/sessions.js.map +1 -0
- package/dist/system.js +169 -0
- package/dist/system.js.map +1 -0
- package/dist/systrace.js +198 -0
- package/dist/systrace.js.map +1 -0
- package/dist/timeline.js +731 -29
- package/dist/timeline.js.map +1 -1
- package/dist/trace.js +317 -27
- package/dist/trace.js.map +1 -1
- package/dist/watermark.js +220 -0
- package/dist/watermark.js.map +1 -0
- package/package.json +10 -4
- package/src/adb.ts +583 -0
- package/src/args.ts +177 -0
- package/src/capture.ts +292 -0
- package/src/cli.ts +367 -0
- package/src/device.ts +635 -0
- package/src/index.ts +2545 -0
- package/src/moment.ts +306 -0
- package/src/perfetto.ts +972 -0
- package/src/report.ts +285 -0
- package/src/save.ts +322 -0
- package/src/sessions.ts +894 -0
- package/src/system.ts +221 -0
- package/src/systrace.ts +258 -0
- package/src/timeline.ts +1036 -0
- package/src/trace.ts +769 -0
- package/src/watermark.ts +337 -0
- package/ui/dist/assets/index-BzqwnvoU.js +70 -0
- package/ui/dist/assets/index-DtnyBXCM.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index--1mlZuNZ.css +0 -1
- package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/sessions.ts
ADDED
|
@@ -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
|
+
}
|