@gravitylabsllc/porthole 0.1.0 → 0.2.1

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-DtnyBXCM.css +1 -0
  52. package/ui/dist/assets/index-h7VNB9Fl.js +70 -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
package/src/report.ts ADDED
@@ -0,0 +1,285 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import type { Finding, Trace } from "./trace.js";
4
+ import { frameBudgetMs } from "./trace.js";
5
+
6
+ /**
7
+ * Lanes a reader should be told were checked and found quiet.
8
+ *
9
+ * Every key a lane can raise a finding from has to be listed here, or the
10
+ * footer ends up contradicting the list above it: a capture with three wedged
11
+ * HTTP calls printed "quiet: http" directly beneath the warning that named
12
+ * them, because the lane keyed on `http.failed` alone and a call that never
13
+ * returns never fails. The two must be added together, so a new lane metric is
14
+ * only half-added until it appears in this table.
15
+ */
16
+ const CHECKED: Array<{ label: string; keys: string[] }> = [
17
+ { label: "http", keys: ["http.failed", "http.stillOpen"] },
18
+ { label: "db", keys: ["db.onMainThread", "db.stillOpen"] },
19
+ { label: "main thread", keys: ["mainThread.stalls"] },
20
+ { label: "frames", keys: ["frames.missed"] },
21
+ { label: "work", keys: ["work.retries", "work.failures", "work.stillOpen"] },
22
+ { label: "memory", keys: ["memory.blockingGcMs"] },
23
+ ];
24
+
25
+ const LABEL: Record<Finding["severity"], string> = {
26
+ error: "ERROR ",
27
+ warning: "WARNING",
28
+ note: "NOTE ",
29
+ };
30
+
31
+ // Raw ANSI SGR codes — no dependency, for four escape sequences. Reset is a
32
+ // full SGR reset (`\x1b[0m`) rather than a scoped "un-bold"/"un-dim" code so
33
+ // that wrapping never depends on which attribute was opened.
34
+ const ANSI_RESET = "\x1b[0m";
35
+ const ANSI_RED = "\x1b[31m";
36
+ const ANSI_YELLOW = "\x1b[33m";
37
+ const ANSI_DIM = "\x1b[2m";
38
+
39
+ const SEVERITY_COLOR: Record<Finding["severity"], string> = {
40
+ error: ANSI_RED,
41
+ warning: ANSI_YELLOW,
42
+ note: ANSI_DIM,
43
+ };
44
+
45
+ /**
46
+ * Whether the severity token in a rendered report should carry colour.
47
+ *
48
+ * Colour belongs on the TTY path a human reads — never on a pipe, CI, or the
49
+ * MCP tool result text an agent reads, where escape bytes are just noise a
50
+ * model has to see past. So this is a question about one output stream, not
51
+ * a global: `porthole capture` writes its summary to stderr while `porthole
52
+ * report` writes to stdout, and the two can disagree about whether they are
53
+ * a terminal (`cmd | less` redirects stdout but leaves stderr a TTY).
54
+ * Callers pass the stream they are about to write to.
55
+ *
56
+ * NO_COLOR (https://no-color.org) is honoured unconditionally when set to
57
+ * anything, including an empty string — the convention is "the variable is
58
+ * present", not "the variable is truthy", so this checks `undefined` rather
59
+ * than falsiness.
60
+ */
61
+ export function shouldColor(
62
+ stream: { isTTY?: boolean } = process.stdout,
63
+ env: NodeJS.ProcessEnv = process.env,
64
+ ): boolean {
65
+ return Boolean(stream.isTTY) && env.NO_COLOR === undefined;
66
+ }
67
+
68
+ function colorSeverity(severity: Finding["severity"], color: boolean): string {
69
+ const label = LABEL[severity];
70
+ return color ? `${SEVERITY_COLOR[severity]}${label}${ANSI_RESET}` : label;
71
+ }
72
+
73
+ export interface RenderReportOptions {
74
+ /**
75
+ * Colour the severity token. Defaults to false: plain text is the safe
76
+ * default for every caller that does not explicitly opt in. That default —
77
+ * not a TTY check inside this function — is what keeps the MCP `findings`
78
+ * tool's text plain (it does not call this with `color: true`, and never
79
+ * will; it does not even call this function) and keeps every existing
80
+ * non-TTY test passing unchanged, by construction rather than by convention.
81
+ */
82
+ color?: boolean;
83
+ }
84
+
85
+ /**
86
+ * One of the footer counts, saying how many of them never finished.
87
+ *
88
+ * The counts deliberately include spans that were still open when the capture
89
+ * ended — a query that never came back still happened and still cost the wait.
90
+ * But "40 queries" reads as forty completions to anyone who does not know that,
91
+ * and this line is the part of the report people quote. The qualifier travels
92
+ * with the number, the same way `atLeastMs` carries its own. It is left off
93
+ * entirely when nothing was open, which is almost every run.
94
+ */
95
+ function counted(trace: Trace, total: string, open: string, noun: string): string {
96
+ const stillOpen = trace.metrics[open] ?? 0;
97
+ const suffix = stillOpen > 0 ? ` (${stillOpen} still open)` : "";
98
+ return `${trace.metrics[total]} ${noun}${suffix}`;
99
+ }
100
+
101
+ export function renderReport(trace: Trace, options: RenderReportOptions = {}): string {
102
+ const color = options.color ?? false;
103
+ const lines: string[] = [];
104
+ const device = trace.device as Record<string, unknown>;
105
+ const hz = Number(device.refreshHz) || 60;
106
+
107
+ lines.push(
108
+ [
109
+ trace.scenario,
110
+ `${(trace.durationMs / 1000).toFixed(1)}s`,
111
+ `${device.model ?? "unknown device"} (${Math.round(hz)}Hz)`,
112
+ trace.app.packageName,
113
+ ]
114
+ .filter(Boolean)
115
+ .join(" · "),
116
+ );
117
+ lines.push("");
118
+
119
+ if (trace.findings.length === 0) {
120
+ lines.push(" nothing worth reporting");
121
+ }
122
+
123
+ // Severity order, not grouped by mark. Grouping reads well until the first
124
+ // marked run, where it drops an ERROR below two WARNINGs and defeats the one
125
+ // job of a prioritised list. The mark rides along on the line instead.
126
+ for (const finding of trace.findings) {
127
+ lines.push(` ${colorSeverity(finding.severity, color)} ${finding.title}`);
128
+ if (finding.during) lines.push(` during "${finding.during}"`);
129
+ if (finding.detail) lines.push(` ${finding.detail}`);
130
+ }
131
+
132
+ const quiet = CHECKED.filter((lane) =>
133
+ lane.keys.every((key) => (trace.metrics[key] ?? 0) === 0),
134
+ ).map((lane) => lane.label);
135
+
136
+ // Saying what was checked and found clean matters as much as the findings. A
137
+ // report that only ever lists problems gives no signal that the things it did
138
+ // not mention were looked at.
139
+ if (quiet.length > 0) {
140
+ lines.push("");
141
+ lines.push(` quiet: ${quiet.join(", ")}`);
142
+ }
143
+
144
+ lines.push("");
145
+ lines.push(
146
+ ` frame budget ${frameBudgetMs(hz)}ms · ` +
147
+ `${trace.metrics["recompose.total"]} recompositions · ` +
148
+ `${counted(trace, "http.calls", "http.stillOpen", "calls")} · ` +
149
+ `${counted(trace, "db.queries", "db.stillOpen", "queries")}`,
150
+ );
151
+ if (trace.marks.length > 0) lines.push(` ${trace.marks.length} marks`);
152
+ if (trace.driver) lines.push(` driver: ${trace.driver}`);
153
+
154
+ return lines.join("\n") + "\n";
155
+ }
156
+
157
+ export interface Change {
158
+ key: string;
159
+ before: number;
160
+ after: number;
161
+ kind: "new" | "regressed" | "improved" | "unchanged";
162
+ }
163
+
164
+ /** Metrics where a larger number is better. Everything else is the other way. */
165
+ const HIGHER_IS_BETTER = new Set<string>();
166
+
167
+ /**
168
+ * Below both of these, a difference is noise.
169
+ *
170
+ * Timing metrics move run to run. With only a relative floor a small absolute
171
+ * change looks enormous; with only an absolute one a large metric never moves
172
+ * enough. Without both, every run is a regression and the check gets switched
173
+ * off, which is the real failure mode.
174
+ */
175
+ const RELATIVE_FLOOR = 0.1;
176
+ const ABSOLUTE_FLOOR = 3;
177
+
178
+ export function compareMetrics(
179
+ before: Record<string, number>,
180
+ after: Record<string, number>,
181
+ ): Change[] {
182
+ const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
183
+
184
+ return keys.map((key) => {
185
+ const a = before[key] ?? 0;
186
+ const b = after[key] ?? 0;
187
+ const worse = HIGHER_IS_BETTER.has(key) ? b < a : b > a;
188
+
189
+ // Categorical, not a drift: the first main-thread query, the first failing
190
+ // call. No floor should hide one of these.
191
+ if (a === 0 && b > 0) return { key, before: a, after: b, kind: "new" as const };
192
+ if (a === b) return { key, before: a, after: b, kind: "unchanged" as const };
193
+
194
+ const absolute = Math.abs(b - a);
195
+ const relative = a === 0 ? 1 : absolute / a;
196
+ if (absolute < ABSOLUTE_FLOOR || relative < RELATIVE_FLOOR) {
197
+ return { key, before: a, after: b, kind: "unchanged" as const };
198
+ }
199
+
200
+ return { key, before: a, after: b, kind: worse ? "regressed" : "improved" };
201
+ });
202
+ }
203
+
204
+ /** Why two traces cannot honestly be compared, or null if they can. */
205
+ export function comparability(before: Trace, after: Trace): string | null {
206
+ if (before.scenario !== after.scenario) {
207
+ return `different scenarios: "${before.scenario}" and "${after.scenario}"`;
208
+ }
209
+
210
+ const a = before.device as Record<string, unknown>;
211
+ const b = after.device as Record<string, unknown>;
212
+
213
+ // Only a known difference counts. A capture that attached to an app already
214
+ // running may not have seen the device profile, and two absent values are not
215
+ // a mismatch — compared as numbers they become NaN !== NaN, which refuses
216
+ // every pair of traces that happen to be missing the same field.
217
+ const differs = (key: string): boolean => {
218
+ const left = a[key];
219
+ const right = b[key];
220
+ if (left === undefined || right === undefined) return false;
221
+ return left !== right;
222
+ };
223
+
224
+ if (differs("refreshHz")) {
225
+ return `different refresh rates: ${a.refreshHz}Hz and ${b.refreshHz}Hz — frame budgets differ`;
226
+ }
227
+ if (differs("cores")) {
228
+ return `different core counts: ${a.cores} and ${b.cores}`;
229
+ }
230
+ if (differs("lowRamDevice")) {
231
+ return "one capture is from a low-RAM device and the other is not";
232
+ }
233
+ return null;
234
+ }
235
+
236
+ export function renderComparison(
237
+ before: Trace,
238
+ after: Trace,
239
+ ): { text: string; regressed: boolean; refused: boolean } {
240
+ const blocked = comparability(before, after);
241
+ if (blocked) {
242
+ return {
243
+ text:
244
+ `refusing to compare: ${blocked}\n\n` +
245
+ " A number from two runs that were never comparable is worse than no\n" +
246
+ " number, because someone will act on it.\n",
247
+ regressed: false,
248
+ refused: true,
249
+ };
250
+ }
251
+
252
+ const changes = compareMetrics(before.metrics, after.metrics);
253
+ const notable = changes.filter((c) => c.kind !== "unchanged");
254
+ const unchanged = changes.length - notable.length;
255
+ const lines: string[] = [];
256
+
257
+ lines.push(`${after.scenario} · against a baseline of ${before.capturedAt}`);
258
+ lines.push("");
259
+
260
+ for (const change of notable) {
261
+ const label = change.kind === "improved" ? "improved " : change.kind.toUpperCase().padEnd(9);
262
+ const delta =
263
+ change.kind === "new"
264
+ ? "new"
265
+ : `(${change.after > change.before ? "+" : ""}${Math.round(((change.after - change.before) / (change.before || 1)) * 100)}%)`;
266
+ lines.push(` ${label} ${change.key.padEnd(24)} ${change.before} → ${change.after} ${delta}`);
267
+ }
268
+
269
+ if (notable.length === 0) lines.push(" nothing moved");
270
+ lines.push("");
271
+ lines.push(` unchanged: ${unchanged} other metrics`);
272
+
273
+ // An agentic driver reasons between steps and does not walk the same path
274
+ // twice, so its timings drift for reasons that are not the code's.
275
+ if (after.driver && after.driver !== before.driver) {
276
+ lines.push("");
277
+ lines.push(` note: drivers differ ("${before.driver ?? "?"}" then "${after.driver}")`);
278
+ }
279
+
280
+ return {
281
+ text: lines.join("\n") + "\n",
282
+ regressed: notable.some((c) => c.kind === "regressed" || c.kind === "new"),
283
+ refused: false,
284
+ };
285
+ }
package/src/save.ts ADDED
@@ -0,0 +1,322 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { DeviceEvent } from "./device.js";
6
+ import { buildTrace, resolveProfile, type ResolvedProfile, type Trace } from "./trace.js";
7
+ import {
8
+ clippedMsOf,
9
+ fillWindowFromDisk,
10
+ listAllSessions,
11
+ sessionSizeBytes,
12
+ type ClippedMs,
13
+ type SessionMetaWithDir,
14
+ } from "./sessions.js";
15
+
16
+ /**
17
+ * GRA-54: turning a slice of what already happened into a named artifact,
18
+ * after the fact.
19
+ *
20
+ * Every other way Porthole produces a durable trace requires deciding to
21
+ * record in advance — `capture` wraps a command, `capture_system_trace`
22
+ * blocks for a fixed duration. Once GRA-53 put sessions on disk, the
23
+ * material for "something bad just happened, keep it" already exists; this
24
+ * module is the one place that turns a window someone already asked about
25
+ * (through `findings`, say) into a trace file, called from both the MCP tool
26
+ * `save_moment` (index.ts, which already has a live device's identity and a
27
+ * merged view to hand in) and the CLI's `porthole save` (which has neither,
28
+ * and resolves both from disk alone — see `saveFromSessions` below).
29
+ *
30
+ * Deliberately thin, on purpose, twice over:
31
+ *
32
+ * - `buildTrace` (trace.ts) is the exact analyser `capture` and the live
33
+ * `findings` tool both call. Byte-compatibility with `capture`'s output
34
+ * is therefore a consequence of calling the same function, not a goal
35
+ * pursued separately here — nothing in this module reimplements a
36
+ * metric, a finding, or a percentile.
37
+ * - The events handed to `buildSavedTrace` always already come from
38
+ * `fillWindowFromDisk` (sessions.ts) — the same merge `findings`,
39
+ * `what_was_happening` and `timeline` all use. This module does not read
40
+ * `events.ndjson` itself and does not merge live and disk data a second
41
+ * way; `saveFromSessions`'s own disk-only use of that same function is
42
+ * the CLI's one exception to "always has a live device", not a new
43
+ * mechanism.
44
+ */
45
+
46
+ /** Recorded on every trace this module builds, so a reader can tell it apart from a live `capture`. Ruling 3. */
47
+ export const SAVE_DRIVER = "session";
48
+
49
+ export interface SaveWindow {
50
+ from: number;
51
+ to: number;
52
+ }
53
+
54
+ /** `Trace`, plus the one field `capture`'s own output never carries: how much of the requested window was actually recorded. */
55
+ export interface SavedTrace extends Trace {
56
+ clippedMs: ClippedMs;
57
+ }
58
+
59
+ /**
60
+ * Ruling 1 / EM's open-question-2, ratified by the coordinator: a saved
61
+ * moment gets a name even when nobody gave it one, because a required
62
+ * `--scenario`/`scenario` is friction the ticket exists to remove. On the
63
+ * uptime clock the window itself is already expressed in, so two saves of
64
+ * the same window land on the same default name rather than one that
65
+ * depends on wall-clock time nobody asked about.
66
+ */
67
+ export function defaultScenarioName(from: number, to: number): string {
68
+ return `moment-${from}-${to}`;
69
+ }
70
+
71
+ /**
72
+ * Ruling 1: the default output path — the same `.porthole/traces/`
73
+ * directory `capture_system_trace` already writes under, so a saved moment
74
+ * and a system trace from the same investigation land next to each other.
75
+ */
76
+ export function defaultOutPath(projectRoot: string, scenario: string): string {
77
+ return path.join(projectRoot, ".porthole", "traces", `${validateScenario(scenario)}.json`);
78
+ }
79
+
80
+ /** Thrown by [validateScenario]; callers turn it into their own refusal (400, `fail()`, exit 2). */
81
+ export class InvalidScenarioError extends Error {
82
+ constructor(message: string) {
83
+ super(message);
84
+ this.name = "InvalidScenarioError";
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The scenario becomes a file name under `.porthole/traces/`, and it arrives
90
+ * from a POST body, a tool argument or a CLI flag — none of which is trusted
91
+ * to stay inside that directory on its own. GRA-116's QA showed
92
+ * `"../../../../tmp/evil"` escaping it. Letters, digits, space, dot, dash
93
+ * and underscore only; no separators of either kind; no name made of dots;
94
+ * a length that still fits a file name comfortably.
95
+ */
96
+ export function validateScenario(scenario: string): string {
97
+ const trimmed = scenario.trim();
98
+ if (trimmed === "") throw new InvalidScenarioError("scenario must not be empty.");
99
+ if (trimmed.length > 120) throw new InvalidScenarioError("scenario must be 120 characters or fewer.");
100
+ if (!/^[A-Za-z0-9 ._-]+$/.test(trimmed)) {
101
+ throw new InvalidScenarioError(
102
+ "scenario may contain only letters, digits, spaces, dots, dashes and underscores — no path separators.",
103
+ );
104
+ }
105
+ if (/^\.+$/.test(trimmed)) throw new InvalidScenarioError("scenario must not be made of dots only.");
106
+ return trimmed;
107
+ }
108
+
109
+ export interface BuildSavedTraceOptions {
110
+ events: DeviceEvent[];
111
+ hello: Record<string, unknown> | null;
112
+ window: SaveWindow;
113
+ /** From the same merged view's `coveredFrom`/`coveredTo` — see `fillWindowFromDisk`. */
114
+ coveredFrom: number | null;
115
+ coveredTo: number | null;
116
+ scenario: string;
117
+ /** GRA-185: resolved by the caller through `resolveProfile` — see that function's own doc comment for why every caller resolves it the same way rather than this module doing it a second time. */
118
+ profile: ResolvedProfile;
119
+ }
120
+
121
+ /**
122
+ * Ruling 3: calls `buildTrace` exactly as `capture` does, with
123
+ * `driver: "session"` and `withEvents: false` always — `--with-events` is
124
+ * cut from this ticket (the EM's own recommendation, taken): a saved moment
125
+ * sits right next to the session file it came from on disk, so embedding a
126
+ * copy of the same events inside the trace would only double the bytes to
127
+ * hand back something already there.
128
+ *
129
+ * Ruling 4: `clippedMs` is computed by the exact function `findings` uses
130
+ * (`clippedMsOf`, sessions.ts) — not a second, hand-rolled copy of the same
131
+ * formula.
132
+ */
133
+ export function buildSavedTrace(options: BuildSavedTraceOptions): SavedTrace {
134
+ const trace = buildTrace({
135
+ scenario: options.scenario,
136
+ driver: SAVE_DRIVER,
137
+ events: options.events,
138
+ hello: options.hello,
139
+ durationMs: Math.max(0, options.window.to - options.window.from),
140
+ withEvents: false,
141
+ profile: options.profile,
142
+ });
143
+ return {
144
+ ...trace,
145
+ clippedMs: clippedMsOf(options.window.from, options.window.to, options.coveredFrom, options.coveredTo),
146
+ };
147
+ }
148
+
149
+ /** Writes the trace to `outPath`, creating the directory if it does not exist yet. */
150
+ export async function writeSavedTrace(trace: SavedTrace, outPath: string): Promise<void> {
151
+ await mkdir(path.dirname(outPath), { recursive: true });
152
+ await writeFile(outPath, JSON.stringify(trace, null, 2));
153
+ }
154
+
155
+ /**
156
+ * Ruling 4: "the tool's summary line states the coverage in words when it
157
+ * is not complete." One sentence, or "" when the window was fully covered
158
+ * — left off entirely rather than a hollow "0.0s was not recorded", the
159
+ * same convention `findings`' own `missing` string in index.ts already
160
+ * uses.
161
+ */
162
+ export function coverageNote(clipped: ClippedMs): string {
163
+ const shortfallMs = clipped.start + clipped.end;
164
+ if (shortfallMs <= 0) return "";
165
+ const seconds = Math.round(shortfallMs / 100) / 10;
166
+ return ` ${seconds}s of the requested window was never recorded and is not in this trace.`;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // the CLI's disk-only path — no live device, so both "which session" and
171
+ // "what does --since count back from" have to come from what is on disk
172
+ // ---------------------------------------------------------------------------
173
+
174
+ export interface SaveFromSessionsOptions {
175
+ root: string;
176
+ projectRoot: string;
177
+ scenario?: string;
178
+ sinceMs?: number;
179
+ from?: number;
180
+ to?: number;
181
+ out?: string;
182
+ }
183
+
184
+ export interface CommandResult {
185
+ /** Process exit code: 0 success, 1 nothing to act on, 2 bad input. */
186
+ code: number;
187
+ message: string;
188
+ }
189
+
190
+ /**
191
+ * `porthole save`'s implementation. Unlike `save_moment` (index.ts), a
192
+ * fresh CLI invocation has no live device and no live buffer to ask "what
193
+ * does --since count back from" of — so both the identity to search and,
194
+ * for `--since`, the anchor the lookback counts from, are resolved from
195
+ * whichever session on disk was most recently written to. That is a real
196
+ * limitation worth naming plainly: with more than one app or device
197
+ * recording into the same sessions root, this picks the busiest one, not
198
+ * necessarily the one the caller meant. `--from`/`--to` (quoted from an
199
+ * earlier `findings` result) sidestep the ambiguity for the window itself,
200
+ * but still use the same most-recently-active session to decide *whose*
201
+ * sessions to search — there is no `--package`/`--device` flag on this
202
+ * command to disambiguate further (not asked for by the ticket, and adding
203
+ * one is a decision, not a bug fix).
204
+ */
205
+ export async function saveFromSessions(options: SaveFromSessionsOptions): Promise<CommandResult> {
206
+ const sessions = await listAllSessions(options.root);
207
+ const withData = sessions.filter(
208
+ (session): session is SessionMetaWithDir & { firstT: number; lastT: number } =>
209
+ session.firstT !== null && session.lastT !== null,
210
+ );
211
+ if (withData.length === 0) {
212
+ return {
213
+ code: 1,
214
+ message: "No sessions recorded on disk yet. Run `porthole ui` or `porthole mcp` against the app first.",
215
+ };
216
+ }
217
+
218
+ // Most recently *active*, not most recently *started* — a session open
219
+ // for hours with a fresh event just now is the one worth calling "now",
220
+ // regardless of when it began.
221
+ const latest = withData.reduce((a, b) => (b.lastT > a.lastT ? b : a));
222
+ const identity = { packageName: latest.packageName, deviceId: latest.deviceId };
223
+
224
+ const to = options.to ?? latest.lastT;
225
+ const from = options.from ?? (options.sinceMs !== undefined ? to - options.sinceMs : latest.firstT);
226
+
227
+ const merged = await fillWindowFromDisk({
228
+ root: options.root,
229
+ identity,
230
+ buffered: [],
231
+ currentSessionDir: null,
232
+ from,
233
+ to,
234
+ });
235
+
236
+ let scenario: string;
237
+ let outPath: string;
238
+ try {
239
+ scenario = options.scenario === undefined ? defaultScenarioName(from, to) : validateScenario(options.scenario);
240
+ outPath = options.out ?? defaultOutPath(options.projectRoot, scenario);
241
+ } catch (error) {
242
+ if (error instanceof InvalidScenarioError) return { code: 2, message: `porthole save: ${error.message}` };
243
+ throw error;
244
+ }
245
+ const hello: Record<string, unknown> = {
246
+ packageName: latest.packageName,
247
+ versionName: latest.versionName,
248
+ device: latest.device,
249
+ sdkInt: latest.sdkInt,
250
+ };
251
+ // GRA-185: no live buffer here — this is the CLI's disk-only path — so the
252
+ // only source `resolveProfile` has beyond the 60Hz fallback is whatever
253
+ // `latest`'s own `meta.json` recorded (`SessionWriter.append` captured it
254
+ // when the session was written, if it was written after that existed).
255
+ const profile = resolveProfile({
256
+ liveEvents: [],
257
+ windowTo: to,
258
+ sessionProfile: latest.profile ?? null,
259
+ hello,
260
+ });
261
+ const trace = buildSavedTrace({
262
+ events: merged.events as unknown as DeviceEvent[],
263
+ hello,
264
+ window: { from, to },
265
+ coveredFrom: merged.coveredFrom,
266
+ coveredTo: merged.coveredTo,
267
+ scenario,
268
+ profile,
269
+ });
270
+ await writeSavedTrace(trace, outPath);
271
+
272
+ return {
273
+ code: 0,
274
+ message: `saved "${scenario}" (${trace.findings.length} finding(s)) to ${outPath}.${coverageNote(trace.clippedMs)}`,
275
+ };
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // `porthole sessions`
280
+ // ---------------------------------------------------------------------------
281
+
282
+ function formatBytes(bytes: number): string {
283
+ if (bytes < 1024) return `${bytes}B`;
284
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
285
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
286
+ }
287
+
288
+ function eventCount(session: SessionMetaWithDir): number {
289
+ return Object.values(session.eventCounts).reduce((sum, n) => sum + n, 0);
290
+ }
291
+
292
+ /**
293
+ * `porthole sessions`'s implementation. "current" is whichever session was
294
+ * most recently written to (greatest `updatedAt`) — purely a fact about
295
+ * what is on disk, deliberately not "is a device connected right now": a
296
+ * one-shot CLI invocation with no live `DeviceClient` has no cheap, honest
297
+ * way to answer that question without opening a socket and waiting on a
298
+ * device that may not even be plugged in, which is a different (and
299
+ * heavier) command than "list what is already recorded".
300
+ */
301
+ export async function listSessionsText(root: string): Promise<CommandResult> {
302
+ const sessions = await listAllSessions(root);
303
+ if (sessions.length === 0) {
304
+ return { code: 0, message: "No sessions recorded on disk yet." };
305
+ }
306
+
307
+ const current = sessions.reduce((a, b) => (b.updatedAt > a.updatedAt ? b : a));
308
+
309
+ const lines: string[] = [];
310
+ for (const session of sessions) {
311
+ const marker = session.dir === current.dir ? "*" : " ";
312
+ const started = new Date(session.createdAt).toISOString();
313
+ const firstT = session.firstT ?? "-";
314
+ const lastT = session.lastT ?? "-";
315
+ const bytes = await sessionSizeBytes(session.dir);
316
+ lines.push(
317
+ `${marker} ${session.packageName} ${session.deviceId} started ${started} ` +
318
+ `t=[${firstT},${lastT}] events=${eventCount(session)} ${formatBytes(bytes)} ${session.dir}`,
319
+ );
320
+ }
321
+ return { code: 0, message: lines.join("\n") };
322
+ }