@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.
- 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-DtnyBXCM.css +1 -0
- package/ui/dist/assets/index-h7VNB9Fl.js +70 -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/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
|
+
}
|