@keepsnappy/agent 0.1.0-alpha.16 → 0.1.0-alpha.18

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.
@@ -212,6 +212,15 @@ class ConfigError extends Error {
212
212
  this.name = "ConfigError";
213
213
  }
214
214
  }
215
+
216
+ class UsageError extends ConfigError {
217
+ constructor(lines) {
218
+ super(lines);
219
+ this.message = lines.join(`
220
+ `);
221
+ this.name = "UsageError";
222
+ }
223
+ }
215
224
  var TITLE_MAX = 200;
216
225
  var DESCRIPTION_MAX = 2000;
217
226
  var TOP = ["project", "app", "platform", "device", "repetitions", "flows", "steps", "output", "timeout_seconds"];
@@ -2189,6 +2198,7 @@ function isComponentConfiguration(value) {
2189
2198
  }
2190
2199
 
2191
2200
  // packages/facts/dist/src/places.js
2201
+ var COMMIT_IDENTITY_KINDS = ["screen", "press", "drag", "other"];
2192
2202
  var placeWithoutVisit = (id) => placeRef(id).stem.replace(/#(\d+|repeat)$/, "");
2193
2203
  var AFTER_3S = "+3s";
2194
2204
  var kindOf = (stem) => {
@@ -2314,7 +2324,7 @@ function withLaunchNames(windows) {
2314
2324
  }
2315
2325
 
2316
2326
  // packages/derive/dist/src/model.js
2317
- var DERIVATION_VERSION = 27;
2327
+ var DERIVATION_VERSION = 28;
2318
2328
  // packages/stats/dist/src/core/rng.js
2319
2329
  function splitmix32(seed) {
2320
2330
  let s = seed >>> 0;
@@ -3171,6 +3181,44 @@ function estimateClockOffset(markers, windows, byOrder = false) {
3171
3181
  const negatives = gaps.filter((g) => g < 0);
3172
3182
  return negatives.length * 2 > starts.length ? Math.min(...negatives) : 0;
3173
3183
  }
3184
+ var ulp = (x) => x === 0 ? Number.MIN_VALUE : 2 ** (Math.floor(Math.log2(Math.abs(x))) - 52);
3185
+ function fitClockOffset(markers, windows, offset, errorMs) {
3186
+ if (!(errorMs > 0))
3187
+ return offset;
3188
+ const inputs = windows.map(inputWall).filter((x) => x !== undefined).sort((a, b) => a - b);
3189
+ const ss = spans(markers, 0).sort((a, b) => a.markerStart - b.markerStart);
3190
+ const score = (o) => {
3191
+ let started = 0;
3192
+ let inside = 0;
3193
+ for (const [i, s] of ss.entries()) {
3194
+ const next = ss[i + 1]?.markerStart;
3195
+ const t = inputs.find((x) => x >= s.markerStart + o);
3196
+ if (t === undefined || next !== undefined && t >= next + o)
3197
+ continue;
3198
+ started += 1;
3199
+ const end = Math.min(s.markerEnd ?? Infinity, next ?? Infinity) + o;
3200
+ inside += inputs.filter((x) => x >= s.markerStart + o && x < end).length;
3201
+ }
3202
+ return [started, inside];
3203
+ };
3204
+ let best = offset;
3205
+ let top = score(offset);
3206
+ for (const [i, s] of ss.entries()) {
3207
+ const prevEnd = ss[i - 1]?.markerEnd;
3208
+ for (const t of inputs) {
3209
+ if (prevEnd !== undefined && t >= prevEnd + offset && t - (prevEnd + offset) <= s.markerStart + offset - t)
3210
+ continue;
3211
+ const o = t - s.markerStart - ulp(t);
3212
+ if (o >= offset || o < offset - errorMs)
3213
+ continue;
3214
+ const sc = score(o);
3215
+ const better = sc[0] > top[0] || sc[0] === top[0] && (sc[1] > top[1] || sc[1] === top[1] && o > best);
3216
+ if (better)
3217
+ [best, top] = [o, sc];
3218
+ }
3219
+ }
3220
+ return best;
3221
+ }
3174
3222
  function spans(markers, offset) {
3175
3223
  const sorted = [...markers].sort((a, b) => a.t_host - b.t_host);
3176
3224
  const out = [];
@@ -3197,7 +3245,8 @@ function ownWindowEnd(windows, t) {
3197
3245
  }
3198
3246
  function alignSteps(markers, windows, opts = {}) {
3199
3247
  const byOrder = opts.byOrder === true;
3200
- const offset = opts.clockOffsetMs ?? estimateClockOffset(markers, windows, byOrder);
3248
+ const given = opts.clockOffsetMs;
3249
+ const offset = given === undefined ? estimateClockOffset(markers, windows, byOrder) : byOrder && opts.clockErrorMs ? fitClockOffset(markers, windows, given, opts.clockErrorMs) : given;
3201
3250
  const ss = spans(markers, offset).sort((a, b) => a.markerStart - b.markerStart);
3202
3251
  const inputs = windows.map(inputWall).filter((x) => x !== undefined).sort((a, b) => a - b);
3203
3252
  const starts = ss.map((s, i) => {
@@ -3247,7 +3296,7 @@ function clusters(windows) {
3247
3296
  }
3248
3297
  return out;
3249
3298
  }
3250
- function onAppClock(windows, version) {
3299
+ function processSegments(windows, version) {
3251
3300
  const order = windows.map((w, i) => ({ w, i })).sort((a, b) => a.w.endWall - b.w.endWall || a.i - b.i);
3252
3301
  const segment = new Map;
3253
3302
  let seg = 0;
@@ -3265,24 +3314,35 @@ function onAppClock(windows, version) {
3265
3314
  prevId = id;
3266
3315
  segment.set(w, seg);
3267
3316
  }
3317
+ return segment;
3318
+ }
3319
+ function onAppClock(windows, version) {
3320
+ const process2 = processSegments(windows, version);
3321
+ const step = (w) => version >= 28 ? w.clockStep : undefined;
3268
3322
  const offset = new Map;
3269
- for (const [w, s] of segment) {
3323
+ for (const [w, seg] of process2) {
3270
3324
  const t1 = num(w.raw.t1);
3271
3325
  if (t1 === undefined)
3272
3326
  continue;
3273
- const o = w.endWall - t1;
3274
- if (!offset.has(s) || o < offset.get(s))
3275
- offset.set(s, o);
3327
+ const st = step(w);
3328
+ if (st && w.endWall - t1 < st.floor)
3329
+ continue;
3330
+ const o = w.endWall - t1 - (st?.end ?? 0);
3331
+ if (!offset.has(seg) || o < offset.get(seg))
3332
+ offset.set(seg, o);
3276
3333
  }
3277
3334
  return windows.map((w) => {
3278
- const s = segment.get(w);
3279
- const o = s === undefined ? undefined : offset.get(s);
3280
- const t0 = num(w.raw.t0);
3335
+ const seg = process2.get(w);
3281
3336
  const t1 = num(w.raw.t1);
3337
+ const own = seg === undefined || t1 === undefined ? undefined : w.endWall - t1 - (step(w)?.end ?? 0);
3338
+ const o = seg === undefined ? undefined : offset.get(seg) ?? own;
3339
+ const t0 = num(w.raw.t0);
3282
3340
  if (o === undefined || t0 === undefined || t1 === undefined)
3283
3341
  return w;
3342
+ const st = step(w);
3343
+ const [atStart, atEnd] = [st?.start ?? 0, st?.end ?? 0];
3284
3344
  const touch = version >= 23 ? num(obj2(w.raw.interaction).touchStartT) : undefined;
3285
- return { ...w, startWall: t0 + o, endWall: t1 + o, ...touch === undefined ? {} : { touchWall: touch + o } };
3345
+ return { ...w, startWall: t0 + o + atStart, endWall: t1 + o + atEnd, ...touch === undefined ? {} : { touchWall: touch + o + atStart } };
3286
3346
  });
3287
3347
  }
3288
3348
 
@@ -3674,7 +3734,7 @@ function profileMetrics(windows, steps, options = {}) {
3674
3734
  const kind = placeRef(key).kind;
3675
3735
  const active = occ.filter((o) => !o.idle);
3676
3736
  const selected = middleBy((active.length ? active : occ).filter((o) => o.window), (o) => o.commitMaxMs, (o) => num2(o.window.raw.id) ?? 0, (o) => num2(o.window.raw.t0) ?? o.window.startWall);
3677
- const identity = options.commitIdentity && metrics.has(`${key}.commit_max_ms`) && ["screen", "press", "drag", "other"].includes(kind) && selected?.slowestCommit;
3737
+ const identity = options.commitIdentity && metrics.has(`${key}.commit_max_ms`) && COMMIT_IDENTITY_KINDS.includes(kind) && selected?.slowestCommit;
3678
3738
  const openedBy = selected?.window ? options.openedBy?.get(selected.window) : undefined;
3679
3739
  profiles.push({ ...profile, ...identity ? { slowestCommit: identity } : {}, ...openedBy ? { openedBy } : {} });
3680
3740
  }
@@ -3820,7 +3880,7 @@ function withActionWindows(windows, steps = [], version) {
3820
3880
  }
3821
3881
  const durMs = w.raw.endReason === "timeout" ? undefined : num3(w.raw.durMs);
3822
3882
  const raw = { ...rest, name: w.name, kind: w.raw.kind, t0: w.raw.t0, t1: w.raw.t1, ...durMs === undefined ? {} : { durMs }, endReason: a.endReason, sdkEndReason: w.raw.endReason, actionSpanMs: a.spanMs, actionUncoveredMs: a.uncoveredMs };
3823
- return { name: w.name, startWall: w.startWall, endWall: w.endWall, raw };
3883
+ return { name: w.name, startWall: w.startWall, endWall: w.endWall, raw, ...w.clockStep === undefined ? {} : { clockStep: w.clockStep } };
3824
3884
  });
3825
3885
  }
3826
3886
  function actionMetrics(windows, steps = [], version) {
@@ -4933,7 +4993,7 @@ function keyFirstOpens(windows) {
4933
4993
  }
4934
4994
 
4935
4995
  // packages/derive/dist/src/derive.js
4936
- var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 26, 27];
4996
+ var DERIVATION_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 26, 27, 28];
4937
4997
  var windowsForVersion = (windows, version = DERIVATION_VERSION) => version >= 22 ? keyFirstOpens(windows) : windows;
4938
4998
  function stepWindowsFor(windows, version = DERIVATION_VERSION) {
4939
4999
  return windowsForVersion(version >= 16 ? withLaunchNames(windows).filter((w) => !isBackgroundWindow(w)) : windows, version);
@@ -4969,7 +5029,7 @@ function profileOptions(windows, version, processWindows = windows) {
4969
5029
  return { fullRowMs: FULL_ROW_MS, commitIdentity: processes.size <= 1, openedBy };
4970
5030
  }
4971
5031
  function deriveRun(input) {
4972
- const { run, markers = [], ri, clockOffsetMs, samples = [], memWindow } = input;
5032
+ const { run, markers = [], ri, clockOffsetMs, clockErrorMs, samples = [], memWindow } = input;
4973
5033
  const version = input.derivationVersion ?? DERIVATION_VERSION;
4974
5034
  if (!DERIVATION_VERSIONS.includes(version))
4975
5035
  throw new Error(`unknown derivation version ${String(version)}`);
@@ -4978,7 +5038,7 @@ function deriveRun(input) {
4978
5038
  const cpu = input.cpu && renamed.size ? new Map([...input.cpu].map(([k, v]) => [renamed.get(k) ?? k, v])) : input.cpu;
4979
5039
  const background = version >= 16 ? named.filter(isBackgroundWindow) : [];
4980
5040
  const sdkWindows = stepWindowsFor(input.windows, version);
4981
- const off = clockOffsetMs === undefined ? {} : { clockOffsetMs };
5041
+ const off = { ...clockOffsetMs === undefined ? {} : { clockOffsetMs }, ...version >= 28 && clockErrorMs !== undefined ? { clockErrorMs } : {} };
4982
5042
  const preSteps = version >= 11 && markers.length ? alignRunSteps(markers, sdkWindows, off, version) : [];
4983
5043
  const windows = version >= 11 ? withActionWindows(sdkWindows, preSteps, version) : sdkWindows;
4984
5044
  const steps = version >= 11 ? preSteps : alignRunSteps(markers, windows, clockOffsetMs === undefined ? {} : { clockOffsetMs }, version);
@@ -5141,100 +5201,6 @@ var markerEnv = (url, runId) => ({
5141
5201
  MAESTRO_KEEPSNAPPY_RUN_ID: runId
5142
5202
  });
5143
5203
 
5144
- // apps/agent/src/upload.ts
5145
- import { join as join4 } from "node:path";
5146
- var MAX_RATE_RETRIES = 5;
5147
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5148
- class UploadError extends Error {
5149
- }
5150
- async function call(f, base, path, headers, body, method = "POST") {
5151
- let res;
5152
- for (let attempt = 0;; attempt++) {
5153
- try {
5154
- res = await f(`${base.replace(/\/+$/, "")}${path}`, {
5155
- method,
5156
- headers: method === "POST" ? { "content-type": "application/json", ...headers } : headers,
5157
- ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
5158
- });
5159
- } catch (e) {
5160
- throw new UploadError(`${method} ${path}: ${e.message}`);
5161
- }
5162
- if (res.status !== 429 || attempt >= MAX_RATE_RETRIES)
5163
- break;
5164
- const wait = Number(res.headers.get("retry-after") ?? "1");
5165
- await res.text().catch(() => "");
5166
- await sleep(Math.min(Math.max(Number.isFinite(wait) ? wait : 1, 1), 60) * 1000);
5167
- }
5168
- const text = await res.text();
5169
- if (res.status < 200 || res.status >= 300)
5170
- throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
5171
- try {
5172
- return text ? JSON.parse(text) : {};
5173
- } catch {
5174
- return {};
5175
- }
5176
- }
5177
- async function uploadRuns(deps, outDir, runs, opts) {
5178
- const f = opts.fetch ?? fetch;
5179
- const size = opts.batchSize ?? 200;
5180
- const skipped = [];
5181
- let uploaded = 0;
5182
- for (const r of runs) {
5183
- if (!r.valid) {
5184
- skipped.push({ run_id: r.run_id, why: r.invalid_reason || "invalid" });
5185
- continue;
5186
- }
5187
- const log = await deps.fs.readFile(join4(outDir, r.run_id, "keepsnappy.log"));
5188
- const windows = windowsInRun(parseWindows(log), r.t0, r.t1);
5189
- if (windows.length === 0) {
5190
- skipped.push({ run_id: r.run_id, why: "no-windows" });
5191
- continue;
5192
- }
5193
- const externalId = `${opts.externalPrefix}-${r.run_id}`;
5194
- const created = await call(f, opts.ingestUrl, "/v1/runs", { "x-keepsnappy-key": opts.key }, {
5195
- external_id: externalId,
5196
- started_at: r.t0,
5197
- ended_at: r.t1,
5198
- device: { ...opts.device?.model ? { model: opts.device.model } : {}, ...opts.device?.os ? { os: opts.device.os } : {}, ...opts.device?.id ? { id: opts.device.id } : {}, exclusive: true },
5199
- meta: {
5200
- arm: r.arm,
5201
- driver: opts.driver ?? "keepsnappy-run",
5202
- ...opts.meta,
5203
- ...r.scenario ? { scenario: r.scenario } : {},
5204
- ...opts.runSetId ? { run_set_id: opts.runSetId } : {}
5205
- }
5206
- });
5207
- const runId = String(created.run_id ?? "");
5208
- const token = String(created.run_token ?? "");
5209
- if (!runId || !token)
5210
- throw new UploadError("POST /v1/runs: response has no run_id/run_token");
5211
- const items = windows.map((w) => `${w.endWall} KEEPSNAPPY window ${JSON.stringify(w.raw)}`);
5212
- for (let i = 0;i * size < items.length; i++) {
5213
- await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
5214
- batch_id: `${externalId}-${String(i + 1).padStart(4, "0")}`,
5215
- items: items.slice(i * size, (i + 1) * size)
5216
- });
5217
- }
5218
- const samples = samplesInRun(parseSamples(log), r.t0, r.t1).map((x) => `${x.wall} KEEPSNAPPY sample ${JSON.stringify(x.raw)}`);
5219
- for (let i = 0;i * size < samples.length; i++) {
5220
- await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
5221
- batch_id: `${externalId}-s${String(i + 1).padStart(4, "0")}`,
5222
- items: samples.slice(i * size, (i + 1) * size)
5223
- });
5224
- }
5225
- let markers = [];
5226
- try {
5227
- markers = parseMarkersNdjson(await deps.fs.readFile(join4(outDir, r.run_id, "markers.ndjson")));
5228
- } catch {}
5229
- for (const m of markers) {
5230
- await call(f, opts.ingestUrl, "/v1/markers", { authorization: `Bearer ${token}` }, { step: m.step, phase: m.phase, t_host: m.t_host });
5231
- }
5232
- await call(f, opts.ingestUrl, `/v1/runs/${runId}/finish`, { "x-keepsnappy-key": opts.key }, {});
5233
- uploaded++;
5234
- }
5235
- return { uploaded, skipped };
5236
- }
5237
-
5238
5204
  // packages/present/dist/src/capital.js
5239
5205
  var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5240
5206
 
@@ -5276,6 +5242,12 @@ var STATE_WORDS = {
5276
5242
  var INFO_WORDS = { heat: "Phone heated up" };
5277
5243
  var isWorseState = (state) => state === "worse";
5278
5244
  var NOT_BLOCKING = "not blocking";
5245
+ var badgedWord = (word, blocking) => `${word}${notBlockingBadge(word, blocking)}`;
5246
+ var notBlockingBadge = (word, blocking) => word === STATE_WORDS.worse && blocking === false ? ` (${NOT_BLOCKING})` : "";
5247
+ var BLOCKED_WORD = "Blocked";
5248
+ var NOT_COMPUTED_WORD = "Verdict not computed yet (computed on demand)";
5249
+ var NO_BASELINE_WORD = "Waiting for main";
5250
+ var NOT_JUDGED_WORD = "Not judged";
5279
5251
 
5280
5252
  // packages/present/dist/src/values.js
5281
5253
  var NO_VALUE = "–";
@@ -5304,17 +5276,23 @@ function roundTo(v, decimals) {
5304
5276
  return Number(magnitude.toFixed(decimals)) * Math.sign(v);
5305
5277
  return Number(`${Math.round(scaled)}e-${decimals}`) * Math.sign(v);
5306
5278
  }
5279
+ function written(unit, decimals, v) {
5280
+ if (unit === "ms" && Math.abs(v) >= LONG_MS && Math.abs(v) < 1e9)
5281
+ return { n: roundTo(v / 1000, 1) + 0, seconds: true };
5282
+ return { n: roundTo(v * shownUnit(unit).scale, decimals) + 0, seconds: false };
5283
+ }
5307
5284
  function format(unit, decimals, v) {
5308
5285
  if (v === null || v === undefined || !Number.isFinite(v))
5309
5286
  return NO_VALUE;
5287
+ const { n, seconds } = written(unit, decimals, v);
5288
+ if (seconds)
5289
+ return `${String(n)} s`;
5310
5290
  const shown = shownUnit(unit);
5311
- if (unit === "ms" && Math.abs(v) >= LONG_MS && Math.abs(v) < 1e9)
5312
- return `${String(roundTo(v / 1000, 1) + 0)} s`;
5313
- const text = String(roundTo(v * shown.scale, decimals) + 0);
5314
- return shown.unit ? `${text} ${shown.unit}` : text;
5291
+ return shown.unit ? `${String(n)} ${shown.unit}` : String(n);
5315
5292
  }
5293
+ var defOf = (measure) => isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
5316
5294
  function valueText(measure, v) {
5317
- const def = isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
5295
+ const def = defOf(measure);
5318
5296
  return format(def.unit, def.decimals, v);
5319
5297
  }
5320
5298
  var KEY_SEPARATOR = ":";
@@ -5348,6 +5326,47 @@ var JOURNEY_ROW_WORDS = {
5348
5326
  notInRun: upperFirst(GONE_TAILS.journey.notInRun),
5349
5327
  removed: upperFirst(GONE_TAILS.journey.removed)
5350
5328
  };
5329
+ var NEW_IN_PR_CHIP = "New in this PR";
5330
+ var placeNamesChangedTitle = (sdk) => `Place names changed with SDK ${sdk}`;
5331
+ var restartRowText = (sdk, mainSessions, needed) => `${placeNamesChangedTitle(sdk)}: ${restartContextText(mainSessions, needed)}`;
5332
+ var restartContextText = (mainSessions, needed) => `main has ${mainSessions} of ${needed} sessions on the new names`;
5333
+ function removedRowsText(n, page) {
5334
+ return { lead: `${plural(n, "component", "components")} ${GONE_TAILS[page].removed}:`, rest: " not counted." };
5335
+ }
5336
+ function notInRunRowsText(c, page) {
5337
+ const what = [c.screens ? plural(c.screens, "screen", "screens") : "", c.places ? `${c.places} ${c.places === 1 ? "tap or scroll" : "taps and scrolls"}` : ""].filter(Boolean).join(" and ");
5338
+ const them = c.screens + c.places === 1 ? "it" : "them";
5339
+ return { lead: `${what} ${GONE_TAILS[page].notInRun}:`, rest: ` the ${page === "journey" ? "newest session" : "journey"} did not reach ${them}; not counted.` };
5340
+ }
5341
+ var sdkTag = (version) => version.replace(/^\d+\.\d+\.\d+-/, "");
5342
+ var placeChartMarker = (tag) => `Place names changed with SDK ${tag}`;
5343
+ var placeNamesChangedOmitted = (tag) => `the line where place names changed (SDK ${tag})`;
5344
+ var componentChartMarker = (tag) => `SDK ${tag}: names changed, library time now counted`;
5345
+ var ownTimeOnlyBefore = (version) => `own time only, before SDK ${version}`;
5346
+ var placeWasText = (oldKey) => `before it this place was ${oldKey}`;
5347
+ var PLACE_NOT_JOINED_TEXT = "no recorded old name, its history starts here";
5348
+ var componentNotJoinedText = (sdk) => `${componentChartMarker(sdk)}; values before it were ${ownTimeOnlyBefore(sdk)}, not joined`;
5349
+ var OUTSIDE_APP_NAME = "Outside app components";
5350
+ var OTHER_RENDER_WORK_NAME = "Other render work";
5351
+ var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
5352
+ var rolledUpText = (rolledUpMs, totalMs) => `From libraries: ${valueText("self_ms", rolledUpMs)} of ${valueText("self_ms", totalMs)}`;
5353
+ var libraryLineText = (l) => `${l.name} · ${l.renders} rendered · ${valueText("self_ms", l.ms)}`;
5354
+ var rolledUpRestText = (rest) => `${plural(rest.n, "other library", "other libraries")} · ${valueText("self_ms", rest.ms)}`;
5355
+ var measureWords = (measure) => isMeasureId(measure) ? MEASURES[measure].label : `component ${BREAKDOWN_MEASURES[measure].label}`;
5356
+ var TIER_STATE = { worse: "worse", slight: "warning", better: "better", same: "no-change" };
5357
+ function leadBody(subject, measure, main, pr) {
5358
+ const unit = shownUnit(isMeasureId(measure) ? MEASURES[measure].unit : BREAKDOWN_MEASURES[measure].unit).unit;
5359
+ const to = valueText(measure, pr);
5360
+ const from = valueText(measure, main);
5361
+ const bare = unit && from.endsWith(` ${unit}`) && to.endsWith(` ${unit}`) ? from.slice(0, from.length - unit.length - 1) : from;
5362
+ return `${subject}, ${measureWords(measure)} ${bare} → ${to}`;
5363
+ }
5364
+ var screenLeadText = (tier, subject, measure, main, pr) => `${STATE_WORDS[TIER_STATE[tier]]}: ${leadBody(subject, measure, main, pr)}`;
5365
+ var FOR_INFORMATION = "For information";
5366
+ var informationLeadText = (subject, measure, main, pr) => `${FOR_INFORMATION}: ${leadBody(subject, measure, main, pr)}`;
5367
+ function foldedText(n, rows, page) {
5368
+ return `${n} ${GONE_TAILS[page][rows === "components" ? "removed" : "notInRun"]}`;
5369
+ }
5351
5370
  function placeShownName(p, o = {}) {
5352
5371
  const target = p.target ? componentShownName(p.target.name, p.target.file) : libraryViewName(p.kind, p.host ?? "");
5353
5372
  const named = p.target && p.parent ? `${target} in ${componentShownName(p.parent.name, p.parent.file)}${p.k ? `, ${ordinal(p.k)}` : ""}` : target;
@@ -5355,7 +5374,7 @@ function placeShownName(p, o = {}) {
5355
5374
  }
5356
5375
  var libraryViewName = (kind, hostType) => `${kind === "drag" ? "Scroll" : "Tap"} outside app components (${hostType})`;
5357
5376
  function componentShownName(name, file) {
5358
- if (name !== "default")
5377
+ if (name !== "default" || !file)
5359
5378
  return name;
5360
5379
  const path = file.replace(/\.[^.\/]+$/, "");
5361
5380
  const route = /^(?:src\/)?app\/(.*)$/.exec(path)?.[1];
@@ -5394,7 +5413,20 @@ var BACKGROUND_SOURCES = {
5394
5413
  touch: "work of a tap outside its window",
5395
5414
  input: "work of typing outside its window"
5396
5415
  };
5416
+ var OPEN_WORDS = { first: "first time", repeat: "again" };
5397
5417
  var OPEN_MARK = /#(1|repeat)$/;
5418
+ var isInputKind = (kind) => kind === "press" || kind === "drag";
5419
+ function placeOpen(id) {
5420
+ const ref = placeRef(id);
5421
+ if (!isInputKind(ref.kind))
5422
+ return null;
5423
+ const m = OPEN_MARK.exec(ref.stem);
5424
+ return m ? m[1] === "repeat" ? "repeat" : "first" : null;
5425
+ }
5426
+ var placeOpenWord = (id) => {
5427
+ const open = placeOpen(id);
5428
+ return open ? OPEN_WORDS[open] : null;
5429
+ };
5398
5430
  function placeIdShownName(id, o = {}) {
5399
5431
  const p = placeParts(id);
5400
5432
  if (!p)
@@ -5438,6 +5470,14 @@ function placeKindWord(id) {
5438
5470
  const { kind } = placeRef(id);
5439
5471
  return kind === "other" ? "Window" : KIND_WORD[kind];
5440
5472
  }
5473
+ function windowQualified(id) {
5474
+ const word = placeOpenWord(id);
5475
+ const label = placeLabel(id);
5476
+ if (!word)
5477
+ return label;
5478
+ const after = placeRef(id).segment === "after3s" ? ` · ${PLACE_KIND_WORDS.after3s}` : "";
5479
+ return `${after ? label.slice(0, -after.length) : label} (${word})${after}`;
5480
+ }
5441
5481
  var ELEMENT_SEPARATORS = /(?<!\d)\.|\.(?!\d)|[_-]+/g;
5442
5482
  var elementWords = (testId) => testId.replace(ELEMENT_SEPARATORS, " ").replace(/\s+/g, " ").trim();
5443
5483
  function placeLabel(id, screen) {
@@ -5456,27 +5496,59 @@ function placeLabel(id, screen) {
5456
5496
  const colon = name.indexOf(": ");
5457
5497
  return `${colon > 0 && ref.kind !== "other" ? `${name.slice(0, colon)} · ${name.slice(colon + 2)}` : name}${after}`;
5458
5498
  }
5459
- // packages/present/dist/src/words.js
5460
- var REASON_WORDS = {
5461
- "no-baseline": "there are no main runs on this phone and journey yet",
5462
- "too-few-runs": "there are too few valid runs on a side",
5463
- "judged-measures-missing": "the judged measures are missing on a side",
5464
- "measured-differently": "the sides were measured differently",
5465
- "runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
5466
- "facts-version-mismatch": "the sides were derived with different versions",
5467
- "not-judged-on-simulators": "timing is not judged on simulators",
5468
- "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
5469
- "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5470
- };
5471
- var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
5499
+ // packages/present/dist/src/placeNames.js
5500
+ function shortestPaths(files) {
5501
+ const out = new Map;
5502
+ for (const file of files) {
5503
+ const parts = file.split("/");
5504
+ let shown = file;
5505
+ for (let k = 1;k <= parts.length; k++) {
5506
+ const tail = parts.slice(-k).join("/");
5507
+ if (!files.some((f) => f !== file && f.split("/").slice(-k).join("/") === tail)) {
5508
+ shown = tail;
5509
+ break;
5510
+ }
5511
+ }
5512
+ out.set(file, shown);
5513
+ }
5514
+ return out;
5515
+ }
5472
5516
  // packages/present/dist/src/stability.js
5473
5517
  var UNSTABLE_WORDS = {
5474
5518
  short: "main varies too much here",
5475
5519
  label: `${STATE_WORDS["cant-tell"]} · main varies too much here`,
5476
5520
  line: "Main varies too much here to tell",
5477
5521
  ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
5478
- what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles."
5522
+ note: "This place varies a lot between runs. Worth checking why: it may be in the code.",
5523
+ what: "Main’s own runs vary too much here to tell, so a comparison there would mostly measure noise. It is shown with its values, never judged, never blocks and is never counted. It is judged again once main settles.",
5524
+ hint: {
5525
+ work: "The work differs between runs or sessions ({count} {min}–{max}): likely the code or the test steps.",
5526
+ speed: "The same work ran at different speeds ({count} {n} in every run): likely the phone or the session.",
5527
+ unclear: "The stored facts don’t show whether the work or the speed varies."
5528
+ }
5479
5529
  };
5530
+ function breakdownParts(ref) {
5531
+ const [kind, ...rest] = ref.split(":");
5532
+ return kind === "b" && rest.length >= 3 ? { key: rest.slice(1, -1).join(":"), measure: rest[rest.length - 1] } : null;
5533
+ }
5534
+ var countText = (n) => String(Math.round(n * 10) / 10);
5535
+ function countName(count, ref) {
5536
+ if (count !== "component.renders" && count !== "component.mounts")
5537
+ return MEASURES[count].short;
5538
+ const key = (ref ? breakdownParts(ref)?.key : undefined) ?? "component";
5539
+ return `${key} ${BREAKDOWN_MEASURES[count === "component.renders" ? "renders" : "mounts"].label}`;
5540
+ }
5541
+ function unstableHintLine(hint, ref) {
5542
+ if (hint.kind === "unclear" || hint.count === null || hint.range === null)
5543
+ return UNSTABLE_WORDS.hint.unclear;
5544
+ const count = countName(hint.count, ref);
5545
+ if (hint.kind === "work")
5546
+ return UNSTABLE_WORDS.hint.work.replace("{count}", count).replace("{min}", countText(hint.range[0])).replace("{max}", countText(hint.range[1]));
5547
+ return hint.median === null ? UNSTABLE_WORDS.hint.unclear : UNSTABLE_WORDS.hint.speed.replace("{count}", count).replace("{n}", countText(hint.median));
5548
+ }
5549
+ var unstableNoteWords = (u) => u.hint ? `${UNSTABLE_WORDS.note} ${unstableHintLine(u.hint, u.stabilityRef)}` : UNSTABLE_WORDS.note;
5550
+ var mainUnstableWords = (reason) => reason.kind === "noisy_main" ? unstableNoteWords(reason) : "";
5551
+
5480
5552
  // packages/present/dist/src/present.js
5481
5553
  var ASPECT_WORDS = {
5482
5554
  renderer: "React renderer",
@@ -5493,6 +5565,131 @@ var ASPECT_WORDS = {
5493
5565
  endGc: "memory clean-up at the journey end",
5494
5566
  cpuProfiling: "CPU profiling"
5495
5567
  };
5568
+ var measuredDifferentlyText = (aspects) => `measured differently (${aspects.map((a) => ASPECT_WORDS[a] ?? a).join(", ")})`;
5569
+
5570
+ // packages/present/dist/src/readWords.js
5571
+ function componentReadName(c) {
5572
+ const ref = (c.key ? parseComponentKey(c.key) : null) ?? parseComponentKey(c.name);
5573
+ return componentShownName(ref?.name ?? c.name, c.file ?? ref?.file ?? "");
5574
+ }
5575
+ function componentReadNames(cs) {
5576
+ const names = cs.map(componentReadName);
5577
+ const rawNames = cs.map((c) => (c.key ? parseComponentKey(c.key)?.name : undefined) ?? parseComponentKey(c.name)?.name ?? c.name);
5578
+ return cs.map((c, i) => {
5579
+ const equals = cs.filter((_, k) => rawNames[k] === rawNames[i]);
5580
+ const file = c.file ?? (c.key ? parseComponentKey(c.key)?.file : undefined);
5581
+ const files = equals.flatMap((x) => x.file ?? (x.key ? parseComponentKey(x.key)?.file ?? [] : []));
5582
+ return new Set(files).size > 1 && file ? `${names[i]} · ${shortestPaths(files).get(file)}` : names[i];
5583
+ });
5584
+ }
5585
+ function storedRefWords(ref, peers = []) {
5586
+ if (!ref.startsWith("b:component:"))
5587
+ return ref;
5588
+ const cut = ref.lastIndexOf(":");
5589
+ const key = ref.slice("b:component:".length, cut);
5590
+ const components = [ref, ...peers].filter((r) => r.startsWith("b:component:")).map((r) => {
5591
+ const componentKey = r.slice("b:component:".length, r.lastIndexOf(":"));
5592
+ return { key: componentKey, name: componentKey };
5593
+ });
5594
+ const name = componentReadNames(components)[0] ?? componentReadName({ key, name: key });
5595
+ return `${name}, ${measureWords(ref.slice(cut + 1))}`;
5596
+ }
5597
+ function resultContextLines(c, verbose = false, short = false) {
5598
+ if (!c)
5599
+ return [];
5600
+ const out = [];
5601
+ if (c.namesChanged)
5602
+ out.push(restartRowText(c.namesChanged.sdk, c.namesChanged.mainSessions, c.namesChanged.needed));
5603
+ for (const w of c.windows) {
5604
+ const shownPlace = `${windowQualified(w.place)}${verbose ? ` (${w.place})` : ""}`;
5605
+ if (w.slowestCommit)
5606
+ out.push(`${shownPlace}: ${MEASURES.commit_max_ms.label}: ${valueText("commit_max_ms", w.slowestCommit.main)} → ${valueText("commit_max_ms", w.slowestCommit.pr)}`);
5607
+ if (!w.cause)
5608
+ continue;
5609
+ const s = w.cause.subject;
5610
+ const subject = s.kind === "component" ? `${componentShownName(s.ref.name, s.ref.file)}${verbose ? ` (${s.ref.file})` : ""}` : placeShownName({ kind: placeParts(s.place)?.kind === "drag" ? "drag" : "press", target: s.target, parent: s.parent, k: s.k, ...s.host ? { host: s.host } : {} });
5611
+ out.push(`${shownPlace}: ${w.information || !w.tier ? informationLeadText(subject, w.cause.measure, w.cause.main, w.cause.pr) : screenLeadText(w.tier, subject, w.cause.measure, w.cause.main, w.cause.pr)}${w.cause.measuredDifferently?.length ? `; ${measuredDifferentlyText(w.cause.measuredDifferently)}` : ""}`);
5612
+ }
5613
+ const screens = new Map;
5614
+ for (const p of c.notInThisRun) {
5615
+ const parts = placeParts(p.place);
5616
+ const key = parts ? componentShownName(parts.screen.name, parts.screen.file ?? "") : "";
5617
+ screens.set(key, [...screens.get(key) ?? [], p]);
5618
+ }
5619
+ for (const [screen, rows] of screens) {
5620
+ const line = notInRunRowsText({ screens: rows.filter((p) => p.kind === "screen").length, places: rows.filter((p) => p.kind !== "screen").length }, c.page);
5621
+ out.push(`${screen ? `${screen}: ` : ""}${line.lead}${line.rest}`);
5622
+ if (rows.length > 3)
5623
+ out.push(` ${foldedText(rows.length, "places", c.page)}`);
5624
+ if (!short || rows.length <= 3)
5625
+ out.push(...rows.map((p) => ` ${windowQualified(p.place)}`));
5626
+ }
5627
+ const removed = new Map;
5628
+ for (const r of c.removed)
5629
+ removed.set(r.place, [...removed.get(r.place) ?? [], r]);
5630
+ for (const [place, rows] of removed) {
5631
+ const line = removedRowsText(rows.length, c.page);
5632
+ out.push(`${windowQualified(place)}: ${line.lead}${line.rest}`);
5633
+ if (rows.length > 3)
5634
+ out.push(` ${foldedText(rows.length, "components", c.page)}`);
5635
+ if (!short || rows.length <= 3)
5636
+ out.push(...rows.map((r) => ` ${r.ref.kind === "breakdown" ? componentReadName({ key: r.ref.key, name: r.ref.name }) : measureWords(r.ref.measure)}`));
5637
+ }
5638
+ return out;
5639
+ }
5640
+ function laneContextLines(context, journey, phone, verbose = false, short = false) {
5641
+ const lines = resultContextLines(context, verbose, short);
5642
+ return lines.length ? [`${journey} on ${phone}:`, ...lines.map((line) => ` ${line}`)] : [];
5643
+ }
5644
+ var exactComponentMs = (ms) => `${ms} ms`;
5645
+ var componentPageText = (page, count) => `Component rows: ${count ? `${page.offset + 1}–${page.offset + count}` : "0"} of ${page.total} stored${page.next_offset !== null ? `; next --page ${Math.floor(page.next_offset / page.limit) + 1} (MCP component_offset ${page.next_offset})` : ""}.`;
5646
+ var omissionText = (o) => `${o.run_id}: SDK omitted ${o.sdkOmitted ?? "unknown"}; Derivation left out ${o.omitted ?? "unknown"}${o.selfMs === null ? "" : ` · ${exactComponentMs(o.selfMs)}`}. Paging leaves recorded omissions unchanged.`;
5647
+ var READ_WORDS = {
5648
+ componentTime: "component render time",
5649
+ ownTime: "own time",
5650
+ detailUnavailable: "Only render time recorded; renders, parent and library detail unavailable.",
5651
+ parent: "Placed by",
5652
+ slowest: MEASURES.commit_max_ms.label,
5653
+ runA: "Run A",
5654
+ runB: "Run B",
5655
+ notMeasured: "Not measured",
5656
+ onlyA: "Only in run A",
5657
+ onlyB: "Only in run B"
5658
+ };
5659
+ var READ_USAGE = {
5660
+ listFindings: `What got ${STATE_WORDS.worse.toLowerCase()} (${STATE_WORDS.worse} / ${STATE_WORDS.warning}) or ${STATE_WORDS.better.toLowerCase()} in the app, worst first, as KeepSnappy measured it on real phones. The answer starts with whether and when the branch was measured and how its newest run set read: an empty list on a branch that was never measured, or whose newest run set was not judged, is not a clean result. Filter by a PR branch or main, journey, phone, and state (present by default; not-judged = the newest run set did not judge it, so it may still be there). Each finding says what changed, where, how sure, and has an id for get_finding. Page with offset.`,
5661
+ components: " --page N | --all (places or compare: a component page, or every stored component and place; --top-components sets the bounded page size)",
5662
+ verbose: " --verbose (include source files and raw identities in text; JSON is unchanged)",
5663
+ page: "--page must be a whole number from 1; use it only with places or compare.",
5664
+ all: "--all is for places or compare; do not combine it with --page, --offset or --short.",
5665
+ detailFlag: " --detail (places: include recorded run detail; also included by --verbose or component paging)",
5666
+ detail: "Include recorded per-run component and window detail.",
5667
+ compareTop: "--top-components must be a whole number from 1 to 200 for compare.",
5668
+ top: "--top-components must be a whole number from 0 to 20, or from 1 to 200 with --page or --all.",
5669
+ paging: "The API did not advance the component page; stopped without claiming the list is complete.",
5670
+ componentOffset: "Skip this many stored components per place.",
5671
+ componentLimit: "Component page size; the response gives stored totals and next offsets."
5672
+ };
5673
+ var componentListsText = (totals, shown) => `Stored component rows: ${shown.rows} of ${totals.rows} compared; ${shown.added} of ${totals.added} ${READ_WORDS.onlyB.toLowerCase()}; ${shown.removed} of ${totals.removed} ${READ_WORDS.onlyA.toLowerCase()}.`;
5674
+ var notMeasuredText = (side) => `${READ_WORDS.notMeasured} in run ${side.toUpperCase()}`;
5675
+
5676
+ // packages/present/dist/src/words.js
5677
+ var REASON_WORDS = {
5678
+ "no-baseline": "there are no main runs on this phone and journey yet",
5679
+ "too-few-runs": "there are too few valid runs on a side",
5680
+ "judged-measures-missing": "the judged measures are missing on a side",
5681
+ "measured-differently": "the sides were measured differently",
5682
+ "runs-unknown": "a change that would count has no value in some runs, so it cannot be confirmed in every run",
5683
+ "facts-version-mismatch": "the sides were derived with different versions",
5684
+ "not-judged-on-simulators": "timing is not judged on simulators",
5685
+ "platforms-differ": "the sides ran on different platforms (iOS and Android are never compared)",
5686
+ "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5687
+ };
5688
+ var waitingWords = (runs) => `${runs} main ${runs === 1 ? "run waits" : "runs wait"} for a reprocess`;
5689
+ var AT_MOST = "≤ ";
5690
+ var atMost = (text, isBound) => isBound ? `${AT_MOST}${text}` : text;
5691
+ var AT_LEAST = "≥ ";
5692
+ var atLeast = (text, isBound) => isBound ? `${AT_LEAST}${text}` : text;
5496
5693
  // packages/present/dist/src/main-history.js
5497
5694
  var MAIN_TIER_WORDS = {
5498
5695
  same: "In norm",
@@ -5501,6 +5698,13 @@ var MAIN_TIER_WORDS = {
5501
5698
  slight: STATE_WORDS.warning,
5502
5699
  worse: STATE_WORDS.worse
5503
5700
  };
5701
+ var MAIN_TIER_RESULT_WORDS = {
5702
+ same: STATE_WORDS["no-change"],
5703
+ better: STATE_WORDS.better,
5704
+ cant: STATE_WORDS["cant-tell"],
5705
+ slight: STATE_WORDS.warning,
5706
+ worse: STATE_WORDS.worse
5707
+ };
5504
5708
  var MAIN_TIER_RANK = { worse: 0, slight: 1, cant: 2, better: 3, same: 4 };
5505
5709
  function mainCantWords(reason, config) {
5506
5710
  if (!reason)
@@ -5526,7 +5730,6 @@ function mainPlaceReasonWords(reason, config) {
5526
5730
  }
5527
5731
  }
5528
5732
  var MAIN_SUMMARY_PENDING = "Runs received; the summary is not computed yet.";
5529
- var MAIN_NEWEST_SUMMARY_PENDING = `Newest: ${MAIN_SUMMARY_PENDING}`;
5530
5733
  var SHORT_ASPECT_WORDS = {
5531
5734
  ...ASPECT_WORDS,
5532
5735
  sdk: "SDK",
@@ -5561,8 +5764,412 @@ function mainCutWords(v) {
5561
5764
  const cut = listWords(v.partial_render.cut);
5562
5765
  parts.push(`${cut[0].toUpperCase()}${cut.slice(1)} start a new render baseline; ${listWords(v.partial_render.kept)} keep theirs.`);
5563
5766
  }
5564
- return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
5767
+ return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
5768
+ }
5769
+ var BY_RANK = Object.keys(MAIN_TIER_RANK).sort((a, b) => MAIN_TIER_RANK[a] - MAIN_TIER_RANK[b]);
5770
+ // packages/present/dist/src/verdict-section.js
5771
+ var places = (n) => `${n.toLocaleString("en-US")} ${n === 1 ? "place" : "places"}`;
5772
+ var of = (n, total) => `${n.toLocaleString("en-US")} of ${places(total)}`;
5773
+ var runs = (n) => `${n} valid ${n === 1 ? "run" : "runs"}`;
5774
+ var VERDICT_SECTION_WORDS = {
5775
+ slower: (n, total, slight) => `${slight ? "A little slower" : "Slower"} at ${of(n, total)}`,
5776
+ blocksMerge: "blocks the merge",
5777
+ faster: (n, total) => `Faster at ${of(n, total)}, slower at none`,
5778
+ mixed: (slower, faster, total) => `Slower at ${of(slower, total)}, faster at ${faster.toLocaleString("en-US")}`,
5779
+ unpublishedSdk: "ran an unpublished SDK build",
5780
+ noneMoved: (total, judged = total) => judged < total ? `No place moved out of its normal range (${judged.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} judged)` : `No place moved out of its normal range (${places(total)})`,
5781
+ noneMovedMeasured: (measured, same) => same ? `Nothing moved at ${of(same, same + measured)}; ${measured} measured differently` : `Nothing moved; ${measured} ${measured === 1 ? "place was" : "places were"} measured differently`,
5782
+ tooFewRuns: (valid, needed) => `Not compared: ${runs(valid)} of ${needed} needed`,
5783
+ tooFewMainRuns: (valid, needed) => `Not compared: main has ${runs(valid)} of ${needed} needed`,
5784
+ tooFewBefore: (valid) => `Not compared: the sessions before have only ${runs(valid)}`,
5785
+ noBaseline: { branch: "Nothing to compare with: no main runs on this phone yet", main: "Nothing to compare with: no earlier sessions on this phone" },
5786
+ noisy: "Not judged: main’s own runs vary too much at every place here",
5787
+ versionMismatch: "Not compared: the sides were computed with different analysis versions",
5788
+ unconfirmed: (n) => `Can’t confirm ${places(n)}: a run has no value there`,
5789
+ notCompared: (reason) => `Not compared: ${reason}`,
5790
+ notJudgedYet: (why) => `Not judged yet: ${why} on this phone`,
5791
+ collecting: (collected, baseline, after) => after ? `Main is collecting a new baseline after ${after}: ${collected} of ${baseline} sessions` : `Main is collecting its baseline: ${collected} of ${baseline} sessions`,
5792
+ doesNotBlock: "doesn’t block",
5793
+ sides: {
5794
+ branch: { left: "Main", right: "This branch", rightShort: "Branch" },
5795
+ main: { left: "Sessions before", right: "Newest session", rightShort: "Newest" },
5796
+ runSet: { left: "Main", right: "This run set", rightShort: "Run set" }
5797
+ },
5798
+ branchSides: (name) => ({ left: "Main", right: name || "This branch", rightShort: "Branch" }),
5799
+ inProgress: (arrived, expected) => `${arrived} of ${expected} runs arrived; the result comes with the last one`,
5800
+ whatChangedLabel: "What changed",
5801
+ forInformation: "for information",
5802
+ allFilter: "All",
5803
+ filter: {
5804
+ label: "Show places",
5805
+ choice: (tierWord) => upperFirst(tierWord),
5806
+ showing: (choice, n) => `Showing ${choice.toLowerCase()}: ${n}`
5807
+ },
5808
+ countsLabel: "Places by result",
5809
+ links: {
5810
+ openChart: "Open chart",
5811
+ seeAll: (n) => `See all ${places(n)}`,
5812
+ whichPlaces: "Which places",
5813
+ whichChanges: "Which changes",
5814
+ howToFix: "How to fix",
5815
+ whatDiffers: "What differs",
5816
+ whatChanged: "What changed",
5817
+ openSetup: "Open setup",
5818
+ openScreen: "Open screen"
5819
+ }
5820
+ };
5821
+ // packages/present/dist/src/summary-message.js
5822
+ var SUMMARY_WORDS = {
5823
+ notComputed: "Not judged yet",
5824
+ notComputedWhy: "waiting for the judgement",
5825
+ placesOnPage: "the places are on the page",
5826
+ noResult: "No result",
5827
+ main: "Main",
5828
+ newestSlower: "newest session slower",
5829
+ journeys: (n) => `${n} journeys`,
5830
+ phones: (n) => `${n} phones`,
5831
+ heated: (n, of) => `${INFO_WORDS.heat.toLowerCase()} in ${n} of ${of} runs`,
5832
+ fewRuns: "few runs",
5833
+ measuredDifferently: "measured differently",
5834
+ namesChanged: (sdk, mainSessions, needed) => `Main: ${mainSessions} of ${needed} sessions on SDK ${sdkTag(sdk)}`,
5835
+ link: {
5836
+ lane: (journey, phone) => `Open ${journey} on ${phone}`,
5837
+ phone: (phone) => `Open on ${phone}`,
5838
+ branch: (branch) => `Open ${branch}`,
5839
+ runSet: "Open the run set"
5840
+ }
5841
+ };
5842
+ var ORDER = ["worse", "warning", "cant-tell", "not-computed", "better", "no-change"];
5843
+ var rank = (l) => ORDER.indexOf(l.state);
5844
+ var WORD = { ...STATE_WORDS, "not-computed": SUMMARY_WORDS.notComputed };
5845
+ var shownLane = (lanes) => [...lanes].sort((a, b) => rank(a) - rank(b) || b.places - a.places)[0];
5846
+ var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
5847
+ var NAME = { name: 32, beside: 18, place: 16, link: 60, button: 30 };
5848
+ var DETAIL = 64;
5849
+ var upper = (s) => s ? `${s[0].toUpperCase()}${s.slice(1)}` : s;
5850
+ var fromTo = (a, b) => {
5851
+ const unit = (v) => / [^\d\s]+$/.exec(v)?.[0];
5852
+ return unit(a) && unit(a) === unit(b) ? `${a.slice(0, -unit(a).length)} → ${b}` : `${a} → ${b}`;
5853
+ };
5854
+ function clipPlace(place, n) {
5855
+ if (place.length <= n)
5856
+ return place;
5857
+ let cut = place.slice(0, n);
5858
+ if (place[n] !== " " && cut.includes(" "))
5859
+ cut = cut.slice(0, cut.lastIndexOf(" "));
5860
+ cut = cut.trimEnd();
5861
+ const open = (cut.match(/“/g) ?? []).length > (cut.match(/”/g) ?? []).length;
5862
+ return `${cut}…${open ? "”" : ""}`;
5863
+ }
5864
+ var changeText2 = (c, room) => {
5865
+ const what = `${c.measure} ${c.main !== null && c.now !== null ? fromTo(c.main, c.now) : c.change}`;
5866
+ return c.place ? `${clipPlace(c.place, Math.max(NAME.place, room - what.length - 3))} · ${what}` : upper(what);
5867
+ };
5868
+ var wordReason = (word) => word?.includes(": ") ? word.slice(word.indexOf(": ") + 2) : null;
5869
+ var notJudgedWhy = (l) => l.state === "not-computed" && l.word?.startsWith(`${NOT_JUDGED_WORD}: `) ? upper(l.word.slice(NOT_JUDGED_WORD.length + 2)) : null;
5870
+ function why(l) {
5871
+ if (l.state === "not-computed")
5872
+ return notJudgedWhy(l) ?? upper(SUMMARY_WORDS.notComputedWhy);
5873
+ if (l.state === "cant-tell" && l.namesChanged)
5874
+ return SUMMARY_WORDS.namesChanged(l.namesChanged.sdk, l.namesChanged.mainSessions, l.namesChanged.needed);
5875
+ if (l.answer)
5876
+ return l.answer;
5877
+ if (l.state === "no-change")
5878
+ return l.places ? VERDICT_SECTION_WORDS.noneMoved(l.places) : noneMoved;
5879
+ if (l.state !== "cant-tell")
5880
+ return upper(SUMMARY_WORDS.placesOnPage);
5881
+ return upper(wordReason(l.word) ?? (l.word === NO_BASELINE_WORD ? l.word : REASON_WORDS["judged-measures-missing"]));
5882
+ }
5883
+ var noneMoved = VERDICT_SECTION_WORDS.noneMoved(1).replace(/ \(.*\)$/, "");
5884
+ function conditions(l, pr) {
5885
+ const moved = l.state === "worse" || l.state === "warning";
5886
+ const heat = l.heatedRuns ? SUMMARY_WORDS.heated(l.heatedRuns, l.runs) : "";
5887
+ const heatIsNews = l.state === "no-change" && !!heat;
5888
+ return {
5889
+ always: [pr && l.state === "worse" ? l.blocking ? VERDICT_SECTION_WORDS.blocksMerge : NOT_BLOCKING : "", heatIsNews ? heat : ""].filter(Boolean),
5890
+ optional: [
5891
+ moved && l.measuredDifferently ? SUMMARY_WORDS.measuredDifferently : "",
5892
+ (moved || l.state === "better") && l.fewRuns ? SUMMARY_WORDS.fewRuns : "",
5893
+ heatIsNews ? "" : heat
5894
+ ].filter(Boolean)
5895
+ };
5896
+ }
5897
+ function summaryMessage(input) {
5898
+ const l = shownLane(input.lanes);
5899
+ if (!l) {
5900
+ return {
5901
+ state: "not-computed",
5902
+ newestSlower: false,
5903
+ lead: `${SUMMARY_WORDS.notComputed} · ${clip(input.branch, NAME.name)}`,
5904
+ detail: upper(SUMMARY_WORDS.notComputedWhy),
5905
+ link: { label: SUMMARY_WORDS.link.branch(clip(input.branch, NAME.button - 5)), url: input.url }
5906
+ };
5907
+ }
5908
+ const journeys = new Set(input.lanes.map((x) => x.journey ?? "-")).size;
5909
+ const single = input.lanes.length === 1;
5910
+ const phone = l.phone ?? "unknown phone";
5911
+ const several = journeys === 1 ? SUMMARY_WORDS.phones(new Set(input.lanes.map((x) => x.phone ?? "-")).size) : SUMMARY_WORDS.journeys(journeys);
5912
+ const newestSlower = !input.pr && (l.state === "worse" || l.state === "warning");
5913
+ const named = (subject, rest) => [clip(subject, rest.length ? NAME.beside : NAME.name), ...rest];
5914
+ const what = input.pr ? named(input.branch, single ? [] : [several]) : single || journeys === 1 ? named(l.journey ?? "journey", single ? [] : [several]) : [several];
5915
+ const lead = newestSlower ? [SUMMARY_WORDS.main, ...what, SUMMARY_WORDS.newestSlower].join(" · ") : [notJudgedWhy(l) ? NOT_JUDGED_WORD : WORD[l.state], ...what].join(" · ");
5916
+ const top = l.state === "worse" || l.state === "warning" || l.state === "better" ? l.changes[0] : undefined;
5917
+ const { always, optional } = conditions(l, input.pr);
5918
+ const answer = top ? null : always.includes(SUMMARY_WORDS.heated(l.heatedRuns, l.runs)) ? noneMoved : why(l);
5919
+ let extra = [...always];
5920
+ for (const c of optional)
5921
+ if ((answer ?? changeText2(top, 0)).length + `; ${[...extra, c].join(", ")}`.length <= DETAIL)
5922
+ extra = [...extra, c];
5923
+ const tail = extra.length ? `; ${extra.join(", ")}` : "";
5924
+ const detail = `${top ? changeText2(top, DETAIL - tail.length) : answer}${tail}`;
5925
+ const journey = clip(l.journey ?? "journey", NAME.link);
5926
+ const short = (full, fallback) => full.length <= NAME.button ? full : fallback;
5927
+ const label = single ? short(SUMMARY_WORDS.link.lane(journey, phone), SUMMARY_WORDS.link.phone(phone)) : input.pr ? SUMMARY_WORDS.link.branch(clip(input.branch, NAME.button - 5)) : journeys === 1 ? short(SUMMARY_WORDS.link.lane(journey, several), SUMMARY_WORDS.link.phone(several)) : SUMMARY_WORDS.link.runSet;
5928
+ return { state: l.state, newestSlower, lead, detail, link: { label, url: (single ? l.url : null) ?? input.url } };
5929
+ }
5930
+ function pendingMessage(branch, url) {
5931
+ return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.notComputed} · ${clip(branch, NAME.name)}`, detail: upper(SUMMARY_WORDS.notComputedWhy), link: { label: SUMMARY_WORDS.link.runSet, url } };
5932
+ }
5933
+ function noResultMessage(branch, reason, url) {
5934
+ return { state: "not-computed", newestSlower: false, lead: `${SUMMARY_WORDS.noResult} · ${clip(branch, NAME.name)}`, detail: reason.replace(/\.$/, ""), link: url ? { label: SUMMARY_WORDS.link.runSet, url } : null };
5935
+ }
5936
+ // packages/present/dist/src/run-set-message.js
5937
+ var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
5938
+ function movedItems(v, sc) {
5939
+ const flat = v.items.filter((i) => i.scenario === sc.scenario && (!sc.device || !i.device_id || i.device_id === sc.device.id));
5940
+ const h = sc.headline;
5941
+ if (h?.errors === undefined)
5942
+ return flat.filter((i) => i.state === "worse" || i.state === "better");
5943
+ const graded = [...h.errors, ...h.warnings ?? []].map((g) => {
5944
+ const same = flat.find((i) => i.step === g.window && i.metric === g.metric);
5945
+ return {
5946
+ ...same ?? { kind: "change", low_data: false, low_data_reason: null },
5947
+ scenario: sc.scenario,
5948
+ step: g.window,
5949
+ metric: g.metric,
5950
+ state: "worse",
5951
+ grade: g.grade,
5952
+ baseline: g.baseline ?? same?.baseline ?? null,
5953
+ ...(g.baseline != null ? g.baseline_is_bound : same?.baseline_is_bound) ? { baseline_is_bound: true } : {},
5954
+ ...(g.baseline != null ? g.change_is_bound : same?.change_is_bound) ? { change_is_bound: true } : {},
5955
+ candidate: g.candidate ?? same?.candidate ?? null,
5956
+ change_text: g.change,
5957
+ ...g.finding ? { finding: g.finding } : {},
5958
+ ...g.label ? { label_text: midLine(g.label, g.metric) } : {},
5959
+ ...sc.device ? { device_id: sc.device.id } : {}
5960
+ };
5961
+ });
5962
+ return [...graded, ...flat.filter((i) => i.state === "better" && (i.judged ?? "outcome") === "outcome")];
5963
+ }
5964
+ var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
5965
+ var linkOf = (x, set) => x?.share_url || set?.share_url || x?.dashboard_url || set?.dashboard_url || null;
5966
+ var measureWord = (i) => {
5967
+ if (i.label_text)
5968
+ return i.label_text;
5969
+ if (i.metric === "dur_ms" && placeKindWord(i.step ?? "") !== "Tap")
5970
+ return "length";
5971
+ return measureDef(i.metric)?.short ?? i.metric.replace(/_/g, " ");
5972
+ };
5973
+ var changeWords = (i) => {
5974
+ if (i.change_text)
5975
+ return i.change_text.replace(/ \(.*\)$/, "");
5976
+ const { baseline: b, candidate: c } = i;
5977
+ if (b === null || c === null)
5978
+ return "";
5979
+ if (b === 0)
5980
+ return atLeast(`${c - b >= 0 ? "+" : ""}${fieldValueText(i.metric, c - b)}`, i.change_is_bound);
5981
+ const p = Math.round((c / b - 1) * 100);
5982
+ return atLeast(`${p >= 0 ? "+" : ""}${p} %`, i.change_is_bound);
5983
+ };
5984
+ var placeOf = (step) => !step || step === "flow" || step === "*" ? null : placeLabel(step);
5985
+ function stateOf(sc, errors) {
5986
+ if (sc.state === null)
5987
+ return sc.word === NO_BASELINE_WORD ? "cant-tell" : "not-computed";
5988
+ if (sc.state === "inconclusive")
5989
+ return "cant-tell";
5990
+ if (sc.state === "warn")
5991
+ return errors || sc.word?.startsWith(STATE_WORDS.worse) ? "worse" : "warning";
5992
+ return sc.state;
5993
+ }
5994
+ function laneOf(v, sc, set) {
5995
+ const moved = sc.state === "inconclusive" ? [] : movedItems(v, sc);
5996
+ const worse = moved.filter((i) => i.state === "worse").sort((a, b) => Number(a.grade === "warn") - Number(b.grade === "warn"));
5997
+ const errors = worse.filter((i) => i.grade !== "warn");
5998
+ const state = stateOf(sc, errors.length);
5999
+ const shown = state === "better" ? moved.filter((i) => i.state === "better") : worse;
6000
+ return {
6001
+ journey: sc.scenario,
6002
+ phone: sc.device?.model ?? null,
6003
+ state,
6004
+ answer: sc.headline?.answer ?? null,
6005
+ word: sc.word ?? null,
6006
+ changes: shown.map((i) => ({
6007
+ place: placeOf(i.step),
6008
+ measure: measureWord(i),
6009
+ main: i.baseline === null ? null : atMost(fieldValueText(i.metric, i.baseline), i.baseline_is_bound),
6010
+ now: i.candidate === null ? null : fieldValueText(i.metric, i.candidate),
6011
+ change: changeWords(i)
6012
+ })),
6013
+ places: placeCount(shown),
6014
+ blocking: sc.headline?.blocking ?? v.blocking ?? v.headline?.blocking ?? true,
6015
+ measuredDifferently: !!sc.headline?.measured_differently?.length,
6016
+ fewRuns: sc.low_data,
6017
+ heatedRuns: sc.heated_runs ?? 0,
6018
+ runs: sc.runs.valid + sc.runs.invalid,
6019
+ namesChanged: sc.context?.namesChanged ?? null,
6020
+ url: linkOf(sc, set)
6021
+ };
6022
+ }
6023
+ var isPending = (s) => lanesOf(s).every((l) => l.state === "not-computed" && !l.word?.startsWith(`${NOT_JUDGED_WORD}: `));
6024
+ var lanesOf = (s) => s.verdicts ? s.verdicts.scenarios.map((sc) => laneOf(s.verdicts, sc, s)) : [];
6025
+ var isPr = (s) => !!s.verdicts?.scenarios.some((sc) => sc.role === "comparison");
6026
+ var runSetMessage = (s) => summaryMessage({ branch: s.branch, pr: isPr(s), lanes: lanesOf(s), url: linkOf(s) ?? s.dashboard_url });
6027
+ var MESSAGE_COLORS = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
6028
+ function messageTone(s) {
6029
+ const lanes = s ? lanesOf(s) : [];
6030
+ if (lanes.some((l) => l.state === "worse" && l.blocking))
6031
+ return { mark: "\uD83D\uDD34", color: MESSAGE_COLORS.error };
6032
+ if (lanes.some((l) => l.state === "warning"))
6033
+ return { mark: "\uD83D\uDFE1", color: MESSAGE_COLORS.warn };
6034
+ switch (shownLane(lanes)?.state) {
6035
+ case "better":
6036
+ return { mark: "\uD83D\uDFE2", color: MESSAGE_COLORS.better };
6037
+ case "cant-tell":
6038
+ return { mark: "❔", color: MESSAGE_COLORS.none };
6039
+ default:
6040
+ return { mark: "⚪", color: MESSAGE_COLORS.none };
6041
+ }
6042
+ }
6043
+ var esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
6044
+ function slackPayload(m, tone) {
6045
+ const lead = `${tone.mark} ${esc(m.lead)}`;
6046
+ return {
6047
+ attachments: [{
6048
+ color: tone.color,
6049
+ fallback: lead,
6050
+ blocks: [
6051
+ { type: "section", text: { type: "mrkdwn", text: `*${lead}*
6052
+ ${esc(m.detail)}` } },
6053
+ ...m.link ? [{ type: "actions", elements: [{ type: "button", text: { type: "plain_text", text: m.link.label }, url: m.link.url }] }] : []
6054
+ ]
6055
+ }]
6056
+ };
6057
+ }
6058
+ function resultSlackPayload(s, o = {}) {
6059
+ if (!s.verdicts)
6060
+ return null;
6061
+ const lanes = lanesOf(s);
6062
+ const moved = lanes.some((l) => l.state === "worse" || l.state === "warning");
6063
+ const heat = lanes.some((l) => l.heatedRuns > 0);
6064
+ const improved = lanes.some((l) => l.state === "better") || s.verdicts.state === "better";
6065
+ if (isPr(s) ? !moved && !heat : !moved && !improved && !heat && !o.always)
6066
+ return null;
6067
+ return slackPayload(runSetMessage(s), messageTone(s));
6068
+ }
6069
+ var noResultSlackPayload = (branch, reason, url) => slackPayload(noResultMessage(branch, reason, url), { mark: "⚪", color: MESSAGE_COLORS.none });
6070
+ var markdown = (m, mark) => `### ${mark} ${m.lead}
6071
+
6072
+ ${m.detail}
6073
+ ${m.link ? `
6074
+ [${m.link.label} ›](${m.link.url})
6075
+ ` : ""}`;
6076
+ var resultMarkdown = (s) => markdown(runSetMessage(s), messageTone(s).mark);
6077
+ var noResultMarkdown = (branch, reason, url) => markdown(noResultMessage(branch, reason, url), "⚪");
6078
+ var pendingMarkdown = (branch, url) => markdown(pendingMessage(branch, url), "⚪");
6079
+ // apps/agent/src/upload.ts
6080
+ import { join as join4 } from "node:path";
6081
+ var MAX_RATE_RETRIES = 5;
6082
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6083
+ class UploadError extends Error {
6084
+ }
6085
+ async function call(f, base, path, headers, body, method = "POST") {
6086
+ let res;
6087
+ for (let attempt = 0;; attempt++) {
6088
+ try {
6089
+ res = await f(`${base.replace(/\/+$/, "")}${path}`, {
6090
+ method,
6091
+ headers: method === "POST" ? { "content-type": "application/json", ...headers } : headers,
6092
+ ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
6093
+ });
6094
+ } catch (e) {
6095
+ throw new UploadError(`${method} ${path}: ${e.message}`);
6096
+ }
6097
+ if (res.status !== 429 || attempt >= MAX_RATE_RETRIES)
6098
+ break;
6099
+ const wait = Number(res.headers.get("retry-after") ?? "1");
6100
+ await res.text().catch(() => "");
6101
+ await sleep(Math.min(Math.max(Number.isFinite(wait) ? wait : 1, 1), 60) * 1000);
6102
+ }
6103
+ const text = await res.text();
6104
+ if (res.status < 200 || res.status >= 300)
6105
+ throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
6106
+ try {
6107
+ return text ? JSON.parse(text) : {};
6108
+ } catch {
6109
+ throw new UploadError(`${method} ${path} -> ${res.status}, the answer is not JSON (is the URL the KeepSnappy API?): ${text.slice(0, 200)}`);
6110
+ }
6111
+ }
6112
+ async function uploadRuns(deps, outDir, runs, opts) {
6113
+ const f = opts.fetch ?? fetch;
6114
+ const size = opts.batchSize ?? 200;
6115
+ const skipped = [];
6116
+ let uploaded = 0;
6117
+ for (const r of runs) {
6118
+ if (!r.valid) {
6119
+ skipped.push({ run_id: r.run_id, why: r.invalid_reason || "invalid" });
6120
+ continue;
6121
+ }
6122
+ const log = await deps.fs.readFile(join4(outDir, r.run_id, "keepsnappy.log"));
6123
+ const windows = windowsInRun(parseWindows(log), r.t0, r.t1);
6124
+ if (windows.length === 0) {
6125
+ skipped.push({ run_id: r.run_id, why: "no-windows" });
6126
+ continue;
6127
+ }
6128
+ const externalId = `${opts.externalPrefix}-${r.run_id}`;
6129
+ const created = await call(f, opts.ingestUrl, "/v1/runs", { "x-keepsnappy-key": opts.key }, {
6130
+ external_id: externalId,
6131
+ started_at: r.t0,
6132
+ ended_at: r.t1,
6133
+ device: { ...opts.device?.model ? { model: opts.device.model } : {}, ...opts.device?.os ? { os: opts.device.os } : {}, ...opts.device?.id ? { id: opts.device.id } : {}, exclusive: true },
6134
+ meta: {
6135
+ arm: r.arm,
6136
+ driver: opts.driver ?? "keepsnappy-run",
6137
+ ...opts.meta,
6138
+ ...r.scenario ? { scenario: r.scenario } : {},
6139
+ ...opts.runSetId ? { run_set_id: opts.runSetId } : {}
6140
+ }
6141
+ });
6142
+ const runId = String(created.run_id ?? "");
6143
+ const token = String(created.run_token ?? "");
6144
+ if (!runId || !token)
6145
+ throw new UploadError("POST /v1/runs: response has no run_id/run_token");
6146
+ const items = windows.map((w) => `${w.endWall} KEEPSNAPPY window ${JSON.stringify(w.raw)}`);
6147
+ for (let i = 0;i * size < items.length; i++) {
6148
+ await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
6149
+ batch_id: `${externalId}-${String(i + 1).padStart(4, "0")}`,
6150
+ items: items.slice(i * size, (i + 1) * size)
6151
+ });
6152
+ }
6153
+ const samples = samplesInRun(parseSamples(log), r.t0, r.t1).map((x) => `${x.wall} KEEPSNAPPY sample ${JSON.stringify(x.raw)}`);
6154
+ for (let i = 0;i * size < samples.length; i++) {
6155
+ await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
6156
+ batch_id: `${externalId}-s${String(i + 1).padStart(4, "0")}`,
6157
+ items: samples.slice(i * size, (i + 1) * size)
6158
+ });
6159
+ }
6160
+ let markers = [];
6161
+ try {
6162
+ markers = parseMarkersNdjson(await deps.fs.readFile(join4(outDir, r.run_id, "markers.ndjson")));
6163
+ } catch {}
6164
+ for (const m of markers) {
6165
+ await call(f, opts.ingestUrl, "/v1/markers", { authorization: `Bearer ${token}` }, { step: m.step, phase: m.phase, t_host: m.t_host });
6166
+ }
6167
+ await call(f, opts.ingestUrl, `/v1/runs/${runId}/finish`, { "x-keepsnappy-key": opts.key }, {});
6168
+ uploaded++;
6169
+ }
6170
+ return { uploaded, skipped };
5565
6171
  }
6172
+
5566
6173
  // apps/agent/src/summary.ts
5567
6174
  var fieldWord = (field) => measureDef(field)?.short ?? field;
5568
6175
  function plainMetric(metric) {
@@ -5601,7 +6208,6 @@ function summarize(input) {
5601
6208
 
5602
6209
  // apps/agent/src/remote.ts
5603
6210
  var isBlocking = (s) => !!s?.verdicts && (s.verdicts.blocking ?? s.verdicts.state === "worse");
5604
- var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
5605
6211
  function heatNote(sc) {
5606
6212
  const n = sc.heated_runs ?? 0;
5607
6213
  if (!n)
@@ -5609,30 +6215,6 @@ function heatNote(sc) {
5609
6215
  const phone = sc.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
5610
6216
  return `${INFO_WORDS.heat} in ${n} of ${sc.runs.valid + sc.runs.invalid} runs · ${sc.scenario ?? "journey"} · ${phone}`;
5611
6217
  }
5612
- function movedItems(v, sc) {
5613
- const flat = v.items.filter((i) => i.scenario === sc.scenario && (!sc.device || !i.device_id || i.device_id === sc.device.id));
5614
- const h = sc.headline;
5615
- if (h?.errors === undefined)
5616
- return flat.filter((i) => i.state === "worse" || i.state === "better");
5617
- const graded = [...h.errors, ...h.warnings ?? []].map((g) => {
5618
- const same = flat.find((i) => i.step === g.window && i.metric === g.metric);
5619
- return {
5620
- ...same ?? { kind: "change", low_data: false, low_data_reason: null },
5621
- scenario: sc.scenario,
5622
- step: g.window,
5623
- metric: g.metric,
5624
- state: "worse",
5625
- grade: g.grade,
5626
- baseline: g.baseline ?? same?.baseline ?? null,
5627
- candidate: g.candidate ?? same?.candidate ?? null,
5628
- change_text: g.change,
5629
- ...g.finding ? { finding: g.finding } : {},
5630
- ...g.label ? { label_text: midLine(g.label, g.metric) } : {},
5631
- ...sc.device ? { device_id: sc.device.id } : {}
5632
- };
5633
- });
5634
- return [...graded, ...flat.filter((i) => i.state === "better" && (i.judged ?? "outcome") === "outcome")];
5635
- }
5636
6218
  var placeOfRow = (g) => g.finding ?? placeRef(g.window).stem;
5637
6219
  function movedCounts(sc) {
5638
6220
  const h = sc.headline;
@@ -5642,7 +6224,6 @@ function movedCounts(sc) {
5642
6224
  const slightly = new Set((h.warnings ?? []).map(placeOfRow).filter((p) => !worse.has(p)));
5643
6225
  return { worse: worse.size, warnings: slightly.size, better: h.counts.better, noChange: h.counts.no_change, inconclusive: h.counts.inconclusive };
5644
6226
  }
5645
- var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
5646
6227
  var addCounts = (xs) => xs.reduce((a, c) => ({ worse: a.worse + c.worse, warnings: a.warnings + c.warnings, better: a.better + c.better, noChange: a.noChange + c.noChange, inconclusive: a.inconclusive + c.inconclusive }), { worse: 0, warnings: 0, better: 0, noChange: 0, inconclusive: 0 });
5647
6228
  var setupLine = (h) => {
5648
6229
  const parts = [
@@ -5654,10 +6235,16 @@ var setupLine = (h) => {
5654
6235
  var keyHeader = (t) => ({ "x-keepsnappy-key": t.key });
5655
6236
  async function createRunSet(t, body) {
5656
6237
  const r = await call(t.fetch ?? fetch, t.url, "/v1/run-sets", keyHeader(t), body);
5657
- return { run_set_id: String(r.run_set_id), dashboard_url: String(r.dashboard_url) };
6238
+ if (typeof r.run_set_id !== "string" || !r.run_set_id)
6239
+ throw new UploadError("POST /v1/run-sets: response has no run_set_id (is the URL the KeepSnappy API?)");
6240
+ return { run_set_id: r.run_set_id, dashboard_url: String(r.dashboard_url ?? "") };
5658
6241
  }
5659
6242
  async function completeRunSet(t, id, verdicts) {
5660
- return await call(t.fetch ?? fetch, t.url, `/v1/run-sets/${encodeURIComponent(id)}/complete`, keyHeader(t), { verdicts });
6243
+ const path = `/v1/run-sets/${encodeURIComponent(id)}/complete`;
6244
+ const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), { verdicts });
6245
+ if (!r.run_set_id)
6246
+ throw new UploadError(`POST ${path}: response has no run_set_id`);
6247
+ return r;
5661
6248
  }
5662
6249
  async function createIngestRun(t, body) {
5663
6250
  const r = await call(t.fetch ?? fetch, t.url, "/v1/runs", keyHeader(t), body);
@@ -5672,26 +6259,32 @@ async function finishIngestRun(t, runId, invalidReason, farmSession) {
5672
6259
  });
5673
6260
  }
5674
6261
  async function getRunSetRuns(t, id) {
5675
- return await call(t.fetch ?? fetch, t.url, `/v1/run-sets/${encodeURIComponent(id)}/runs`, keyHeader(t), undefined, "GET");
6262
+ const path = `/v1/run-sets/${encodeURIComponent(id)}/runs`;
6263
+ const r = await call(t.fetch ?? fetch, t.url, path, keyHeader(t), undefined, "GET");
6264
+ if (!Array.isArray(r.runs))
6265
+ throw new UploadError(`GET ${path}: response has no runs`);
6266
+ return r;
5676
6267
  }
5677
6268
  var FEW_RUNS = "Few runs — result may be noisy";
5678
- var word = (s, server, h) => {
5679
- const w = server || stateWord(s ?? "inconclusive");
5680
- return h?.blocking === false && h.measured_differently?.length && (w === STATE_WORDS.worse || s === "worse") ? `${w} (${NOT_BLOCKING})` : w;
5681
- };
6269
+ var word = (s, server, blocking) => badgedWord(server || stateWord(s ?? "inconclusive"), blocking);
6270
+ var setWord = (v) => word(v?.state ?? null, v?.word, v?.blocking ?? v?.headline?.blocking);
6271
+ var scenarioWord = (sc) => word(sc.state, sc.word, sc.headline?.blocking);
6272
+ var notComputed = (s) => !s.verdicts || s.verdicts.state === null && !s.verdicts.blocking;
6273
+ var UNCHECKED_GATE = `${NOT_COMPUTED_WORD}: --fail-on-worse checked nothing`;
6274
+ var uncheckedGate = (s) => notComputed(s) ? s.verdicts?.word && s.verdicts.word !== NOT_COMPUTED_WORD ? `${s.verdicts.word}: --fail-on-worse checked nothing` : UNCHECKED_GATE : null;
5682
6275
  var countsLine = (c) => countsWords(c);
5683
6276
  var noCounts = (c) => !c.worse && !c.warnings && !c.better && !c.noChange && !c.inconclusive;
5684
- var change = (metric, a, b) => {
6277
+ var change = (metric, a, b, bounds) => {
5685
6278
  if (a === null || b === null)
5686
6279
  return "";
5687
- const pct = a === 0 ? "" : ` ${b >= a ? "+" : "-"}${Math.round(Math.abs(b / a - 1) * 100)}%`;
5688
- return `${pct} (${fieldValueText(metric, a)} -> ${fieldValueText(metric, b)})`;
6280
+ const pct = a === 0 ? "" : ` ${atLeast(`${b >= a ? "+" : "-"}${Math.round(Math.abs(b / a - 1) * 100)}%`, bounds.change_is_bound)}`;
6281
+ return `${pct} (${atMost(fieldValueText(metric, a), bounds.baseline_is_bound)} -> ${fieldValueText(metric, b)})`;
5689
6282
  };
5690
6283
  function formatRunSet(s, top = 5) {
5691
6284
  const lines = [];
5692
6285
  const where = `${s.branch}${s.commit_sha ? ` @ ${s.commit_sha.slice(0, 7)}` : ""}`;
5693
6286
  const v = s.verdicts;
5694
- lines.push(`KeepSnappy result for ${where}: ${word(v?.state ?? null, v?.word, v?.headline)}`);
6287
+ lines.push(`KeepSnappy result for ${where}: ${setWord(v)}`);
5695
6288
  if (setupLine(v?.headline))
5696
6289
  lines.push(` ${setupLine(v?.headline)}`);
5697
6290
  if (v?.low_data)
@@ -5705,20 +6298,22 @@ function formatRunSet(s, top = 5) {
5705
6298
  const dev = sc.device ? `${sc.device.model}${sc.device.os ? ` (${sc.device.os})` : ""}` : "no device";
5706
6299
  const what = sc.role === "comparison" ? "compared with the baseline branch" : sc.role === "history" ? "latest runs compared with earlier ones" : "not judged (no branch or flow name)";
5707
6300
  lines.push("");
5708
- lines.push(`${sc.scenario ?? "(no flow name)"} on ${dev}, ${what}: ${word(sc.state, sc.word, sc.headline)}`);
6301
+ lines.push(`${sc.scenario ?? "(no flow name)"} on ${dev}, ${what}: ${scenarioWord(sc)}`);
5709
6302
  if (setupLine(sc.headline) && (v?.scenarios.length ?? 0) > 1)
5710
6303
  lines.push(` ${setupLine(sc.headline)}`);
5711
6304
  if (sc.low_data && (v?.scenarios.length ?? 0) > 1)
5712
6305
  lines.push(` ${FEW_RUNS}`);
5713
6306
  if (sc.role !== "adhoc" && !noCounts(movedCounts(sc)))
5714
6307
  lines.push(` ${countsLine(movedCounts(sc))}`);
6308
+ lines.push(...resultContextLines(sc.context).map((line) => ` ${line}`));
5715
6309
  if (heatNote(sc))
5716
6310
  lines.push(` ${heatNote(sc)}`);
6311
+ lines.push(...(sc.headline?.session_speed ?? []).map((line) => ` ${line}`));
5717
6312
  const allMoved = movedItems(v, sc).sort((a, b) => Number(b.state === "worse") - Number(a.state === "worse") || Number(a.grade === "warn") - Number(b.grade === "warn"));
5718
6313
  const moved = allMoved.slice(0, top);
5719
6314
  for (const i of moved) {
5720
6315
  const what = i.label_text ? `${i.step && i.step !== "*" ? `${placeLabel(i.step)}: ` : ""}${i.label_text}` : i.step ? `${placeLabel(i.step)}: ${fieldWord(i.metric)}` : plainMetric(i.metric);
5721
- lines.push(` ${stateWord(i.state === "worse" && i.grade === "warn" ? "warn" : i.state).padEnd(WORD_WIDTH)} ${what}${i.change_text ? `: ${i.change_text}` : change(i.metric, i.baseline, i.candidate)}`);
6316
+ lines.push(` ${stateWord(i.state === "worse" && i.grade === "warn" ? "warn" : i.state).padEnd(WORD_WIDTH)} ${what}${i.change_text ? `: ${i.change_text}` : change(i.metric, i.baseline, i.candidate, i)}`);
5722
6317
  }
5723
6318
  if (allMoved.length > moved.length)
5724
6319
  lines.push(` ${allMoved.length - moved.length} more`);
@@ -5735,33 +6330,12 @@ function formatRunSet(s, top = 5) {
5735
6330
  `);
5736
6331
  }
5737
6332
  function markdownRunSet(s, fallback) {
5738
- if (!s || !s.verdicts) {
6333
+ if (!s || !s.verdicts)
5739
6334
  return `### KeepSnappy
5740
6335
 
5741
6336
  Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [open KeepSnappy](${fallback.dashboardUrl})
5742
6337
  `;
5743
- }
5744
- const v = s.verdicts;
5745
- const heated = v.scenarios.flatMap((sc) => heatNote(sc) ?? []);
5746
- const rows = v.scenarios.map((sc) => {
5747
- const c = movedCounts(sc);
5748
- return `| ${sc.scenario ?? "-"} | ${sc.device ? sc.device.model : "-"} | ${word(sc.state, sc.word, sc.headline)}${sc.low_data ? ` (${FEW_RUNS})` : ""} | ${noCounts(c) ? "–" : `${c.worse} / ${c.warnings} / ${c.better} / ${c.noChange} / ${c.inconclusive}`} | ${sc.dashboard_url ? `[open](${sc.dashboard_url})` : "-"} |`;
5749
- });
5750
- return [
5751
- `### KeepSnappy: ${word(v.state, v.word, v.headline)}`,
5752
- "",
5753
- ...setupLine(v.headline) ? [`${setupLine(v.headline)}.`, ""] : [],
5754
- `\`${s.branch}\`${s.commit_sha ? ` @ \`${s.commit_sha.slice(0, 7)}\`` : ""}, ${s.runs.valid}/${s.runs.total} runs used.${v.low_data ? ` ${FEW_RUNS}.` : ""}`,
5755
- "",
5756
- `| Journey | Device | Result | ${STATE_WORDS.worse} / ${STATE_WORDS.warning} / ${STATE_WORDS.better} / ${STATE_WORDS["no-change"]} / ${STATE_WORDS["cant-tell"]} | Details |`,
5757
- "|---|---|---|---|---|",
5758
- ...rows,
5759
- ...heated.length ? ["", ...heated.map((note) => `${note}.`)] : [],
5760
- "",
5761
- `[Open the dashboard](${s.dashboard_url})`,
5762
- ""
5763
- ].join(`
5764
- `);
6338
+ return isPending(s) ? pendingMarkdown(s.branch, linkOf(s) ?? s.dashboard_url) : resultMarkdown(s);
5765
6339
  }
5766
6340
 
5767
6341
  // apps/agent/src/run.ts
@@ -9137,7 +9711,7 @@ function parseArms(spec) {
9137
9711
  return spec.split(",").map((part) => {
9138
9712
  const eq = part.indexOf("=");
9139
9713
  if (eq <= 0)
9140
- throw new Error(`bad --arms entry "${part}", expected NAME=<app-path-or-bundle-id>`);
9714
+ throw new ConfigError([`bad --arms entry "${part}", expected NAME=<app-path-or-bundle-id>`]);
9141
9715
  return { name: part.slice(0, eq).trim(), target: part.slice(eq + 1).trim() };
9142
9716
  });
9143
9717
  }
@@ -9147,10 +9721,10 @@ function parseArmEnv(specs, arms) {
9147
9721
  for (const spec of specs ?? []) {
9148
9722
  const m = /^([^:=]+):([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(spec);
9149
9723
  if (!m)
9150
- throw new Error(`bad --arm-env "${spec}", expected ARM:KEY=VALUE`);
9724
+ throw new ConfigError([`bad --arm-env "${spec}", expected ARM:KEY=VALUE`]);
9151
9725
  const [, arm = "", key = "", value = ""] = m;
9152
9726
  if (!names.has(arm))
9153
- throw new Error(`--arm-env "${spec}": no arm named ${arm} (arms: ${[...names].join(", ")})`);
9727
+ throw new ConfigError([`--arm-env "${spec}": no arm named ${arm} (arms: ${[...names].join(", ")})`]);
9154
9728
  const e = env.get(arm) ?? {};
9155
9729
  e[key] = value;
9156
9730
  if (!key.startsWith("MAESTRO_"))
@@ -9161,12 +9735,15 @@ function parseArmEnv(specs, arms) {
9161
9735
  }
9162
9736
 
9163
9737
  // apps/agent/src/runner.ts
9738
+ var TEST_ERROR = "test-error";
9739
+ var TEST_FAILED = "test-failed";
9740
+ var failedTest = (reason) => reason.startsWith(`${TEST_FAILED}:`) || reason.startsWith(`${TEST_ERROR}:`);
9164
9741
  var isBuildPath = (target) => !!target && /\.(app|ipa)\/?$/.test(target);
9165
9742
  async function measure(deps, source, installer, opts) {
9166
9743
  const { fs, clock, spawner, log } = deps;
9167
9744
  const flows = opts.flows?.length ? opts.flows : [{ name: undefined, command: opts.testCommand }];
9168
9745
  if (flows.some((f) => !f.command[0]))
9169
- throw new Error("no test command given (put it after `--`)");
9746
+ throw new ConfigError(["no test command given (put it after `--`)"]);
9170
9747
  await fs.mkdir(opts.outDir);
9171
9748
  const runsPath = join7(opts.outDir, "runs.ndjson");
9172
9749
  await fs.writeFile(runsPath, "");
@@ -9207,9 +9784,9 @@ async function measure(deps, source, installer, opts) {
9207
9784
  --- stderr ---
9208
9785
  ${r.stderr}` : ""}`);
9209
9786
  if (r.error)
9210
- reason = `test-error: ${r.error}`;
9787
+ reason = `${TEST_ERROR}: ${r.error}`;
9211
9788
  else if (r.code !== 0)
9212
- reason = `test-failed: exit ${r.code}`;
9789
+ reason = `${TEST_FAILED}: exit ${r.code}`;
9213
9790
  }
9214
9791
  const t1 = clock.now();
9215
9792
  let windows = [];
@@ -9307,7 +9884,7 @@ async function replayRuns(deps, sources, outDir, scenario) {
9307
9884
  const runs = [];
9308
9885
  for (const src of sources) {
9309
9886
  const text = await deps.fs.readFile(join8(src.dir, "runs.ndjson")).catch(() => {
9310
- throw new Error(`--replay ${src.dir}: no runs.ndjson (not a keepsnappy out dir)`);
9887
+ throw new ConfigError([`--replay ${src.dir}: no runs.ndjson (not a keepsnappy out dir)`]);
9311
9888
  });
9312
9889
  for (const line of text.split(`
9313
9890
  `)) {
@@ -9327,7 +9904,7 @@ async function replayRuns(deps, sources, outDir, scenario) {
9327
9904
  }
9328
9905
  }
9329
9906
  if (runs.length === 0)
9330
- throw new Error(`--replay: no valid runs in ${sources.map((x) => x.dir + (x.arm ? `:${x.arm}` : "")).join(", ")}`);
9907
+ throw new ConfigError([`--replay: no valid runs in ${sources.map((x) => x.dir + (x.arm ? `:${x.arm}` : "")).join(", ")}`]);
9331
9908
  await deps.fs.writeFile(join8(outDir, "runs.ndjson"), runs.map((r) => JSON.stringify(r)).join(`
9332
9909
  `) + `
9333
9910
  `);
@@ -9341,7 +9918,7 @@ var DEFAULT_API_URL = "https://keepsnappy-api-922918702810.europe-west1.run.app"
9341
9918
  // apps/agent/src/journeys.ts
9342
9919
  import { isAbsolute, relative, resolve as resolve2, sep } from "node:path";
9343
9920
  var charCount = (s) => [...s].length;
9344
- function clip(s, max) {
9921
+ function clip2(s, max) {
9345
9922
  if (charCount(s) <= max)
9346
9923
  return s;
9347
9924
  const parts = typeof Intl.Segmenter === "function" ? Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s), (x) => x.segment) : [...s];
@@ -9375,7 +9952,7 @@ function flowHeader(text) {
9375
9952
  para.push(t);
9376
9953
  }
9377
9954
  const description = para.join(" ").replace(/\s+/g, " ").trim();
9378
- return { ...title ? { title: clip(title, TITLE_MAX) } : {}, ...description ? { description: clip(description, DESCRIPTION_MAX) } : {} };
9955
+ return { ...title ? { title: clip2(title, TITLE_MAX) } : {}, ...description ? { description: clip2(description, DESCRIPTION_MAX) } : {} };
9379
9956
  }
9380
9957
  async function repoRoot(deps) {
9381
9958
  const r = await deps.spawner.run("git", ["rev-parse", "--show-toplevel"]).catch(() => null);
@@ -9429,10 +10006,11 @@ function baseRefs(env) {
9429
10006
  return target ? [`origin/${target}`] : ["origin/main", "main"];
9430
10007
  }
9431
10008
  var SHA = /^[0-9a-f]{40,64}$/;
10009
+ var onMain = (env, branch) => !!branch && baseRefs(env).some((r) => !r.startsWith("-") && r.replace(REMOTE, "") === branch);
9432
10010
  var FIX = "fetch main and the commit, in CI with fetch-depth: 0, or pass --base-commit SHA";
9433
10011
  async function defaultBaseCommit(deps, env, o) {
9434
10012
  const refs = baseRefs(env).filter((r) => !r.startsWith("-"));
9435
- if (o.branch && refs.some((r) => r.replace(REMOTE, "") === o.branch))
10013
+ if (onMain(env, o.branch))
9436
10014
  return {};
9437
10015
  const tip = o.commit?.trim();
9438
10016
  if (!tip)
@@ -9468,6 +10046,34 @@ async function commitFields(deps, env, shas) {
9468
10046
  const pr = await pullRequestInfo(deps, env);
9469
10047
  return { ...commit ? { commit } : {}, ...base ? { base_commit: base } : {}, ...pr ? { pull_request: { ...pr } } : {} };
9470
10048
  }
10049
+ var MAIN_MERGES_LIMIT = 200;
10050
+ var SQUASH = /^(.*?)\s*\(#(\d+)\)$/;
10051
+ var MERGE = /^Merge pull request #(\d+) from \S+$/;
10052
+ function mergeOf(sha, subject, body) {
10053
+ const merge = MERGE.exec(subject);
10054
+ const squash = merge ? null : SQUASH.exec(subject);
10055
+ const number = Number(merge?.[1] ?? squash?.[2] ?? 0);
10056
+ const title = (merge ? body.split(`
10057
+ `).find((l) => l.trim())?.trim() : squash?.[1]) || subject.trim() || sha.slice(0, 7);
10058
+ return { sha, ...number > 0 ? { number } : {}, title: Array.from(title).slice(0, 300).join("") };
10059
+ }
10060
+ async function mainMerges(deps, env, o) {
10061
+ if (!onMain(env, o.branch))
10062
+ return {};
10063
+ const tip = o.commit?.trim();
10064
+ if (!tip || tip.startsWith("-"))
10065
+ return { warning: "main merges not recorded: no commit is recorded for the run set (pass --commit, or run in GitHub Actions)" };
10066
+ const r = await deps.spawner.run("git", ["log", "--first-parent", `--max-count=${MAIN_MERGES_LIMIT}`, "--format=%H%x1f%s%x1f%b%x1e", tip, "--"], { timeoutMs: 1e4 }).catch(() => null);
10067
+ const merges = r && r.code === 0 ? r.stdout.split("\x1E").map((x) => x.replace(/^\n/, "").split("\x1F")).filter(([sha]) => sha && SHA.test(sha)).map(([sha, s, b]) => mergeOf(sha, s ?? "", b ?? "")) : [];
10068
+ if (!merges.length)
10069
+ return { warning: `main merges not recorded: git log ${tip.slice(0, 12)} failed (fetch main and the commit, in CI with fetch-depth: 0)` };
10070
+ if (merges.length < MAIN_MERGES_LIMIT) {
10071
+ const shallow = await deps.spawner.run("git", ["rev-parse", "--is-shallow-repository"], { timeoutMs: 5000 }).catch(() => null);
10072
+ if (shallow?.code === 0 && shallow.stdout.trim() === "true")
10073
+ return { merges, warning: `main merges stop at the clone's depth (${merges.length}): fetch main with fetch-depth: 0 for the PRs of main changes` };
10074
+ }
10075
+ return { merges };
10076
+ }
9471
10077
 
9472
10078
  // apps/agent/src/run-command.ts
9473
10079
  var EXIT = { ok: 0, infra: 1, config: 2, worse: 3 };
@@ -9513,7 +10119,7 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
9513
10119
  api = { url, key, ...rt.fetch ? { fetch: rt.fetch } : {} };
9514
10120
  }
9515
10121
  if (o.wait && !o.upload)
9516
- throw new ConfigError(["--wait needs --upload (the verdict is computed by the server)"]);
10122
+ throw new ConfigError(["--wait needs --upload (it waits until the server has processed the uploaded runs)"]);
9517
10123
  if (o.wait && !git.branch)
9518
10124
  throw new ConfigError(["--wait needs the branch: pass --branch, or run in GitHub Actions (GITHUB_HEAD_REF / GITHUB_REF_NAME)"]);
9519
10125
  if (o["fail-on-worse"] && !o.wait)
@@ -9532,11 +10138,15 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
9532
10138
  let runSet;
9533
10139
  if (api && git.branch) {
9534
10140
  const base = await baseCommitFor(deps, rt.env, { explicit: o["base-commit"], branch: git.branch, commit: git.commit }, rt.print);
10141
+ const main = await mainMerges(deps, rt.env, { branch: git.branch, commit: git.commit });
10142
+ if (main.warning)
10143
+ rt.print(main.warning);
9535
10144
  runSet = await createRunSet(api, {
9536
10145
  branch: git.branch,
9537
10146
  ...git.commit ? { commit_sha: git.commit } : {},
9538
10147
  ...base ? { base_commit_sha: base } : {},
9539
10148
  ...await commitFields(deps, rt.env, { commit: git.commit, base }),
10149
+ ...main.merges ? { merges: main.merges } : {},
9540
10150
  device: { ...device, provider },
9541
10151
  scenarios: flows.map((f) => f.name),
9542
10152
  journeys: await journeysFor(flows, { readFile: (p) => deps.fs.readFile(p), root: await repoRoot(deps) }),
@@ -9591,11 +10201,21 @@ ${outcome.summary}`);
9591
10201
  ...api.fetch ? { fetch: api.fetch } : {}
9592
10202
  }).catch(infra(`results are saved in ${out}, but the upload failed`));
9593
10203
  rt.print(`uploaded ${up.uploaded} runs to ${api.url}${up.skipped.length ? ` (${up.skipped.length} skipped: ${up.skipped.map((x) => `${x.run_id} ${x.why}`).join(", ")})` : ""}`);
10204
+ if (up.uploaded === 0) {
10205
+ if (runSet)
10206
+ await completeRunSet(api, runSet.run_set_id, "on-read").catch(infra("no run was uploaded, and the run set could not be closed"));
10207
+ const failed = up.skipped.length > 0 && up.skipped.every((x) => failedTest(x.why));
10208
+ const why = [...new Set(up.skipped.map((x) => x.why))].join(", ");
10209
+ rt.print(`${BLOCKED_WORD}: no run sent any data. ${failed ? `Every run failed its test command (${why}); see test.log in each run folder of ${out}.` : "Check that the build carries the SDK and that the app reaches the API."}`);
10210
+ if (runSet)
10211
+ await writeResult(deps, out, rt, runSet, null, git.branch, true);
10212
+ return EXIT.infra;
10213
+ }
9594
10214
  if (!runSet)
9595
10215
  return EXIT.ok;
9596
10216
  let summary = null;
9597
10217
  if (o.wait) {
9598
- summary = await completeRunSet(api, runSet.run_set_id, "inline").catch(infra("the runs are uploaded, but the verdict could not be computed"));
10218
+ summary = await completeRunSet(api, runSet.run_set_id, "inline").catch(infra("the runs are uploaded, but the run set could not be closed"));
9599
10219
  rt.print(`
9600
10220
  ${formatRunSet(summary)}`);
9601
10221
  } else {
@@ -9607,6 +10227,9 @@ ${formatRunSet(summary)}`);
9607
10227
  rt.print(`--fail-on-worse: a journey is ${STATE_WORDS.worse.toLowerCase()} than its baseline and blocks (exit 3)`);
9608
10228
  return EXIT.worse;
9609
10229
  }
10230
+ const unchecked = o["fail-on-worse"] && summary ? uncheckedGate(summary) : null;
10231
+ if (unchecked)
10232
+ rt.print(unchecked);
9610
10233
  return EXIT.ok;
9611
10234
  }
9612
10235
  async function replayFlow(config, scenario) {
@@ -9623,13 +10246,13 @@ var infra = (what) => (e) => {
9623
10246
  throw new Error(`${what}: ${e.message}`);
9624
10247
  throw e;
9625
10248
  };
9626
- async function writeResult(deps, out, rt, runSet, summary, branch) {
10249
+ async function writeResult(deps, out, rt, runSet, summary, branch, blocked = false) {
9627
10250
  const url = summary?.dashboard_url ?? runSet.dashboard_url;
9628
- await deps.fs.writeFile(join9(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: summary?.verdicts?.state ?? null, summary }, null, 2)}
10251
+ await deps.fs.writeFile(join9(out, "keepsnappy-result.json"), `${JSON.stringify({ run_set_id: runSet.run_set_id, dashboard_url: url, state: blocked ? "blocked" : summary?.verdicts?.state ?? null, summary }, null, 2)}
9629
10252
  `);
9630
10253
  const step = rt.env.GITHUB_STEP_SUMMARY;
9631
10254
  if (step)
9632
- await deps.fs.appendFile(step, markdownRunSet(summary, { dashboardUrl: url, branch }));
10255
+ await deps.fs.appendFile(step, blocked ? noResultMarkdown(branch, "No run sent any data.", url) : markdownRunSet(summary, { dashboardUrl: url, branch }));
9633
10256
  }
9634
10257
 
9635
10258
  // apps/agent/src/doctor.ts
@@ -10091,7 +10714,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
10091
10714
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
10092
10715
  import { fileURLToPath } from "node:url";
10093
10716
  function driverVersion() {
10094
- const built = "0.1.0-alpha.16";
10717
+ const built = "0.1.0-alpha.18";
10095
10718
  if (built)
10096
10719
  return built;
10097
10720
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -10146,8 +10769,576 @@ var SDK_FLUSH_WORST_CASE_MS = 315000;
10146
10769
  var FLUSH_MARGIN_MS = 30000;
10147
10770
  var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
10148
10771
  var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
10149
- // packages/appium-driver/dist/src/gestures.js
10150
- var densities = new WeakMap;
10772
+ // packages/verdict-core/dist/src/window-count.js
10773
+ var WINDOW_DEPENDENT_JOURNEY_MEASURES = ["sampler.cpu_ms", "sampler.frames.longest_gap_ms"];
10774
+ var OFF = new WeakSet;
10775
+ // packages/verdict-core/dist/src/clauses.js
10776
+ var row = (id, what, unit, cmp, ruling, uncalibrated, value) => ({ id, what, unit, cmp, ruling, uncalibrated, value });
10777
+ var NO_RULING = "no ruling: a starting value of the deciding config (L2 audit F-13)";
10778
+ var FLOOR = { rel: 0.3, abs: 5 };
10779
+ var COMPONENT_WARN_MS = { update: 5, work: 10 };
10780
+ var CLAUSES = {
10781
+ "G-MINRUNS": row("G-MINRUNS", `Valid runs a side needs to be compared at all; fewer is "Can't tell: too few runs".`, "runs", "≥", "no ruling for the deciding config (L2 audit G-MINRUNS)", true, { runs: 2 }),
10782
+ "B-BASELINE": row("B-BASELINE", "The baseline is main's newest sessions on the same phone, journey and renderer.", "sessions", "≥", "REQ-VER-33 (Q4); the number is a starting value", true, { sessions: 3 }),
10783
+ "M-LEVELSHIFT": row("M-LEVELSHIFT", "A change on main is confirmed when this many following main sessions agree.", "sessions", "≥", "REQ-VER-33, plan 63 §3 (controller 2026-10-17)", false, { confirmSessions: 2 }),
10784
+ "S-UNCERTAIN": row("S-UNCERTAIN", "A worse result is uncertain and does not block while main has fewer sessions than this.", "sessions", "≥", 'REQ-VER-38, Arek 2026-10-16 "Nie blokuj przy małej bazie"', false, { belowMainSessions: 3 }),
10785
+ "G-JUDGE-FROM": row("G-JUDGE-FROM", "A phone or simulator is judged only with at least this many main sessions.", "sessions", "≥", "REQ-VER-41, Arek 2026-10-18", false, { mainSessions: 3 }),
10786
+ "W-WORKKIND": row("W-WORKKIND", "The share of render time spent mounting from which a place is an in-screen mount, not an update.", "share", "≥", "REQ-MET-25; TraceSift MOUNT_DOMINANT_SHARE", true, { mountShareAtLeast: 0.8 }),
10787
+ "W-WINDOW-COUNT": row("W-WINDOW-COUNT", "A PR run that opened fewer windows on a screen than this share of main's median there leaves that screen (its taps, scrolls and steps) and the journey's window-dependent totals out of its comparisons.", "share", "≥", "plan 51 D2 floor (90 % of the usual window count), Arek 2026-10-22 ~07:20, per screen (plan 79 S8)", false, { below: 0.9 }),
10788
+ "T-FRAME": row("T-FRAME", "One frame, the unit of every frame limit, the same on every screen (a 120 Hz phone is not held to 8.3 ms).", "ms", ">", "REQ-MET-23, Arek 2026-10-16", false, { ms: LIMIT_FRAME_MS }),
10789
+ "C-DETAIL-MS": row("C-DETAIL-MS", "A component below this keeps key and ms only and is not listed as New or Removed.", "ms", "≥", "plan 79 S9 r2, Arek 2026-10-23 13:20; half the lowest component rule floor (judged and evidence)", false, { ms: Math.min(FLOOR.abs, COMPONENT_WARN_MS.update, COMPONENT_WARN_MS.work) / 2 }),
10790
+ "T-WARN-MS": row("T-WARN-MS", "A judged change counts as Slightly worse past at least this many ms (an update, or an entry / in-screen mount).", "ms", ">", "REQ-MET-26 (Arek 2026-10-16 ~14:30, ~17:30); followups [RULED 2026-10-18 ~09:40]", false, COMPONENT_WARN_MS),
10791
+ "TIER-ERROR": row("TIER-ERROR", "A judged change is Worse past at least this many ms (an update, or an entry / in-screen mount).", "ms", ">", "REQ-MET-26 (Arek 2026-10-16 ~17:30); over the 100 ms budget alone is only a warning (Arek 2026-10-17)", false, { update: 15, work: 30 }),
10792
+ "S-BETTER": row("S-BETTER", "Nothing worse and a judged improvement past the Worse line measured from main reads Better; the same magnitude must make a regression Worse. Applies to timing noise curves and memory thresholds.", "", null, "REQ-VER-47; Arek 2026-10-22 ~15:35, option 1 (standing-rulings.md): Better only past the Worse line; controller review ruling 2026-10-22: measure from main using the same timing noise and memory thresholds as Worse; no new number", false, { from: "error-tier" }),
10793
+ "T-WARN-NOISE": row("T-WARN-NOISE", "No-change noise curve per judged measure: Slightly worse past c x k95 x sqrt(main), Worse past c x k99 x sqrt(main).", "ms", ">", 'Arek 2026-10-16 "Ok" (the relative part adapts to size), 2026-10-18 ~09:40 (k95 warns, k99 errs); docs/stats/noise/2026-10-16-noise-fit.md "Result to encode"', false, {
10794
+ render: { c: 0.9244, k95: 1.5678, k99: 2.2255 },
10795
+ commit: { c: 0.8326, k95: 1.7675, k99: 2.8108 },
10796
+ component: { c: 0.6481, k95: 1.8075, k99: 2.6648 }
10797
+ }),
10798
+ "T-REL": row("T-REL", "The relative part of both tiers when a rule has no noise curve; the deciding config's tiers cap it at the error ms, so no input reaches it.", "share", ">", "REQ-MET-26, controller 2026-10-16", false, { rel: 0.3 }),
10799
+ "J-MEMORY-RETAINED": row("J-MEMORY-RETAINED", "Memory the journey kept after the end GC: Slightly worse past max(warn MB, warn % of main), Worse past max(error MB, error % of main), on every device.", "MB", ">", "REQ-MET-4, Arek 2026-10-20 ~08:00, pair B (standing-rulings.md); provisional", true, { warn: { abs: 20, rel: 0.1 }, error: { abs: 150, rel: 0.3 } }),
10800
+ "E-REACT-COUNTS": row("E-REACT-COUNTS", "Commit and render counts are evidence past max(floor, +50 % of main).", "count", ">", "REQ-MET-13, Arek 2026-10-20 (morning rulings, Layer 2); provisional", true, { commits: { rel: 0.5, abs: 2 }, renders: { rel: 0.5, abs: 10 } }),
10801
+ "E-RENDER-UPPER": row("E-RENDER-UPPER", "Timer-carried render time is evidence past max(floor, % of main).", "ms", ">", "REQ-MEAS-32 option A (Arek 2026-10-17); the number has no ruling", true, { render_upper: FLOOR }),
10802
+ "E-BACKGROUND": row("E-BACKGROUND", "Background places are evidence past max(floor, % of main).", "ms", ">", "REQ-MEAS-32; the number has no ruling", true, { background_commit: FLOOR, background_render: FLOOR, background_component: FLOOR }),
10803
+ "E-LAUNCH": row("E-LAUNCH", "The launch screen is evidence past max(5 ms, +30 % of main).", "ms", ">", "REQ-VER-15; floor adopted 2026-10-21 from config.ts F-13, [ctl-stab-noold-1625]", true, { launch_commit: FLOOR, launch_render: FLOOR, launch_component: FLOOR }),
10804
+ "E-STEP": row("E-STEP", "A declared step is evidence past max(5 ms, +30 % of main).", "ms", ">", "REQ-MET-14; floor adopted 2026-10-21 from config.ts F-13, [ctl-stab-noold-1625]", true, { step_commit: FLOOR, step_render: FLOOR, step_component: FLOOR }),
10805
+ "E-TOUCH": row("E-TOUCH", "Touch response is evidence past max(10 ms, +20 % of main).", "ms", ">", 'ruling "touch response is not judged" (REQ-MET-24)', true, { touch: { rel: 0.2, abs: 10 } }),
10806
+ "E-FREEZE": row("E-FREEZE", "The longest freeze (window, journey) is evidence past max(17 ms, +50 % of main).", "ms", ">", NO_RULING, true, { freeze: { rel: 0.5, abs: 17 }, freeze_journey: { rel: 0.5, abs: 17 } }),
10807
+ "E-MEMORY-PLACE": row("E-MEMORY-PLACE", "Memory added at a place is evidence past this many MB (screens have their own), on every device.", "MB", ">", 'REQ-MET-4 amendment 2026-10-18 ~21:10 and ~22:20 ("abs 2 MB, screens 10 MB")', true, { memory_added: { abs: 2 }, memory_added_screen: { abs: 10 } }),
10808
+ "E-MEMORY-JOURNEY": row("E-MEMORY-JOURNEY", "Memory at launch, end growth and peak growth are evidence past max(2 MB, +8 % of main).", "MB", ">", NO_RULING, true, { memory_launch: { rel: 0.08, abs: 2 }, memory_end_growth: { rel: 0.08, abs: 2 }, memory_peak_growth: { rel: 0.08, abs: 2 } }),
10809
+ "E-CPU-JOURNEY": row("E-CPU-JOURNEY", "Whole-journey CPU is evidence past +5 % of main.", "ms (relative part only)", ">", 'no ruling: "a +10 % whole-app CPU change must show as evidence"', true, { cpu: { rel: 0.05 } }),
10810
+ "E-WINDOW-LOAD": row("E-WINDOW-LOAD", "Window CPU, long tasks, GC time, requests and JS CPU are evidence past max(floor, % of main).", "ms (requests: count)", ">", NO_RULING, true, {
10811
+ window_cpu: { rel: 0.5, abs: 20 },
10812
+ long_tasks: { rel: 0.3, abs: 50 },
10813
+ gc: { rel: 0.5, abs: 2 },
10814
+ requests: { rel: 0.5, abs: 1 },
10815
+ js_cpu: { rel: 0.5, abs: 5 }
10816
+ }),
10817
+ "P-SLOW-COMMIT": row("P-SLOW-COMMIT", "Single problem: the slowest commit over this many frames in every PR run, on phones.", "frames", ">", "REQ-MET-27, Arek 2026-10-16 (a commit over one 60 Hz frame drops below 60 fps)", true, { frames: 1 }),
10818
+ "P-HEAVY-COMPONENT": row("P-HEAVY-COMPONENT", "Single problem: a component's self time over this many frames in every PR run, on phones.", "frames", ">", "REQ-MET-27, Arek 2026-10-16", true, { frames: 1 }),
10819
+ "P-HEAVY-ENTRY": row("P-HEAVY-ENTRY", "Single problem: React render time over this budget in every PR run at an entry or in-screen mount; also the budget of both kinds of work.", "ms", ">", "REQ-MET-25 ruling d (about 100 ms)", true, { ms: 100 }),
10820
+ "P-BUDGETS": row("P-BUDGETS", "The budget of an update, shown with a single problem, in frames.", "frames", ">", "REQ-MET-25 ruling d", true, { updateFrames: 1 }),
10821
+ "U-NOISY": row("U-NOISY", "A place is unstable on main above this ratio of spread to allowed, and stays so down to `below`; from this many main runs in this many sessions.", "ratio", ">", "REQ-MET-29, Arek 2026-10-18 ~12:00-13:20; thresholds provisional", false, { above: 1, below: 0.75, minMainRuns: 6, minSessions: 3 })
10822
+ };
10823
+ var CLAUSE_LIST = Object.values(CLAUSES);
10824
+ // packages/verdict-core/dist/src/config.js
10825
+ var WINDOWS = ["screen", "press", "drag", "step", "other"];
10826
+ var STEP = ["step"];
10827
+ var JUDGED_WINDOWS = WINDOWS.filter((k) => !STEP.includes(k));
10828
+ var BACKGROUND = ["background"];
10829
+ var LAUNCH = ["launch"];
10830
+ var SHOWN2 = [...WINDOWS, ...LAUNCH];
10831
+ var REACT_PLACES = [...SHOWN2, ...BACKGROUND];
10832
+ var JOURNEY = ["journey"];
10833
+ var DEVICE = ["device"];
10834
+ var ANY_DEVICE = ["device", "sim"];
10835
+ var ENTRY_BUDGET_MS = 100;
10836
+ var UNCERTAIN_BELOW_MAIN_SESSIONS = 3;
10837
+ var GUESS = (from) => ({ uncalibrated: true, source: `${from}; starting value carried from the plan 58 table (today's warn value), plan 57 calibrates it` });
10838
+ var evidenceRule = (id, on, places, threshold, from, more = {}) => ({
10839
+ id,
10840
+ role: "evidence",
10841
+ on,
10842
+ places,
10843
+ devices: DEVICE,
10844
+ threshold,
10845
+ ...GUESS(from),
10846
+ ...more
10847
+ });
10848
+ var MEMORY_MEASURES = Object.keys(MEASURES).filter((id) => MEASURES[id].unit.startsWith("MB"));
10849
+ var REACT_MEASURES = [
10850
+ "commit_max_ms",
10851
+ "render_ms",
10852
+ "commits",
10853
+ "renders",
10854
+ "mounts",
10855
+ "commits_over_budget",
10856
+ "mount_share",
10857
+ "self_ms_top",
10858
+ "component_ms",
10859
+ "dimension:component",
10860
+ "dimension:updater"
10861
+ ];
10862
+ var WINDOW_MODEL_AFFECTS = [
10863
+ "commits",
10864
+ "renders",
10865
+ "mounts",
10866
+ "alloc_mb",
10867
+ "gc_count",
10868
+ "gc_ms",
10869
+ "render_ms",
10870
+ "js_cpu_ms",
10871
+ "cpu_ms",
10872
+ "commit_max_ms",
10873
+ "commits_over_budget",
10874
+ "longtask_ms",
10875
+ "longtask_max_ms",
10876
+ "touch_max_ms",
10877
+ "net_requests",
10878
+ "net_duplicates",
10879
+ "dur_ms",
10880
+ "sampler_frames_missed",
10881
+ "sampler_footprint_delta_mb",
10882
+ "memory_left_mb",
10883
+ "retained_mb_per_cycle",
10884
+ "dimension:component",
10885
+ "dimension:updater"
10886
+ ];
10887
+ var MEASURES_AT = (kind) => [...Object.keys(MEASURES).filter((id) => MEASURES[id].at.includes(kind)), "dimension:component", "dimension:updater"];
10888
+ var SPLIT_AFFECTS = [
10889
+ ...REACT_MEASURES,
10890
+ ...Object.keys(MEASURES).filter((id) => /^(commits_over_|slow_commit_|mount_share_|unsettled_commit_)/.test(id))
10891
+ ];
10892
+ var GC_STEP_AFFECTS = [
10893
+ "gc_heap_before_mb",
10894
+ "gc_heap_kept_mb",
10895
+ "gc_heap_garbage_mb",
10896
+ "gc_memory_before_mb",
10897
+ "gc_memory_after_mb",
10898
+ "gc_memory_step_added_mb",
10899
+ "gc_memory_kept_mb",
10900
+ "gc_command_ms",
10901
+ "gc_queue_ms"
10902
+ ];
10903
+ var SAMPLER_EDGE_AFFECTS = [
10904
+ "cpu_ms",
10905
+ "sampler.cpu_ms",
10906
+ "sampler_frames_missed",
10907
+ "sampler_longest_gap_ms",
10908
+ "sampler.frames.longest_gap_ms",
10909
+ "sampler.frames.missed",
10910
+ "sampler.frames.presented",
10911
+ ...MEMORY_MEASURES.filter((m) => !GC_STEP_AFFECTS.includes(m))
10912
+ ];
10913
+ var DRAG_TIME_AND_CPU = ["dur_ms", "action_span_ms", "next_input_gap_ms", "action_uncovered_ms", "cpu_ms", "main_thread_ms", "sampler_cpu_cores", "js_cpu_ms", "js_thread_ms", "js_thread_app_ms"];
10914
+ var LOADED_MEMORY = Object.keys(MEASURES).filter((id) => /^(mem|sampler\.memory|ri\.memory)\./.test(id) || id === "memory_left_mb");
10915
+ var END_READ_DECLARES = [{ affects: ["mem.retained_growth_mb", "mem.end_after_gc_mb"], places: ["journey"] }];
10916
+ var END_READ_SOURCE = `plan 78 G4/G5, Arek 2026-10-21 (D5, "po GC powinniśmy robić manualny odczyt pamięci"): right after the end GC the driver asks the SDK for one memory reading (derivation 23, docs/design/endgc-manual-memory-read.md); memory kept is that reading minus launch, not the GC's own post record. REQ-FARM-35`;
10917
+ var PLACE_KEY_KINDS = ["screen", "launch", "press", "drag"];
10918
+ var SDK_A17_PLACES = ["screen", "press", "drag", "step"];
10919
+ var SDK_RELEASES = [
10920
+ {
10921
+ version: "0.1.0-alpha.9",
10922
+ previous: "0.1.0-alpha.8",
10923
+ declares: [{ affects: WINDOW_MODEL_AFFECTS, places: ["press", "drag"] }, { affects: ["mount_share", "mount_share_min", "mount_share_max"] }],
10924
+ uncalibrated: false,
10925
+ source: 'packages/react-native/CHANGELOG.md "window model 2" and "mountSelfMs"; git merge-base --is-ancestor 988945d / 5995729 v0.1.0-alpha.9 (yes), v0.1.0-alpha.8 (no)'
10926
+ },
10927
+ {
10928
+ version: "0.1.0-alpha.10",
10929
+ previous: "0.1.0-alpha.9",
10930
+ declares: [{ affects: SAMPLER_EDGE_AFFECTS }],
10931
+ uncalibrated: false,
10932
+ source: 'packages/react-native/CHANGELOG.md 0.1.0-alpha.10 "a sample at every window edge"; git merge-base --is-ancestor 7331c8f v0.1.0-alpha.10 (yes), v0.1.0-alpha.9 (no)'
10933
+ },
10934
+ {
10935
+ version: "0.1.0-alpha.11",
10936
+ previous: "0.1.0-alpha.10",
10937
+ declares: [
10938
+ { affects: MEASURES_AT("screen"), places: ["screen"] },
10939
+ { affects: SPLIT_AFFECTS, places: WINDOWS },
10940
+ { affects: MEASURES_AT("launch"), places: LAUNCH },
10941
+ { affects: GC_STEP_AFFECTS, places: ["step"] }
10942
+ ],
10943
+ uncalibrated: false,
10944
+ source: `pending release: window model 3, screen windows end at the next action with no 3 s cap (REQ-MEAS-12, packages/react-native/CHANGELOG.md; commits 741c36a and 62c0107 on main, not in v0.1.0-alpha.10); work split by its cause, every window's React measures are its judged part (REQ-MEAS-32, CHANGELOG "work is split by its cause", branch feat/action-causes-d16); the launch screen starts when the app starts a surface (REQ-MEAS-22, same branch); screen windows start at the action that caused them, with no time limits (REQ-MEAS-28, same branch); the GC command before a declared step adds the gc_* step rows (docs/plans/gc-before-action.md, branch feat/gc-before-action)`
10945
+ },
10946
+ {
10947
+ version: "0.1.0-alpha.12",
10948
+ previous: "0.1.0-alpha.11",
10949
+ declares: [{ affects: [...new Set([...MEASURES_AT("press"), ...MEASURES_AT("drag")])], places: ["press", "drag"] }],
10950
+ uncalibrated: false,
10951
+ source: 'packages/react-native/CHANGELOG.md 0.1.0-alpha.12 "Drag names that hold across runs, and flings on a moving list": back-to-back flings on a moving list are drags, not presses (`press:<row>` becomes `drag:<list>`), and generated list ids are no longer names; those press and drag places are not comparable across the change. Arek 2026-10-21 (A/A "Slightly worse" decision, Q-10): alpha.12 declares this rename; nothing else in it changes a measure (REQ-FARM-35).',
10952
+ renamed: { places: ["press", "drag"] }
10953
+ },
10954
+ {
10955
+ version: "0.1.0-alpha.13",
10956
+ previous: "0.1.0-alpha.12",
10957
+ declares: "nothing",
10958
+ uncalibrated: false,
10959
+ source: 'KeepSnappy native module, capture protocol and artifact naming; measurement calculations unchanged from alpha.12 (REQ-FARM-35, Arek 2026-10-19: declared "changes nothing"). Requires rebuilt native clients and coordinated server migration (docs/keepsnappy-migration.md).'
10960
+ },
10961
+ {
10962
+ version: "0.1.0-alpha.14",
10963
+ previous: "0.1.0-alpha.13",
10964
+ declares: END_READ_DECLARES,
10965
+ uncalibrated: false,
10966
+ source: `SDK answers the driver's memread command with one sample (reason endgc-read:<n>); ${END_READ_SOURCE}. Nothing else since alpha.13 changes a measure: the flush always ends and is acknowledged (#140), local builds carry +local (#147), each "Not an instrument change" in packages/react-native/CHANGELOG.md`
10967
+ },
10968
+ {
10969
+ version: "0.1.0-alpha.15",
10970
+ previous: "0.1.0-alpha.14",
10971
+ declares: "nothing",
10972
+ uncalibrated: false,
10973
+ source: "Shared release version only: git log v0.1.0-alpha.14..main -- packages/react-native is empty; SDK capture and measurement calculations are unchanged from alpha.14 (REQ-FARM-35)."
10974
+ },
10975
+ {
10976
+ version: "0.1.0-alpha.16",
10977
+ previous: "0.1.0-alpha.15",
10978
+ declares: [
10979
+ { affects: ["dimension:component", "dimension:updater", "self_ms_top", "component_ms"] },
10980
+ { affects: [...new Set(PLACE_KEY_KINDS.flatMap((k) => MEASURES_AT(k)))], places: PLACE_KEY_KINDS }
10981
+ ],
10982
+ renamed: { places: PLACE_KEY_KINDS, dimensions: ["component", "updater"] },
10983
+ uncalibrated: false,
10984
+ source: "plan 79 (docs/plans/79-ui-change-flexibility.md): client component keys with library time rolled up; places keyed by client components (D4, Arek 2026-10-21 ~19:15 and 2026-10-22 ~19:20); S9: every client component retained by value, compact detail below C-DETAIL-MS, SDK omission and derivation-26 completeness"
10985
+ },
10986
+ {
10987
+ version: "0.1.0-alpha.17",
10988
+ previous: "0.1.0-alpha.16",
10989
+ declares: [
10990
+ { affects: [...new Set(SDK_A17_PLACES.flatMap((k) => MEASURES_AT(k)))], places: SDK_A17_PLACES },
10991
+ { affects: [...WINDOW_DEPENDENT_JOURNEY_MEASURES], places: ["journey"] }
10992
+ ],
10993
+ renamed: { places: ["screen", "press", "drag"] },
10994
+ uncalibrated: false,
10995
+ source: "#277 (plan 74 step 4 SDK review): screen windows end at every touch delivered while open; late-mounting screens keep their component key (3-commit cap gone), so screen keys and the press/drag keys on them change; the run commit stream is cut at the flush, not inside a window (window times of long journeys and journey CPU change); a gesture whose touchEnd never reached JS moves press → drag; a late press takes its own open sample (press sampler measures). Flush answers lost data as error (validity only, no measure). Launch and journey memory stay compared."
10996
+ }
10997
+ ];
10998
+ var DRIVER_RELEASES = [
10999
+ {
11000
+ version: "0.1.0-alpha.12",
11001
+ previous: "0.1.0-alpha.11",
11002
+ declares: "nothing",
11003
+ uncalibrated: false,
11004
+ source: 'Host clock round trips each run (c1a430ed): the clock error falls from 126-197 ms on the S23, so step edges line up better; a stale element is looked up again; flingTimes, flingUntil, stableCentres and tapAt added for journeys. REQ-FARM-35, Arek 2026-10-19 ~21:40 (standing-rulings): declared "changes nothing" although it holds c1a430ed.'
11005
+ },
11006
+ {
11007
+ version: "0.1.0-alpha.13",
11008
+ previous: "0.1.0-alpha.12",
11009
+ declares: "nothing",
11010
+ uncalibrated: false,
11011
+ source: 'KeepSnappy link route, flush and GC answer element names and Android command name (the protocol cutover, docs/keepsnappy-migration.md); timing and touches unchanged. REQ-FARM-35, Arek 2026-10-19: declared "changes nothing".'
11012
+ },
11013
+ {
11014
+ version: "0.1.0-alpha.14",
11015
+ previous: "0.1.0-alpha.13",
11016
+ declares: END_READ_DECLARES,
11017
+ uncalibrated: false,
11018
+ source: `driver sends memread after the end GC's answer and waits for its answer before the flush; ${END_READ_SOURCE}. Nothing else since alpha.13 changes a measure: the flush is re-sent once and an unacknowledged flush fails the run (#140), driver-log.json is written as the run goes (#157), each "Not an instrument change" in packages/appium-driver/CHANGELOG.md`
11019
+ },
11020
+ {
11021
+ version: "0.1.0-alpha.15",
11022
+ previous: "0.1.0-alpha.14",
11023
+ declares: "nothing",
11024
+ uncalibrated: false,
11025
+ source: "Activation settles for 2 s before the journey relaunch (be050585, #167, ACTIVATION_SETTLE_MS); absent Android attributes read as null (3fed6c8c, #159). The delay is before the measured journey; attribute normalization is element lookup and post-window acknowledgement handling. No window edge, touch, GC, memory read or flush timing changes (REQ-FARM-35)."
11026
+ },
11027
+ {
11028
+ version: "0.1.0-alpha.16",
11029
+ previous: "0.1.0-alpha.15",
11030
+ declares: "nothing",
11031
+ uncalibrated: false,
11032
+ source: '#260 list and gesture helpers in appium-driver (not in the run path; adopting modules send identical gestures; CHANGELOG "Not an instrument change"); #211 driver.unit is evidence only (meta.device_unit). No window edge, touch, GC, memory read or flush timing changes (REQ-FARM-35).'
11033
+ },
11034
+ {
11035
+ version: "0.1.0-alpha.17",
11036
+ previous: "0.1.0-alpha.16",
11037
+ declares: [
11038
+ { affects: MEASURES_AT("drag"), places: ["drag"] },
11039
+ { affects: MEASURES_AT("step"), places: ["step"] },
11040
+ { affects: LOADED_MEMORY }
11041
+ ],
11042
+ uncalibrated: false,
11043
+ source: "Drag end points move above the platform bottom system-gesture zone (fix/driver-gesture-zone); start points unchanged; on iOS a shortened drag keeps its duration (slower); on Android it keeps its speed (ends sooner). A drag's end, and the duration of a step whose scroll steps contain such drags, can change. W1 (#306): farm sessions record no video or device logs by default (`--capture off`), so the phone carries less load and every memory measure (mem.*, memory_left_mb, sampler.memory.*, ri.memory.*) is measured differently from capture-on alpha.16 sessions (Elastyczność 2026-10-26 15:45 B1-DIFF; controller Stab 4). The declaration only separates alpha.16 from alpha.17: two alpha.17 sessions with capture on and off still compare unflagged until the run's setup.capture (#344) is judged (follow-up). Screens and taps stay compared (REQ-FARM-35)."
11044
+ },
11045
+ {
11046
+ version: "0.1.0-alpha.18",
11047
+ previous: "0.1.0-alpha.17",
11048
+ declares: [{ affects: DRAG_TIME_AND_CPU, places: ["drag"], platforms: ["ios"] }],
11049
+ uncalibrated: false,
11050
+ source: "R-SCROLL-BUILD (#355 one scroll primitive and the counted gesture retry, #356 the scroll fault matrix). Android declares nothing: on a phone that takes every gesture the driver sends the same requests, bodies and waits as alpha.17 (the whole command stream is pinned). iOS: each checked drag (every to-top drag of flingUntilShown/passChecked and the first drag of each scrollToEnd pass) gains two frame reads of the held row, one right before and one right after it, so a drag window's time and CPU can change; its React render, commits and memory stay compared. A phone that drops a gesture (BrowserStack Galaxy S23, S25 Ultra) gets that gesture once more, as a counted harness defect (REQ-FARM-35)."
11051
+ }
11052
+ ];
11053
+ var UNSTABLE_NOISY = {
11054
+ reason: "noisy",
11055
+ spread: "sessions",
11056
+ robust: true,
11057
+ between: false,
11058
+ above: 1,
11059
+ below: 0.75,
11060
+ minMainRuns: 6,
11061
+ minSessions: 3,
11062
+ curve: { render: 0.9244, commit: 0.8326, component: 0.6481 },
11063
+ uncalibrated: false,
11064
+ source: 'Arek 2026-10-18 ~12:00, ~12:20 (noisy on main = unstable, main only, per comparison, hysteresis), 12:50 (within-session spread only: a regression that lands on main moves whole sessions and must stay judged), 13:20 ("zrób na razie wg rekomendacji"; threshold provisional). ' + "Ratio = z99 x the predicted no-change difference SD from the pooled within-session MAD of main (Var(median) = (pi/2) sigma^2 / n) / the smallest warning step at main's median. " + "Replay (noisy/REPORT.md, derivation 22, 1 PR vs 3 main): S23 night 2 -> 1 false worse z 60 (MAD rule 0), sim 0, NI pool 95 -> 64 warnings z 840 (MAD 92); 3 vs 3 0 everywhere; flagged per split S23 17.8, NI 4.9, sim 0.7; judged per split S23 64.5 -> 46.8; flips S23 8 (MAD 21); planted main steps flagged noisy 0 (MAD 5-29 %, with the between-session part 56-99 %). " + "Curve: kept for the judged-rule lookup; the ratio uses the warning step"
11065
+ };
11066
+
11067
+ class ConfigError2 extends Error {
11068
+ problems;
11069
+ constructor(problems) {
11070
+ super(`invalid verdict config: ${problems.join("; ")}`);
11071
+ this.problems = problems;
11072
+ this.name = "ConfigError";
11073
+ }
11074
+ }
11075
+ var noRow = (id) => {
11076
+ throw new ConfigError2([`${id}: no row in the clause table`]);
11077
+ };
11078
+ var NOISE_FITS = CLAUSES["T-WARN-NOISE"].value;
11079
+ var TIER_REL = 0.3;
11080
+ var TIER_UPDATE_MS = { warnMs: 5, errorMs: 15 };
11081
+ var TIER_WORK_MS = { warnMs: 10, errorMs: 30 };
11082
+ var NOISE_SOURCE = 'Arek 2026-10-16 "Ok" (the relative part adapts to size, "przy małych pomiarach 50 %, przy coraz większych stopniowo coraz mniejszy"; sqrt form, one curve per measure and the two lifts chosen by the controller): ' + 'keepsnappy-measure scratchpad/measure/noise/fit.md "Result to encode" (scripts fit.py MODE=dedup-nomi, lifts.py). Data: S23 TapIn live-ui-appium 11 sessions x 3 (A/A 6073ac76, c86689e2, 4a784c05, f8d36522, 6814be6b; #1 b6d080ec, 52f47229, df55466a; #2 2a55a595, ab33b26e, 3042c714; changed places removed) and iPhone 15 Pro Appium A/A 6 x 3 (a18884dd, 9e364887, c775eab0, d45eb2c1, d3637624, b745fa1c), derivation 14; one place per window (+3s twins and one-window steps dropped), press:new-messages-indicator and its step left out (pushed chat, external work); 41 S23 and 40 iPhone places. ' + "R² of c x sqrt(m) before the dedup (fit.md §2): render 0.66, commit 0.44, component 0.42 (fitted on self_ms_top, the largest component); the dedup moves c by at most 0.03. Places above the lifted curve, S23 / iPhone: k95 render 0 z 41 / 4 z 40, commit 1 / 3, component 2 / 2; k99 render 0 / 1, commit 0 / 1, component 0 / 1. " + "Limits: the iPhone is not fully inside at k95; the iPhone set has 6 sessions (20 splits per place), SDK alpha.7 and window model 1; no split by kind of work";
11083
+ var CAPPED_SOURCE = 'Arek 2026-10-16 ~23:00 "Ok" (followups "[RULED 2026-10-16 ~23:00]"): REQ-MET-26 tiers, the relative part min(30 % of base, error tier ms) of the ~19:00 session-bar ruling, on all three judged measures (~17:50); the 30 % and the ms are not calibrated';
11084
+ var TIERS_SOURCE = 'REQ-MET-26: update error past 15 ms, entry and mount past 30 ms; going over the 100 ms limit is a warning, not an error (Arek 2026-10-17, followups 530fb5a: "Sam w sobie nie, to raczej ostrzeżenie"); same numbers as the pairs candidate (tiers2 BASE_LEVELS)';
11085
+ var MEMORY_PAIR_SOURCE = "REQ-MET-4, Arek 2026-10-20 ~08:00, standing-rulings.md pair B: Slightly worse above max(20 MB, 10 % of main), Worse above max(150 MB, 30 %); provisional, one rule for every device; docs/stats/memory/2026-10-20-memory-kept-two-thresholds.md";
11086
+ var COUNT_SOURCE = "REQ-MET-13, Arek 2026-10-20 ruling (docs/handoffs/2026-10-20-morning-rulings.md, Layer 2): counts are evidence only; commits above max(50 %, +2), renders above max(50 %, +10); provisional, not calibrated";
11087
+ var noiseJudged = (id, on) => {
11088
+ const fit = NOISE_FITS[id] ?? noRow(id);
11089
+ const noise = { a: 0, b: fit.c, power: 0.5, lift: fit.k95 };
11090
+ return {
11091
+ id,
11092
+ role: "judged",
11093
+ on,
11094
+ places: JUDGED_WINDOWS,
11095
+ devices: ANY_DEVICE,
11096
+ threshold: [{ abs: TIER_UPDATE_MS.warnMs, noise }],
11097
+ uncalibrated: false,
11098
+ source: `k95 lift ${fit.k95}, error tier k99 lift ${fit.k99}, c ${fit.c}; ${NOISE_SOURCE}`,
11099
+ thresholdByWork: { entry: [{ abs: TIER_WORK_MS.warnMs, noise }], mount: [{ abs: TIER_WORK_MS.warnMs, noise }] },
11100
+ errorNoise: { ...noise, lift: fit.k99 }
11101
+ };
11102
+ };
11103
+ var part = (b) => ({ ...b.rel !== undefined ? { rel: b.rel } : {}, ...b.abs !== undefined ? { abs: b.abs } : {} });
11104
+ var EVIDENCE_BARS = new Map(Object.values(CLAUSES).filter((c) => c.id.startsWith("E-")).flatMap((c) => Object.entries(c.value).map(([rule, bar]) => [rule, { bar, uncalibrated: c.uncalibrated }])));
11105
+ function withClauseValues(base) {
11106
+ if (!base.better)
11107
+ throw new ConfigError2(["better: missing from base config"]);
11108
+ const warnMs = CLAUSES["T-WARN-MS"].value;
11109
+ const errorMs = CLAUSES["TIER-ERROR"].value;
11110
+ const noise = CLAUSES["T-WARN-NOISE"];
11111
+ const memory = CLAUSES["J-MEMORY-RETAINED"];
11112
+ const judgedByNoise = (r) => {
11113
+ const fit = NOISE_FITS[r.id] ?? noRow(r.id);
11114
+ const n = { a: 0, b: fit.c, power: 0.5, lift: fit.k95 };
11115
+ return {
11116
+ ...r,
11117
+ threshold: [{ abs: warnMs.update, noise: n }],
11118
+ thresholdByWork: { entry: [{ abs: warnMs.work, noise: n }], mount: [{ abs: warnMs.work, noise: n }] },
11119
+ errorNoise: { ...n, lift: fit.k99 },
11120
+ uncalibrated: noise.uncalibrated
11121
+ };
11122
+ };
11123
+ const evidence = (r) => {
11124
+ const e = EVIDENCE_BARS.get(r.id) ?? noRow(r.id);
11125
+ const at = r.thresholdAt && Object.fromEntries(Object.keys(r.thresholdAt).map((kind) => [kind, [part((EVIDENCE_BARS.get(`${r.id}_${kind}`) ?? noRow(`${r.id}_${kind}`)).bar)]]));
11126
+ return { ...r, threshold: [part(e.bar)], ...at ? { thresholdAt: at } : {}, uncalibrated: e.uncalibrated };
11127
+ };
11128
+ const rules = base.rules.map((r) => {
11129
+ if (r.id === "memory_retained")
11130
+ return { ...r, threshold: [part(memory.value.warn)], uncalibrated: memory.uncalibrated };
11131
+ return r.role === "judged" ? judgedByNoise(r) : evidence(r);
11132
+ });
11133
+ const frames = (id) => ({ above: { frames: CLAUSES[id].value.frames }, uncalibrated: CLAUSES[id].uncalibrated });
11134
+ const entry = CLAUSES["P-HEAVY-ENTRY"];
11135
+ const unstable = CLAUSES["U-NOISY"];
11136
+ const c = (id) => CLAUSES[id];
11137
+ return {
11138
+ ...base,
11139
+ better: { ...base.better, from: c("S-BETTER").value.from, uncalibrated: c("S-BETTER").uncalibrated },
11140
+ minRuns: c("G-MINRUNS").value.runs,
11141
+ baseline: { ...base.baseline, sessions: c("B-BASELINE").value.sessions, uncalibrated: c("B-BASELINE").uncalibrated },
11142
+ levelShift: { ...base.levelShift, confirmSessions: c("M-LEVELSHIFT").value.confirmSessions, uncalibrated: c("M-LEVELSHIFT").uncalibrated },
11143
+ uncertain: { ...base.uncertain, belowMainSessions: c("S-UNCERTAIN").value.belowMainSessions, uncalibrated: c("S-UNCERTAIN").uncalibrated },
11144
+ judgeFrom: { ...base.judgeFrom, mainSessions: c("G-JUDGE-FROM").value.mainSessions, uncalibrated: c("G-JUDGE-FROM").uncalibrated },
11145
+ work: { ...base.work, mountShareAtLeast: c("W-WORKKIND").value.mountShareAtLeast, uncalibrated: c("W-WORKKIND").uncalibrated },
11146
+ ...base.windowCount ? { windowCount: { ...base.windowCount, below: c("W-WINDOW-COUNT").value.below, uncalibrated: c("W-WINDOW-COUNT").uncalibrated } } : {},
11147
+ frame: { ...base.frame, ms: c("T-FRAME").value.ms, uncalibrated: c("T-FRAME").uncalibrated },
11148
+ tiers: {
11149
+ ...base.tiers,
11150
+ rel: c("T-REL").value.rel,
11151
+ update: { warnMs: warnMs.update, errorMs: errorMs.update },
11152
+ work: { warnMs: warnMs.work, errorMs: errorMs.work },
11153
+ uncalibrated: c("T-WARN-MS").uncalibrated || c("TIER-ERROR").uncalibrated || c("T-REL").uncalibrated,
11154
+ memory: { error: part(memory.value.error), uncalibrated: memory.uncalibrated }
11155
+ },
11156
+ rules,
11157
+ problems: base.problems.map((p) => {
11158
+ if (p.id === "slow-commit")
11159
+ return { ...p, ...frames("P-SLOW-COMMIT") };
11160
+ if (p.id === "heavy-component")
11161
+ return { ...p, ...frames("P-HEAVY-COMPONENT") };
11162
+ if (p.id === "heavy-entry")
11163
+ return { ...p, above: { value: entry.value.ms }, uncalibrated: entry.uncalibrated };
11164
+ return noRow(p.id);
11165
+ }),
11166
+ budgets: { ...base.budgets, entry: { value: entry.value.ms }, mount: { value: entry.value.ms }, update: { frames: c("P-BUDGETS").value.updateFrames }, uncalibrated: c("P-BUDGETS").uncalibrated },
11167
+ unstable: base.unstable.map((u) => ({
11168
+ ...u,
11169
+ above: unstable.value.above,
11170
+ below: unstable.value.below,
11171
+ minMainRuns: unstable.value.minMainRuns,
11172
+ minSessions: unstable.value.minSessions,
11173
+ uncalibrated: unstable.uncalibrated,
11174
+ curve: Object.fromEntries(Object.keys(u.curve).map((id) => [id, (NOISE_FITS[id] ?? noRow(`unstable curve ${id}`)).c]))
11175
+ }))
11176
+ };
11177
+ }
11178
+ var SHAPE = {
11179
+ id: "simple-4.25+noise95-99",
11180
+ minRuns: 2,
11181
+ baseline: { sessions: 3, ...GUESS("Q4 / REQ-VER-33: main's newest 3 sessions against one PR session (the quieter setting of the plan 58 simulation)") },
11182
+ repeats: { kind: "every-pr-beyond-every-main" },
11183
+ frame: { ms: LIMIT_FRAME_MS, uncalibrated: false, source: "Arek 2026-10-16: one frame is 16.7 ms on every screen; lag starts below 60 fps, so a 120 Hz phone is not held to 8.3 ms" },
11184
+ levelShift: {
11185
+ confirmSessions: 2,
11186
+ uncalibrated: false,
11187
+ source: "the smallest number of agreeing main sessions with zero noise confirmations on the data (plan 63 §3; controller 2026-10-17), reviewer scripts scratchpad/rev-p6-scripts on the S23 sessions with candidates only after 3 sessions in their bounded baseline: aa-shifts (no change): A/A all 120 orders 0 shifts at m = 1 and m = 2, 11 sessions x 60 random orders 1 of 60 at both; aa-inject (false later shifts after an injected real step): m = 1 2 of 40 sequences, m = 2 0 of 40. Limit: a component with two typical levels (AnimatedView at step:gia.history-first gave the 1 of 60) can fool any m"
11188
+ },
11189
+ uncertain: { belowMainSessions: UNCERTAIN_BELOW_MAIN_SESSIONS, uncalibrated: false, source: 'Arek 2026-10-16: "Nie blokuj przy małej bazie". With fewer than 3 main sessions a worse result is reported as uncertain and does not fail the check' },
11190
+ work: {
11191
+ mountShareAtLeast: 0.8,
11192
+ uncalibrated: true,
11193
+ source: "TraceSift MOUNT_DOMINANT_SHARE (0.8); the share is a lower-biased estimate (PR C review), so it only separates an in-screen mount from an update; an entry is decided by the route-change label alone"
11194
+ },
11195
+ instrument: {
11196
+ aspects: ["sdk", "driver", "driverVersion", "journeyVersion", "actionGc"],
11197
+ sdkReleases: SDK_RELEASES,
11198
+ uncalibrated: false,
11199
+ source: 'Arek 2026-10-16 ("Tak, to też ustalaliśmy"): the SDK, the driver and the journey are the instrument (REQ-VER-35); a release declares the measures it affects (REQ-VER-34); a forced GC before each step is a different instrument (REQ-VER-13, docs/plans/gc-before-action.md)',
11200
+ driverReleases: DRIVER_RELEASES,
11201
+ unknownDiffers: ["journeyVersion"]
11202
+ },
11203
+ calibrated: { platforms: ["ios", "android"], uncalibrated: false, source: 'REQ-FARM-26: Android moved in by Arek ("Ok", 2026-10-17) after the S23 A/A, 5 sessions × 3 runs: 0 z 25 false worse under the deciding rule (stats-android/aa/aa-report.md)' },
11204
+ rules: [
11205
+ noiseJudged("commit", { measure: "commit_max_ms" }),
11206
+ noiseJudged("render", { measure: "render_ms" }),
11207
+ noiseJudged("component", { dimension: "component", measure: "self_ms" }),
11208
+ { ...evidenceRule("commits", { measure: "commits" }, REACT_PLACES, [{ rel: 0.5, abs: 2 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11209
+ { ...evidenceRule("renders", { measure: "renders" }, REACT_PLACES, [{ rel: 0.5, abs: 10 }], "render and commit counts are evidence only (ruling a)"), source: COUNT_SOURCE },
11210
+ evidenceRule("render_upper", { measure: "render_ms_upper" }, WINDOWS, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "REQ-MEAS-32 timers, option A (Arek 2026-10-17): judged plus timer-carried render time is evidence with the render threshold; render_ms decides"),
11211
+ evidenceRule("background_commit", { measure: "commit_max_ms" }, BACKGROUND, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "background places are evidence only (REQ-MEAS-32)"),
11212
+ evidenceRule("background_render", { measure: "render_ms" }, BACKGROUND, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "background places are evidence only (REQ-MEAS-32)"),
11213
+ evidenceRule("background_component", { dimension: "component", measure: "self_ms" }, BACKGROUND, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "background places are evidence only (REQ-MEAS-32)"),
11214
+ evidenceRule("launch_commit", { measure: "commit_max_ms" }, LAUNCH, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "the launch screen is evidence only (REQ-VER-15)"),
11215
+ evidenceRule("launch_render", { measure: "render_ms" }, LAUNCH, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "the launch screen is evidence only (REQ-VER-15)"),
11216
+ evidenceRule("launch_component", { dimension: "component", measure: "self_ms" }, LAUNCH, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "the launch screen is evidence only (REQ-VER-15)"),
11217
+ evidenceRule("step_commit", { measure: "commit_max_ms" }, STEP, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "a declared step is evidence only (REQ-MET-14)"),
11218
+ evidenceRule("step_render", { measure: "render_ms" }, STEP, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "a declared step is evidence only (REQ-MET-14)"),
11219
+ evidenceRule("step_component", { dimension: "component", measure: "self_ms" }, STEP, [{ rel: TIER_REL, abs: TIER_UPDATE_MS.warnMs }], "a declared step is evidence only (REQ-MET-14)"),
11220
+ evidenceRule("touch", { measure: "touch_max_ms" }, SHOWN2, [{ rel: 0.2, abs: 10 }], "touch response is not judged (ruling)"),
11221
+ evidenceRule("freeze", { measure: "sampler_longest_gap_ms" }, SHOWN2, [{ rel: 0.5, abs: 17 }], "v5.3 warn level"),
11222
+ evidenceRule("freeze_journey", { measure: "sampler.frames.longest_gap_ms" }, JOURNEY, [{ rel: 0.5, abs: 17 }], "v5.3 warn level"),
11223
+ evidenceRule("memory_added", { measure: "memory_left_mb" }, SHOWN2, [{ abs: 2 }], "v5.3 warn level", { thresholdAt: { screen: [{ abs: 10 }] }, devices: ANY_DEVICE }),
11224
+ evidenceRule("memory_launch", { measure: "mem.launch_mb" }, JOURNEY, [{ rel: 0.08, abs: 2 }], "v5.3 warn level"),
11225
+ evidenceRule("memory_end_growth", { measure: "mem.end_growth_mb" }, JOURNEY, [{ rel: 0.08, abs: 2 }], "v5.3 warn level"),
11226
+ evidenceRule("memory_peak_growth", { measure: "mem.peak_growth_mb" }, JOURNEY, [{ rel: 0.08, abs: 2 }], "v5.3 warn level"),
11227
+ evidenceRule("cpu", { measure: "sampler.cpu_ms" }, JOURNEY, [{ rel: 0.05 }], "a +10 % whole-app CPU change must show as evidence (hard case, with room for run noise on both sides); the prototype used 20 %"),
11228
+ evidenceRule("window_cpu", { measure: "cpu_ms" }, SHOWN2, [{ rel: 0.5, abs: 20 }], "v5.3 warn level"),
11229
+ evidenceRule("long_tasks", { measure: "longtask_ms" }, SHOWN2, [{ rel: 0.3, abs: 50 }], "v5.3 warn level"),
11230
+ evidenceRule("gc", { measure: "gc_ms" }, SHOWN2, [{ rel: 0.5, abs: 2 }], "v5.3 warn level"),
11231
+ evidenceRule("requests", { measure: "net_requests" }, SHOWN2, [{ rel: 0.5, abs: 1 }], "v5.3 warn level"),
11232
+ evidenceRule("js_cpu", { measure: "js_cpu_ms" }, SHOWN2, [{ rel: 0.5, abs: 5 }], "v5.3 warn level"),
11233
+ {
11234
+ id: "memory_retained",
11235
+ role: "judged",
11236
+ on: { measure: "mem.retained_growth_mb" },
11237
+ places: JOURNEY,
11238
+ devices: ANY_DEVICE,
11239
+ threshold: [{ rel: 0.1, abs: 20 }],
11240
+ uncalibrated: true,
11241
+ source: MEMORY_PAIR_SOURCE
11242
+ }
11243
+ ],
11244
+ problems: [
11245
+ {
11246
+ id: "slow-commit",
11247
+ on: { measure: "commit_max_ms" },
11248
+ places: JUDGED_WINDOWS,
11249
+ devices: DEVICE,
11250
+ above: { frames: 1 },
11251
+ inRuns: "all",
11252
+ uncalibrated: true,
11253
+ source: "Arek 2026-10-16: a commit over one 60 Hz frame (16.7 ms) drops below 60 fps, where lag starts; present in every PR run (TapIn Appium: 37 places, 9 of them at 17-22 ms in only some sessions)"
11254
+ },
11255
+ {
11256
+ id: "heavy-component",
11257
+ on: { dimension: "component", measure: "self_ms" },
11258
+ places: JUDGED_WINDOWS,
11259
+ devices: DEVICE,
11260
+ above: { frames: 1 },
11261
+ inRuns: "all",
11262
+ uncalibrated: true,
11263
+ source: "Arek 2026-10-16: one 60 Hz frame, as the slowest commit; a component with a long render in the PR's own runs; a component that exists only on the PR side lands here, never as a regression row (Q3)"
11264
+ },
11265
+ {
11266
+ id: "heavy-entry",
11267
+ on: { measure: "render_ms" },
11268
+ places: JUDGED_WINDOWS,
11269
+ devices: DEVICE,
11270
+ above: { value: ENTRY_BUDGET_MS },
11271
+ inRuns: "all",
11272
+ work: ["entry", "mount"],
11273
+ uncalibrated: true,
11274
+ source: 'ruling d: an entry or in-screen mount has a budget of about 100 ms of React work; its second half, "no dropped frames during the transition", is covered by `slow-commit` at one frame (the earlier `blocking-commit` at 2 frames only repeated it)'
11275
+ }
11276
+ ],
11277
+ budgets: {
11278
+ entry: { value: ENTRY_BUDGET_MS },
11279
+ mount: { value: ENTRY_BUDGET_MS },
11280
+ update: { frames: 1 },
11281
+ uncalibrated: true,
11282
+ source: "ruling d: entry and in-screen mount about 100 ms; an update one frame (16.7 ms on every screen, Arek 2026-10-16)"
11283
+ },
11284
+ freeze: { on: "sampler_longest_gap_ms", uncalibrated: false, source: 'evidence only: the longest freeze covers the whole window, not the transition; "no dropped frames during the transition" (ruling d) needs per-place frame data that is not available' },
11285
+ notJudgedLabels: ["launch"],
11286
+ unstable: [{
11287
+ ...UNSTABLE_NOISY,
11288
+ step: "own",
11289
+ source: `${UNSTABLE_NOISY.source}; Arek 2026-10-26 ("A wygląda kusząco. Oddaj do impl.", ctl-arek-met29-own-step): the step is the place's own work kind's (entry and in-screen mount 10 ms, update 5 ms), the smallest only when the kind is unknown`
11290
+ }],
11291
+ judgeFrom: { mainSessions: 3, uncalibrated: false, source: 'Arek 2026-10-18 (REQ-VER-41): a phone is judged only when main has at least 3 sessions on that phone and journey; with fewer the result is "not enough main yet", not judged, not blocking, values shown. Replay (agents/release/replay, d22): 1 PR vs 2 main on the S23 night pool gave 13 z 60 false worse+warning, 1 vs 3 gave 0' },
11292
+ measuredWith: [
11293
+ { aspect: "renderer", affects: REACT_MEASURES },
11294
+ { aspect: "reactExport", affects: MEMORY_MEASURES },
11295
+ { aspect: "windowModel", affects: WINDOW_MODEL_AFFECTS, places: ["press", "drag"], marks: true },
11296
+ { aspect: "screenWindowModel", affects: WINDOW_MODEL_AFFECTS, places: ["screen"], marks: true },
11297
+ { aspect: "cpuSource", affects: ["sampler.cpu_ms"] },
11298
+ { aspect: "causeSplit", affects: REACT_MEASURES, places: REACT_PLACES, marks: true },
11299
+ { aspect: "endGc", affects: MEMORY_MEASURES }
11300
+ ],
11301
+ tiers: {
11302
+ rel: TIER_REL,
11303
+ update: TIER_UPDATE_MS,
11304
+ work: TIER_WORK_MS,
11305
+ uncalibrated: false,
11306
+ source: `${NOISE_SOURCE}; ${CAPPED_SOURCE}; ${TIERS_SOURCE}; ${MEMORY_PAIR_SOURCE}`,
11307
+ relAtMostErrorMs: true,
11308
+ memory: { error: { rel: 0.3, abs: 150 }, uncalibrated: true }
11309
+ },
11310
+ better: { from: CLAUSES["S-BETTER"].value.from, uncalibrated: false, source: CLAUSES["S-BETTER"].ruling },
11311
+ placeCoverage: {
11312
+ uncalibrated: false,
11313
+ source: "plan 79 S1/S2, Arek 2026-10-21 ~18:55 and 2026-10-22 ~16:45: removed never scores; list main-only places; count main sessions that have each place and value"
11314
+ },
11315
+ windowCount: { below: CLAUSES["W-WINDOW-COUNT"].value.below, uncalibrated: CLAUSES["W-WINDOW-COUNT"].uncalibrated, source: 'plan 51 D2 floor (90 % of the usual window count), Arek 2026-10-22 ~07:20 "a changed number of swipes/windows on one screen must affect only that screen"; per PR run, a decrease only (an increase is not marked)' },
11316
+ detail: { componentMs: CLAUSES["C-DETAIL-MS"].value.ms, censored: "certify" },
11317
+ sdkExclusionByPlace: {
11318
+ uncalibrated: false,
11319
+ source: `REQ-VER-35 amendment 2026-10-21 (Arek, "porównywać to co się da"), option (a): only the places an SDK step moved are measured differently; comparable places decide, Can't tell only when no comparable place is left (one test for instrument marks, window counts and SDK exclusions); an unstable place is never judged, so its left-out values are not counted (REQ-MET-29)`
11320
+ }
11321
+ };
11322
+ var CONFIG = withClauseValues(SHAPE);
11323
+ var P2A_SLIGHT = withClauseValues({
11324
+ ...CONFIG,
11325
+ id: `${CONFIG.id}+p2a-sw`,
11326
+ unstable: CONFIG.unstable.map((u) => ({
11327
+ ...u,
11328
+ judgeAt: { line: "error", tier: "warning" },
11329
+ source: `${u.source}; P2a candidate (plan 81, Arek 2026-10-23 ~15:45): judged only past the error line, as Slightly worse, never blocking`
11330
+ }))
11331
+ });
11332
+ var P4_EVEN = {
11333
+ ...CONFIG,
11334
+ id: `${CONFIG.id}+p4-even`,
11335
+ evenSlowdown: { maxExcess: 0.3, renders: 0.1, commits: 0.25, uncalibrated: true, source: "plan 81 P4 (Opus O2 guesses); Arek 2026-10-23 ~12:45 direction (4); Arek 2026-10-26 ctl-arek-p81-p3p4: render and commit counts may block a downgrade as a guard, never colour a verdict alone" }
11336
+ };
11337
+ var P3_COMMIT = withClauseValues({
11338
+ ...CONFIG,
11339
+ id: `${CONFIG.id}+p3-commit`,
11340
+ commitGrouping: { uncalibrated: true, source: "plan 81 P3; Arek 2026-10-26 [ctl-arek-p81-p3p4]: two windows that share one proven commit (the same slowest commit in every PR run) may be one finding; never merged without it" }
11341
+ });
10151
11342
  // apps/agent/src/device-model.ts
10152
11343
  import { tmpdir } from "node:os";
10153
11344
  import { join as join12 } from "node:path";
@@ -10226,172 +11417,10 @@ async function detectSimulator(deps, udid) {
10226
11417
  }
10227
11418
 
10228
11419
  // apps/agent/src/slack.ts
10229
- var COLOR = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
10230
- var MAX_ROWS = 3;
10231
- var NAME_W = 30;
10232
- var COLUMN_GAP = 1;
10233
- function metricWord(item) {
10234
- if (item.label_text)
10235
- return item.label_text;
10236
- if (item.metric === "dur_ms" && placeKindWord(item.step ?? "") !== "Tap")
10237
- return "length";
10238
- return measureDef(item.metric)?.short ?? item.metric.replace(/_/g, " ");
10239
- }
10240
- function change2(item) {
10241
- if (item.change_text)
10242
- return item.change_text.replace(/ \(.*\)$/, "");
10243
- const { baseline: b, candidate: c } = item;
10244
- if (b === null || c === null)
10245
- return "";
10246
- if (b === 0)
10247
- return `${c - b >= 0 ? "+" : ""}${fieldValueText(item.metric, c - b)}`;
10248
- const p = Math.round((c / b - 1) * 100);
10249
- return `${p >= 0 ? "+" : ""}${p} %`;
10250
- }
10251
- var size = (i) => i.baseline === null || i.candidate === null ? 0 : i.baseline === 0 ? i.candidate === 0 ? 0 : Number.POSITIVE_INFINITY : Math.abs(Math.log(Math.max(i.candidate, 0.000000001) / i.baseline));
10252
- var bySize = (a, b) => size(a) === size(b) ? 0 : size(a) > size(b) ? -1 : 1;
10253
- var worseOrder = (a, b) => Number(a.grade === "warn") - Number(b.grade === "warn") || bySize(a, b);
10254
- var isWorse = (i) => i.state === "worse";
10255
- var isBetter = (i) => i.state === "better";
10256
- var differs = (sc) => sc?.headline?.measured_differently ?? [];
10257
- var scenarioBlocks = (sc) => sc?.headline?.blocking !== false;
10258
- var isRed = (i, sc) => i.grade !== "warn" && scenarioBlocks(sc);
10259
- var mark = (i, sc) => isBetter(i) ? "\uD83D\uDFE2" : isRed(i, sc) ? "\uD83D\uDD34" : i.grade !== "warn" ? "⚪" : "\uD83D\uDFE1";
10260
- var fit = (s, w) => s.length > w ? `${s.slice(0, w - 1)}…` : s.padEnd(w);
10261
- var right = (s, w) => s.padStart(w);
10262
- var phone = (sc) => sc?.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
10263
- function commitLine(ctx) {
10264
- const c = ctx.commit;
10265
- if (!c)
10266
- return null;
10267
- const short = c.sha.slice(0, 7);
10268
- return c.title ? `Commit: *${c.title}*${c.author ? ` · ${c.author}` : ""} (${short})` : `Commit ${short}`;
10269
- }
10270
- var section = (text) => ({ type: "section", text: { type: "mrkdwn", text } });
10271
- var context = (text) => ({ type: "context", elements: [{ type: "mrkdwn", text }] });
10272
- function prTable(items, sc) {
10273
- const cells = items.map((i) => ({ i, main: fieldValueText(i.metric, i.baseline), pr: fieldValueText(i.metric, i.candidate), change: change2(i) }));
10274
- const widest = (head, pick) => Math.max(head.length, ...cells.map((c) => pick(c).length)) + COLUMN_GAP;
10275
- const w = [NAME_W, widest("main", (c) => c.main), widest("PR", (c) => c.pr), widest("change", (c) => c.change)];
10276
- const head = `${" ".repeat(3)}${fit("", w[0])}${right("main", w[1])}${right("PR", w[2])}${right("change", w[3])}`;
10277
- const rows = cells.flatMap((c) => [
10278
- `${mark(c.i, sc)} ${fit(placeLabel(c.i.step ?? "*"), w[0])}${right(c.main, w[1])}${right(c.pr, w[2])}${right(c.change, w[3])}`,
10279
- ` ${metricWord(c.i)}`
10280
- ]);
10281
- return ["```", head, ...rows, "```"].join(`
10282
- `);
10283
- }
10284
- function mainCard(i) {
10285
- const label = (s) => ` ${s.padEnd(16)}`;
10286
- return [
10287
- "```",
10288
- placeLabel(i.step ?? "*"),
10289
- `${label(metricWord(i))}${right(fieldValueText(i.metric, i.candidate), 9)}`,
10290
- `${label("usually")}${right(fieldValueText(i.metric, i.baseline), 9)}`,
10291
- `${label("change")}${right(change2(i), 9)}`,
10292
- "```"
10293
- ].join(`
10294
- `);
10295
- }
10296
- function blockedMessage(s, ctx) {
10297
- const text = `⚪ No result for ${ctx.branch}: the measurement did not finish`;
10298
- const blocks = [
10299
- section(`*${text}*
10300
- ${ctx.blocked?.reason ?? "No iteration sent any data."}
10301
- Nothing was judged, so this is not a "${STATE_WORDS["no-change"].toLowerCase()}".`),
10302
- ...commitLine(ctx) ? [context(commitLine(ctx))] : [],
10303
- ...s ? [section(`\uD83D\uDC49 <${s.dashboard_url}|See what happened>`)] : []
10304
- ];
10305
- return { text, attachments: [{ color: COLOR.none, blocks }] };
10306
- }
10307
11420
  function slackMessage(s, ctx) {
10308
11421
  if (ctx.blocked)
10309
- return blockedMessage(s, ctx);
10310
- const v = s?.verdicts;
10311
- if (!s || !v)
10312
- return null;
10313
- const items = v.scenarios.flatMap((sc) => sc.state === "inconclusive" ? [] : movedItems(v, sc));
10314
- const scenarioOf = (i) => v.scenarios.find((sc) => sc.scenario === i.scenario && (!i.device_id || sc.device?.id === i.device_id)) ?? v.scenarios.find((sc) => sc.scenario === i.scenario);
10315
- const isPr = v.scenarios.some((sc) => sc.role === "comparison");
10316
- const worse = items.filter(isWorse).sort(worseOrder);
10317
- const better = items.filter(isBetter).sort(bySize);
10318
- const commit = commitLine(ctx);
10319
- const heated = v.scenarios.filter((sc) => heatNote(sc));
10320
- const heat = heated.map((sc) => context(heatNote(sc)));
10321
- const errorRows = worse.filter((i) => i.grade !== "warn");
10322
- const warningRows = worse.filter((i) => i.grade === "warn");
10323
- const errors = placeCount(errorRows);
10324
- const warns = placeCount(worse) - errors;
10325
- const red = errorRows.some((i) => isRed(i, scenarioOf(i)));
10326
- const notBlocking = errors > 0 && !red;
10327
- const worseMark = red ? "\uD83D\uDD34" : warns > 0 ? "\uD83D\uDFE1" : "⚪";
10328
- const worseColor = red ? COLOR.error : warns > 0 ? COLOR.warn : COLOR.none;
10329
- if (isPr) {
10330
- if (!worse.length) {
10331
- if (!heat.length)
10332
- return null;
10333
- const head = `⚪ ${ctx.branch}: ${INFO_WORDS.heat}`;
10334
- const blocks = [section(`*${head}*`), ...heat, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0].dashboard_url ?? s.dashboard_url}|View the comparison>`)];
10335
- return { text: head, attachments: [{ color: COLOR.none, blocks }] };
10336
- }
10337
- const graded = worse.some((i) => i.grade);
10338
- const places = (word, n) => `${word.toLowerCase()} in ${n} place${n > 1 ? "s" : ""}`;
10339
- const counts = graded ? [errors ? `${places(STATE_WORDS.worse, errors)}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? places(STATE_WORDS.warning, warns) : ""].filter(Boolean).join(", ") : `${places(STATE_WORDS.worse, placeCount(worse))}${notBlocking ? ` (${NOT_BLOCKING})` : ""}`;
10340
- const head = `${worseMark} ${ctx.branch}: ${counts}`;
10341
- const blocks = [section(`*${head}*`)];
10342
- const groups = new Map;
10343
- for (const i of worse)
10344
- groups.set(scenarioOf(i), [...groups.get(scenarioOf(i)) ?? [], i]);
10345
- let shown = 0;
10346
- for (const [sc, list] of groups) {
10347
- if (shown >= MAX_ROWS)
10348
- break;
10349
- const rows = list.slice(0, MAX_ROWS - shown);
10350
- shown += rows.length;
10351
- const nB = Math.max(...rows.map((r) => r.n_baseline ?? 0));
10352
- const nC = Math.max(...rows.map((r) => r.n_candidate ?? 0)) || (sc?.runs?.valid ?? 0);
10353
- const runs = nB && nC ? ` · ${nC} PR run${nC === 1 ? "" : "s"} vs ${nB} main run${nB === 1 ? "" : "s"}` : "";
10354
- const note = differs(sc).length ? `
10355
- _Measured differently (${differs(sc).join(", ")})${scenarioBlocks(sc) ? "" : ` — ${NOT_BLOCKING}`}_` : "";
10356
- const nonblocking = !scenarioBlocks(sc) && rows.some((i) => i.grade !== "warn") && !differs(sc).length ? ` · ${NOT_BLOCKING}` : "";
10357
- blocks.push(section(`*${sc?.scenario ?? "journey"}*${nonblocking} · ${phone(sc)}${runs}${note}
10358
- ${prTable(rows, sc)}`));
10359
- }
10360
- const more = worse.length - shown;
10361
- blocks.push(...heat);
10362
- if (commit)
10363
- blocks.push(context(commit));
10364
- blocks.push(section(`\uD83D\uDC49 <${groups.keys().next().value?.dashboard_url ?? s.dashboard_url}|View the comparison>${more > 0 ? ` · ${more} more` : ""}`));
10365
- return { text: head, attachments: [{ color: worseColor, blocks }] };
10366
- }
10367
- const improved = better.length > 0 || v.state === "better";
10368
- if (!worse.length && !improved && !ctx.always && !heat.length)
10369
- return null;
10370
- const parts = [errors ? `${errors} ${STATE_WORDS.worse.toLowerCase()}${notBlocking ? ` (${NOT_BLOCKING})` : ""}` : "", warns ? `${warns} ${STATE_WORDS.warning.toLowerCase()}` : "", better.length ? `${placeCount(better)} ${STATE_WORDS.better.toLowerCase()}` : ""].filter(Boolean);
10371
- const quiet = improved ? STATE_WORDS.better.toLowerCase() : (v.state === null || v.state === "inconclusive") && v.word ? v.word : STATE_WORDS["no-change"].toLowerCase();
10372
- const status = parts.length ? `${ctx.branch}: ${parts.join(", ")}` : !improved && heat.length && !ctx.always ? `${ctx.branch}: ${INFO_WORDS.heat}` : quiet.startsWith("Main: ") && ctx.branch === "main" ? quiet : `${ctx.branch}: ${quiet}`;
10373
- const head = `${worse.length ? worseMark : improved ? "\uD83D\uDFE2" : v.state === "inconclusive" && (ctx.always || !heat.length) ? "❔" : "⚪"} ${status}`;
10374
- const journeys = new Set(v.scenarios.map((sc) => `${sc.scenario}|${sc.device?.id}`)).size;
10375
- const blocks = [section(`*${head}*
10376
- ${journeys} journey${journeys === 1 ? "" : "s"} checked`)];
10377
- let shown = 0;
10378
- for (const [title, list] of [[STATE_WORDS.worse, errorRows], [STATE_WORDS.warning, warningRows], [STATE_WORDS.better, better]]) {
10379
- for (const i of list) {
10380
- if (shown >= MAX_ROWS)
10381
- break;
10382
- shown++;
10383
- const sc = scenarioOf(i);
10384
- blocks.push(section(`*${title}*${i.grade !== "warn" && isWorse(i) && !scenarioBlocks(sc) ? ` (${NOT_BLOCKING})` : ""} · *${sc?.scenario ?? "journey"}* · ${phone(sc)}
10385
- ${mainCard(i)}`));
10386
- }
10387
- }
10388
- const more = worse.length + better.length - shown;
10389
- blocks.push(...heat);
10390
- if (commit)
10391
- blocks.push(context(commit));
10392
- blocks.push(section(`\uD83D\uDC49 <${s.dashboard_url}|Open KeepSnappy>${more > 0 ? ` · ${more} more changes` : ""}`));
10393
- const color = worse.length ? worseColor : improved ? COLOR.better : COLOR.none;
10394
- return { text: head, attachments: [{ color, blocks }] };
11422
+ return noResultSlackPayload(ctx.branch, ctx.blocked.reason, s ? linkOf(s) : null);
11423
+ return s ? resultSlackPayload(s, { always: ctx.always }) : null;
10395
11424
  }
10396
11425
  async function postToSlack(url, payload, fetchImpl = fetch) {
10397
11426
  try {
@@ -10409,17 +11438,23 @@ var LINKS_USAGE = `keepsnappy activation-link --scheme <app://> --token <run tok
10409
11438
  flush-link --scheme <app://>
10410
11439
  gc-link --scheme <app://> [--n 1]`;
10411
11440
  function linksCommand(cmd, argv, rt) {
10412
- const { values: o } = parseArgs({
10413
- args: [...argv],
10414
- options: {
10415
- scheme: { type: "string" },
10416
- token: { type: "string" },
10417
- n: { type: "string" },
10418
- "no-diag": { type: "boolean" },
10419
- "no-profile": { type: "boolean" },
10420
- set: { type: "string", multiple: true }
11441
+ const { values: o } = (() => {
11442
+ try {
11443
+ return parseArgs({
11444
+ args: [...argv],
11445
+ options: {
11446
+ scheme: { type: "string" },
11447
+ token: { type: "string" },
11448
+ n: { type: "string" },
11449
+ "no-diag": { type: "boolean" },
11450
+ "no-profile": { type: "boolean" },
11451
+ set: { type: "string", multiple: true }
11452
+ }
11453
+ });
11454
+ } catch (e) {
11455
+ throw new UsageError([e.message, LINKS_USAGE]);
10421
11456
  }
10422
- });
11457
+ })();
10423
11458
  if (!o.scheme)
10424
11459
  throw new ConfigError([`--scheme is required (the app's URL scheme, e.g. myapp://)`]);
10425
11460
  if (cmd === "flush-link") {
@@ -10470,7 +11505,7 @@ function instrumentFlags(f) {
10470
11505
  return { ...driver ? { "driver-version": driver } : {}, ...journey ? { "journey-version": journey } : {} };
10471
11506
  }
10472
11507
  var TOKENS_STUB = "// Replaced by `keepsnappy farm open` for one upload. Never commit real tokens.\noutput.keepsnappyTokens = {};\n";
10473
- var JOURNEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
11508
+ var JOURNEY2 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
10474
11509
  var DEFAULT_TTL = 21600;
10475
11510
  var POLL_MS = 5000;
10476
11511
  function tokensJs(api, runSetId, tokens, links = maestroLinkFields()) {
@@ -10557,7 +11592,7 @@ async function farmOpen(deps, f, rt) {
10557
11592
  if (journeys.length === 0)
10558
11593
  problems.push("--journeys a,b is required (the flow names the device runs, one history per journey)");
10559
11594
  for (const j of journeys)
10560
- if (!JOURNEY.test(j))
11595
+ if (!JOURNEY2.test(j))
10561
11596
  problems.push(`--journeys: "${j}" is not a journey name (letters, digits, . _ -)`);
10562
11597
  if (new Set(journeys).size !== journeys.length)
10563
11598
  problems.push("--journeys lists a journey twice");
@@ -10585,11 +11620,15 @@ async function farmOpen(deps, f, rt) {
10585
11620
  const withInstrument = { ...f, ...instrumentFlags(f) };
10586
11621
  const setup = Object.fromEntries(SETUP_FLAGS.flatMap(([flag, key]) => withInstrument[flag]?.trim() ? [[key, withInstrument[flag].trim().slice(0, 200)]] : []));
10587
11622
  const base = await baseCommitFor(deps, rt.env, { explicit: f["base-commit"], branch: git.branch, commit: git.commit }, rt.print);
11623
+ const main = await mainMerges(deps, rt.env, { branch: git.branch, commit: git.commit });
11624
+ if (main.warning)
11625
+ rt.print(main.warning);
10588
11626
  const rs = await createRunSet(api, {
10589
11627
  branch: git.branch,
10590
11628
  ...git.commit ? { commit_sha: git.commit } : {},
10591
11629
  ...base ? { base_commit_sha: base } : {},
10592
11630
  ...await commitFields(deps, rt.env, { commit: git.commit, base }),
11631
+ ...main.merges ? { merges: main.merges } : {},
10593
11632
  device: { model: device.model, ...device.os ? { os: device.os } : {}, provider: device.provider, ...f["device-id"]?.trim() ? { id: f["device-id"].trim() } : {} },
10594
11633
  scenarios: journeys,
10595
11634
  journeys: journeyInfo,
@@ -10706,7 +11745,7 @@ async function settle(api, runSetId, o) {
10706
11745
  }
10707
11746
  }
10708
11747
  var FAILED_SESSION = new Set(["failed", "error", "timedout", "stopped", "skipped"]);
10709
- var median5 = (xs) => {
11748
+ var median6 = (xs) => {
10710
11749
  const v = [...xs].sort((a, b) => a - b);
10711
11750
  return v.length % 2 ? v[(v.length - 1) / 2] : (v[v.length / 2 - 1] + v[v.length / 2]) / 2;
10712
11751
  };
@@ -10728,10 +11767,11 @@ function brokenRun(state, runs, session) {
10728
11767
  const others = ordered.filter((r) => r !== last && r.journey === last.journey && has(r)).map((r) => byId.get(r.run_id).windows ?? 0);
10729
11768
  const journey = typeof s.usual_windows === "number" && s.usual_windows > 0 ? s.usual_windows : null;
10730
11769
  const mine = ordered.filter((r) => r.journey === last.journey && has(r) && byId.get(r.run_id).complete !== false).map((r) => byId.get(r.run_id).windows ?? 0).filter((n) => n > 0);
10731
- const m = mine.length >= 2 ? median5(mine) : null;
10732
- const changed = m !== null && mine.every((n) => n >= 0.9 * m && 0.9 * n <= m) && (journey === null || m < 0.9 * journey);
10733
- const usual = changed ? m : journey ?? (others.length ? median5(others) : null);
10734
- if (usual !== null && (s.windows ?? 0) < 0.9 * usual)
11770
+ const m = mine.length >= 2 ? median6(mine) : null;
11771
+ const floor = CLAUSES["W-WINDOW-COUNT"].value.below;
11772
+ const changed = m !== null && mine.every((n) => n >= floor * m && floor * n <= m) && (journey === null || m < floor * journey);
11773
+ const usual = changed ? m : journey ?? (others.length ? median6(others) : null);
11774
+ if (usual !== null && (s.windows ?? 0) < floor * usual)
10735
11775
  return last;
10736
11776
  return next;
10737
11777
  }
@@ -10767,6 +11807,7 @@ async function farmClose(deps, f, rt) {
10767
11807
  await scrubTokens(deps, state.tokens_js, rt);
10768
11808
  }
10769
11809
  }
11810
+ var blockedReason = (session) => `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.`;
10770
11811
  async function closeRunSet(deps, f, rt, state, statePath) {
10771
11812
  const settleSeconds = Number(f["settle-seconds"] ?? 60);
10772
11813
  const maxWaitSeconds = Number(f["max-wait-seconds"] ?? Math.max(300, settleSeconds * 3));
@@ -10821,7 +11862,7 @@ async function closeRunSet(deps, f, rt, state, statePath) {
10821
11862
  const blocked = summary.runs.valid === 0 && withData === 0;
10822
11863
  if (blocked) {
10823
11864
  rt.print("");
10824
- rt.print(`KeepSnappy result for ${where}: Blocked — no iteration sent any data${session.known && !session.passed ? ` (device session ${session.status})` : ""}.`);
11865
+ rt.print(`KeepSnappy result for ${where}: ${BLOCKED_WORD} — no iteration sent any data${session.known && !session.passed ? ` (device session ${session.status})` : ""}.`);
10825
11866
  rt.print(" Check that the build carries the SDK, the activation link ran, and the device reached the API.");
10826
11867
  rt.print(`Dashboard: ${summary.dashboard_url}`);
10827
11868
  } else if (f.wait) {
@@ -10842,42 +11883,23 @@ async function closeRunSet(deps, f, rt, state, statePath) {
10842
11883
  `);
10843
11884
  await githubOutputs(deps, rt, { state: state2.result.state ?? "none", dashboard_url: summary.dashboard_url, run_set_id: state.run_set_id });
10844
11885
  const step = rt.env.GITHUB_STEP_SUMMARY;
10845
- if (step) {
10846
- const head = blocked ? `### KeepSnappy: Blocked
10847
-
10848
- No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}. [Open the dashboard](${summary.dashboard_url})
10849
- ` : markdownRunSet(f.wait ? summary : null, { dashboardUrl: summary.dashboard_url, branch: state.branch });
10850
- const noData = summary.runs.no_data?.length ?? 0;
10851
- const tail = [
10852
- session.known && !session.passed ? `Device session ${session.status}${session.message ? `: ${session.message}` : ""}.` : "",
10853
- noData ? `${noData} of ${state.runs.length} iterations sent no data.` : ""
10854
- ].filter(Boolean);
10855
- await deps.fs.appendFile(step, `${head}${tail.length ? `
10856
- ${tail.join(" ")}
10857
- ` : ""}`);
10858
- }
11886
+ if (step)
11887
+ await deps.fs.appendFile(step, blocked ? noResultMarkdown(state.branch, blockedReason(session), summary.dashboard_url) : markdownRunSet(f.wait ? summary : null, { dashboardUrl: summary.dashboard_url, branch: state.branch }));
10859
11888
  const hook = envValue(rt.env, "SLACK_WEBHOOK");
10860
- if (hook) {
10861
- const commit = state.commit ? await commitInfo(deps, state.commit) : undefined;
10862
- const msg = slackMessage(f.wait || blocked ? summary : null, {
10863
- branch: state.branch,
10864
- ...commit ? { commit } : {},
10865
- ...blocked ? { blocked: { reason: `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.` } } : {},
10866
- always: envValue(rt.env, "SLACK_ALWAYS") === "1"
10867
- });
10868
- if (!msg)
10869
- rt.print(f.wait ? `Slack: nothing to report (${STATE_WORDS["no-change"].toLowerCase()})` : "Slack: skipped, the message needs --wait");
10870
- else {
10871
- const r = await postToSlack(hook, msg, rt.fetch ?? fetch);
10872
- rt.print(r.ok ? "Slack: posted" : `Slack: not posted (${r.error}); the job is not affected`);
10873
- }
10874
- }
11889
+ if (hook && blocked) {
11890
+ const r = await postToSlack(hook, slackMessage(summary, { branch: state.branch, blocked: { reason: blockedReason(session) } }), rt.fetch ?? fetch);
11891
+ rt.print(r.ok ? "Slack: posted" : `Slack: not posted (${r.error}); the job is not affected`);
11892
+ } else if (hook)
11893
+ rt.print("Slack: results are posted after the verdict is computed");
10875
11894
  if (blocked)
10876
11895
  return EXIT.infra;
10877
11896
  if (f["fail-on-worse"] && isBlocking(summary)) {
10878
11897
  rt.print(`--fail-on-worse: a journey is ${STATE_WORDS.worse.toLowerCase()} than its baseline and blocks (exit 3)`);
10879
11898
  return EXIT.worse;
10880
11899
  }
11900
+ const unchecked = f["fail-on-worse"] ? uncheckedGate(summary) : null;
11901
+ if (unchecked)
11902
+ rt.print(unchecked);
10881
11903
  return EXIT.ok;
10882
11904
  }
10883
11905
 
@@ -11181,7 +12203,7 @@ function nodeFarmRunPorts(deps, rt) {
11181
12203
  }
11182
12204
  var TUNNEL_TARGET = /^(?:localhost|127\.0\.0\.1):(\d{1,5})$/;
11183
12205
  var ENV_PAIR = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
11184
- var DEVICE = /^(.+)-([^-]+)$/;
12206
+ var DEVICE2 = /^(.+)-([^-]+)$/;
11185
12207
  var SECRETISH = /KEY|TOKEN|SECRET|PASSWORD/i;
11186
12208
  var RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
11187
12209
  function planFarmRun(f) {
@@ -11191,7 +12213,7 @@ function planFarmRun(f) {
11191
12213
  const platform = f.platform ?? "ios";
11192
12214
  if (platform !== "ios" && platform !== "android")
11193
12215
  problems.push("--platform must be ios or android");
11194
- if (!f.device || !DEVICE.test(f.device))
12216
+ if (!f.device || !DEVICE2.test(f.device))
11195
12217
  problems.push('--device "<model>-<os>" is required, e.g. "iPhone 15 Pro-17"');
11196
12218
  const driver = f.driver ?? "maestro";
11197
12219
  if (driver !== "maestro" && driver !== "appium")
@@ -11200,7 +12222,7 @@ function planFarmRun(f) {
11200
12222
  if (journeys.length === 0)
11201
12223
  problems.push("--journeys a,b is required");
11202
12224
  for (const j of journeys)
11203
- if (!JOURNEY.test(j))
12225
+ if (!JOURNEY2.test(j))
11204
12226
  problems.push(`--journeys: "${j}" is not a journey name (letters, digits, . _ -)`);
11205
12227
  const repeat = Number(f.repeat ?? 5);
11206
12228
  if (!Number.isInteger(repeat) || repeat < 1 || repeat > 100)
@@ -11297,7 +12319,7 @@ function printPlan(p, f, rt) {
11297
12319
  line(` env ${k}=${substitute(v, {})}`);
11298
12320
  line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${join16(p.out, `browserstack.${p.platform}.json`)}`);
11299
12321
  line(" 7. always: stop the tunnel, put the empty stub back over the tokens file");
11300
- line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse");
12322
+ line(` exit: 0 any verdict, 1 infrastructure / driver failed / ${BLOCKED_WORD}, 2 configuration, 3 --fail-on-worse (a computed blocking Worse only; no computed verdict: nothing checked)`);
11301
12323
  }
11302
12324
  var ANNOTATION = /^::(error|warning|notice)( [^:]*)?::/;
11303
12325
  async function runDriver2(deps, rt, f, p, ports, ctx) {
@@ -11400,7 +12422,7 @@ async function farmRun(deps, f, rt, ports) {
11400
12422
  let driver = { ok: true, message: null };
11401
12423
  let closeCode = EXIT.ok;
11402
12424
  try {
11403
- const [model, os] = [DEVICE.exec(f.device)[1], DEVICE.exec(f.device)[2]];
12425
+ const [model, os] = [DEVICE2.exec(f.device)[1], DEVICE2.exec(f.device)[2]];
11404
12426
  const openCode = await farmOpen(deps, {
11405
12427
  ...f,
11406
12428
  journeys: p.journeys.join(","),
@@ -11729,7 +12751,8 @@ var SHORT_FORM_NOTES = {
11729
12751
  main_vs_previous: `the movers after the first ${SHORT_PLACES} (every new, gone or multi-device place stays)`,
11730
12752
  place_trend: `the sessions older than the newest ${SHORT_SESSIONS} and the history restarts among them (unknown restarts always stay)`,
11731
12753
  window_history: `the runs older than the newest ${SHORT_POINTS}`,
11732
- pr_status: "the per-journey dashboard links and the heat note"
12754
+ pr_status: "the per-journey dashboard links and the heat note",
12755
+ list_problems: `the work and component lines, the dashboard links and the problems after the first ${SHORT_PLACES}`
11733
12756
  };
11734
12757
  var andList = (xs) => xs.length < 2 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
11735
12758
 
@@ -11748,9 +12771,20 @@ class Rows {
11748
12771
  full(...texts) {
11749
12772
  return this.add(false, ...texts);
11750
12773
  }
12774
+ detail(...texts) {
12775
+ for (const text of texts)
12776
+ for (const line of text.split(`
12777
+ `))
12778
+ this.rows.push({ text: line, short: false, notice: false });
12779
+ return this;
12780
+ }
12781
+ twin(full, short) {
12782
+ this.rows.push({ text: full, short: false, notice: false }, { text: short, short: true, shortOnly: true });
12783
+ return this;
12784
+ }
11751
12785
  render(form, left = []) {
11752
12786
  if (form !== "short")
11753
- return this.rows.map((r) => r.text).join(`
12787
+ return this.rows.filter((r) => !r.shortOnly).map((r) => r.text).join(`
11754
12788
  `);
11755
12789
  const kept = [];
11756
12790
  for (const r of this.rows) {
@@ -11762,7 +12796,7 @@ class Rows {
11762
12796
  }
11763
12797
  if (kept[kept.length - 1] === "")
11764
12798
  kept.pop();
11765
- if (this.rows.some((r) => !r.short && r.text.trim() !== ""))
12799
+ if (this.rows.some((r) => !r.short && r.notice !== false && r.text.trim() !== ""))
11766
12800
  kept.push(`Short form: ${andList(left.length ? left : ["other lines"])} left out; the full form shows them.`);
11767
12801
  return kept.join(`
11768
12802
  `);
@@ -11770,19 +12804,22 @@ class Rows {
11770
12804
  }
11771
12805
  var LABEL_W = 14;
11772
12806
  var line = (label, text) => ` ${label.padEnd(LABEL_W)}${text}`;
11773
- var plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
12807
+ var plural2 = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
11774
12808
  var promptText = (f) => f.what_to_do.prompt ?? "No prompt: this finding is an improvement.";
11775
12809
  function formatFinding(f, opts = {}) {
11776
12810
  const r = new Rows;
11777
12811
  r.both(f.answer.headline, line("What changed", f.answer.what_changed), line("Where", f.answer.where), line("How sure", f.answer.how_sure));
12812
+ if (f.lane)
12813
+ r.both(...laneContextLines(f.lane.context, f.lane.journey, f.lane.phone_name, opts.verbose, opts.form === "short"));
11778
12814
  r.full(line("Why", f.answer.why));
11779
12815
  r.both(line("What to do", f.answer.what_to_do));
11780
- r.full(line("Seen", f.answer.lifecycle), line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
12816
+ r.full(line("Seen", f.answer.lifecycle), ...f.lifecycle.unstable_note ? [line("", f.lifecycle.unstable_note)] : [], line("Layer", `${f.layer.id} · ${f.layer.name} (${f.certificate ? "certified" : "uncertified"})${f.limits.length ? `; limits: ${f.limits.join("; ")}` : ""}`));
11781
12817
  r.both(line("Id", f.id));
11782
12818
  if (f.why.components.length) {
11783
12819
  r.full(line("Components", ""));
11784
- for (const c of f.why.components) {
11785
- r.full(` ${c.component}: ${c.self_ms_before ?? "–"} → ${c.self_ms_after} ms own time, renders ${c.renders_before ?? "–"} → ${c.renders_after ?? "–"}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
12820
+ const names = componentReadNames(f.why.components.map((c) => ({ key: c.key, name: c.component, ...c.file ? { file: c.file } : {} })));
12821
+ for (const [i, c] of f.why.components.entries()) {
12822
+ r.full(` ${names[i]}${opts.verbose && c.file ? ` (${c.file})` : ""}: ${c.self_ms_before ?? "–"} → ${c.self_ms_after} ms ${c.key ? READ_WORDS.componentTime : READ_WORDS.ownTime}, renders ${c.renders_before ?? "–"} → ${c.renders_after ?? "–"}${c.cause ? `; ${c.cause}` : ""}${c.flagged ? "" : " (not certain)"}`);
11786
12823
  }
11787
12824
  }
11788
12825
  const memory = f.why.memory ?? [];
@@ -11797,15 +12834,15 @@ function formatFinding(f, opts = {}) {
11797
12834
  r.full("", promptText(f));
11798
12835
  return r.render(opts.form, [
11799
12836
  "the why, seen and layer lines",
11800
- ...f.why.components.length ? [plural(f.why.components.length, "component line")] : [],
11801
- ...memory.some((m) => m.metric !== f.what.metric) ? [plural(memory.filter((m) => m.metric !== f.what.metric).length, "memory evidence line")] : [],
12837
+ ...f.why.components.length ? [plural2(f.why.components.length, "component line")] : [],
12838
+ ...memory.some((m) => m.metric !== f.what.metric) ? [plural2(memory.filter((m) => m.metric !== f.what.metric).length, "memory evidence line")] : [],
11802
12839
  ...opts.prompt ? ["the prompt"] : []
11803
12840
  ]);
11804
12841
  }
11805
12842
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
11806
12843
  var LANES_SHOWN = 8;
11807
- var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
11808
- function measuredLines(l, r) {
12844
+ var STATE_WORDS3 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
12845
+ function measuredLines(l, r, opts) {
11809
12846
  const f = l.filters;
11810
12847
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
11811
12848
  const m = l.measured;
@@ -11813,7 +12850,7 @@ function measuredLines(l, r) {
11813
12850
  if (!n) {
11814
12851
  const on = f.branch ? f.branch === l.baseline_branch ? f.branch : `branch ${f.branch}` : "any branch";
11815
12852
  const narrow = [f.journey, f.phone ? `phone “${f.phone}”` : null].filter(Boolean).join(", ");
11816
- r.both(m.run_sets ? `${scope}: ${plural(m.run_sets, "run set")} measured, but no verdict yet: nothing is known about its performance.` : `No KeepSnappy measurement on ${on}${narrow ? ` for ${narrow}` : ""}: nothing is known about its performance.`);
12853
+ r.both(m.run_sets ? `${scope}: ${plural2(m.run_sets, "run set")} measured, but no verdict yet: nothing is known about its performance.` : `No KeepSnappy measurement on ${on}${narrow ? ` for ${narrow}` : ""}: nothing is known about its performance.`);
11817
12854
  return [];
11818
12855
  }
11819
12856
  const lane = (x) => {
@@ -11821,31 +12858,40 @@ function measuredLines(l, r) {
11821
12858
  return `${x.commit?.sha ? `commit ${x.commit.sha.slice(0, 7)}` : "commit not recorded"} (${when(x.at)}): ${x.word}${runs}${x.waiting_for_reprocess ? `; ${waitingWords(x.waiting_for_reprocess)}` : ""}`;
11822
12859
  };
11823
12860
  if (m.lanes.length === 1) {
11824
- r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural(m.run_sets, "run set")} measured.`);
12861
+ r.both(`${scope}: newest measured ${f.branch ? "" : `on ${n.branch}, `}${lane(n)}; ${plural2(m.run_sets, "run set")} measured.`);
12862
+ r.full(...(n.session_speed ?? []).map((line) => ` ${line}`));
12863
+ r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
11825
12864
  return [];
11826
12865
  }
11827
12866
  const shown = m.lanes.slice(0, LANES_SHOWN);
11828
- const unjudged = shown.filter((x) => !x.judged);
11829
- r.add(unjudged.length > 0, `${scope}: ${plural(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
11830
- for (const x of shown)
11831
- r.add(!x.judged, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12867
+ r.add(shown.some((x) => !x.judged || !!x.context), `${scope}: ${plural2(m.run_sets, "run set")} measured; the newest of each journey × phone:`);
12868
+ for (const x of shown) {
12869
+ const keep = !x.judged || !!x.context;
12870
+ r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12871
+ r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
12872
+ r.full(...(x.session_speed ?? []).map((line) => ` ${line}`));
12873
+ }
11832
12874
  if (m.lanes.length > shown.length)
11833
12875
  r.full(` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`);
11834
- return [...shown.length > unjudged.length ? [plural(shown.length - unjudged.length, "judged lane")] : [], ...m.lanes.length > shown.length ? [plural(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []];
12876
+ const judgedLeft = shown.filter((x) => x.judged && !x.context).length;
12877
+ return [
12878
+ ...judgedLeft ? [plural2(judgedLeft, "judged lane")] : [],
12879
+ ...m.lanes.length > shown.length ? [plural2(m.lanes.length - shown.length, "lane") + " beyond the eighth"] : []
12880
+ ];
11835
12881
  }
11836
12882
  function countsLine2(l) {
11837
12883
  const c = l.counts;
11838
12884
  const parts = [
11839
- c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} not blocking)` : ""}` : "",
12885
+ c.worse ? `${c.worse} ${STATE_WORDS.worse.toLowerCase()}${c.not_blocking ? ` (${c.not_blocking} ${NOT_BLOCKING})` : ""}` : "",
11840
12886
  c.warn ? `${c.warn} ${STATE_WORDS.warning.toLowerCase()}` : "",
11841
12887
  c.better ? `${c.better} ${STATE_WORDS.better.toLowerCase()}` : ""
11842
12888
  ].filter(Boolean);
11843
12889
  const state = l.filters.state;
11844
12890
  const unjudged = l.measured.lanes.filter((x) => !x.judged);
11845
- const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS2[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS2[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
12891
+ const head = parts.length ? `${parts.join(", ")}${state === "all" ? "" : `, ${STATE_WORDS3[state] ?? state}`}` : state !== "present" && state !== "all" ? `No finding ${STATE_WORDS3[state] ?? state}` : unjudged.length === l.measured.lanes.length ? `No finding, but nothing was compared yet (${[...new Set(unjudged.map((x) => x.word))].join(", ")}): measure again before calling it clean` : `Nothing ${STATE_WORDS.worse.toLowerCase()}, nothing ${STATE_WORDS.warning.toLowerCase()}, nothing ${STATE_WORDS.better.toLowerCase()}`;
11846
12892
  const heated = c.heat_runs ?? 0;
11847
12893
  const more = [
11848
- heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (run health, layer 3)` : "",
12894
+ heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (run health, layer 3)` : "",
11849
12895
  state === "present" && l.states.not_judged ? `${l.states.not_judged} more not judged in the newest run set (state not-judged)` : ""
11850
12896
  ].filter(Boolean);
11851
12897
  const line = `${head}${more.length ? `; ${more.join("; ")}` : ""}.`;
@@ -11853,7 +12899,7 @@ function countsLine2(l) {
11853
12899
  }
11854
12900
  function formatFindingList(l, opts = {}) {
11855
12901
  const r = new Rows;
11856
- const lanesLeft = measuredLines(l, r);
12902
+ const lanesLeft = measuredLines(l, r, opts);
11857
12903
  if (l.measured.newest || l.total)
11858
12904
  r.both(countsLine2(l));
11859
12905
  l.findings.forEach((f, i) => {
@@ -11870,11 +12916,7 @@ function formatFindingList(l, opts = {}) {
11870
12916
  if (l.findings.length)
11871
12917
  r.both("", "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt");
11872
12918
  const notWorse = l.findings.filter((f) => !isWorseState(f.state)).length;
11873
- return r.render(opts.form, [
11874
- ...l.findings.length ? [plural(l.findings.length, "how-sure line")] : [],
11875
- ...notWorse ? [plural(notWorse, "place line")] : [],
11876
- ...lanesLeft
11877
- ]);
12919
+ return r.render(opts.form, [...l.findings.length ? [plural2(l.findings.length, "how-sure line")] : [], ...notWorse ? [plural2(notWorse, "place line")] : [], ...lanesLeft]);
11878
12920
  }
11879
12921
  var STATUS_LEAD = {
11880
12922
  ok: "ok",
@@ -11890,9 +12932,11 @@ function formatBranchStatus(s, opts = {}) {
11890
12932
  if (s.lanes.length)
11891
12933
  r.both("");
11892
12934
  for (const l of s.lanes) {
11893
- const badge = l.result === "blocks" ? " (blocks)" : l.word === STATE_WORDS.worse ? ` (${NOT_BLOCKING})` : "";
12935
+ const shown = l.result === "blocks" ? `${l.word} (blocks)` : badgedWord(l.word, l.blocking);
11894
12936
  const sha = l.commit?.sha?.slice(0, 7);
11895
- r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${l.word}${badge} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
12937
+ r.both(` ${l.journey_title ?? l.journey} on ${l.phone_name}: ${shown} · ${sha ? `commit ${sha}` : "commit not recorded"} (${when(l.at)})${l.in_newest_run_set ? "" : " · older run set"}${l.waiting_for_reprocess ? `; ${waitingWords(l.waiting_for_reprocess)}` : ""}`);
12938
+ r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
12939
+ r.full(...(l.session_speed ?? []).map((line) => ` ${line}`));
11896
12940
  for (const f of l.worst)
11897
12941
  r.both(` ${f.id} ${f.headline}`);
11898
12942
  if (l.present_findings > l.worst.length)
@@ -11903,8 +12947,13 @@ function formatBranchStatus(s, opts = {}) {
11903
12947
  const links = s.lanes.filter((l) => l.link).length;
11904
12948
  const heated = s.counts.heat_runs ?? 0;
11905
12949
  const more = [
11906
- [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural(heated, "run")} (information, never blocks)` : ""],
11907
- [true, s.not_judged_findings ? `${plural(s.not_judged_findings, "finding")} not judged in the newest run set; they may still be there: keepsnappy findings --branch ${s.branch} --state not-judged` : ""],
12950
+ [false, heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (information, never blocks)` : ""],
12951
+ [
12952
+ true,
12953
+ s.not_judged_findings ? `${plural2(s.not_judged_findings, "finding")} not judged in the newest run set; they may still be there: keepsnappy findings --branch ${s.branch} --state not-judged` : ""
12954
+ ],
12955
+ [true, statusProblemsLine(s)],
12956
+ [true, s.problems?.unread_lanes ? `${unreadWords(s.problems.unread_lanes)} for problems: ${staleResult(s.problems.unread_lanes)}` : ""],
11908
12957
  [true, s.links.overview ? `Dashboard: ${s.links.overview}` : ""],
11909
12958
  [true, s.worst.length ? "One finding with its evidence and a prompt: keepsnappy finding <id> --prompt" : ""]
11910
12959
  ];
@@ -11913,10 +12962,65 @@ function formatBranchStatus(s, opts = {}) {
11913
12962
  r.both("");
11914
12963
  for (const [short, text] of shownMore)
11915
12964
  r.add(short, text);
11916
- return r.render(opts.form, [...links ? [plural(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
12965
+ return r.render(opts.form, [...links ? [plural2(links, "dashboard link")] : [], ...heated ? ["the heat note"] : []]);
12966
+ }
12967
+ function statusProblemsLine(s) {
12968
+ const p = s.problems;
12969
+ if (!p?.count)
12970
+ return "";
12971
+ return `${plural2(p.places, "place")} over budget in this branch’s own runs (${p.far_count ? `${p.far_count} far over budget; ` : ""}not a regression, never blocks): list_problems, or keepsnappy problems --branch ${s.branch}`;
12972
+ }
12973
+ var unreadWords = (n) => `${n} journey × phone not read`;
12974
+ var staleResult = (n) => `${n === 1 ? "its stored result is" : "their stored results are"} not current`;
12975
+ function formatProblemList(l, opts = {}) {
12976
+ const r = new Rows;
12977
+ const b = l.filters.branch;
12978
+ const c = l.counts;
12979
+ const unread = c.unread_lanes ?? 0;
12980
+ const read = l.lanes.length - unread;
12981
+ if (!l.measured.run_sets) {
12982
+ r.both(`No KeepSnappy measurement on ${b === l.baseline_branch ? b : `branch ${b}`}: nothing is known about its problems.`);
12983
+ return r.render(opts.form);
12984
+ }
12985
+ if (!l.lanes.length) {
12986
+ r.both(`${b}: ${plural2(l.measured.run_sets, "run set")} measured, but no judgement yet to read problems from.`);
12987
+ return r.render(opts.form);
12988
+ }
12989
+ if (c.count)
12990
+ r.both(`${b}: ${plural2(c.places, "place")} over budget in ${b === l.baseline_branch ? "main’s" : "this branch’s"} own runs${c.far_count ? ` (${c.far_count} far over budget)` : ""}`);
12991
+ else if (read)
12992
+ r.both(`${b}: no problem over budget in the newest run set (${read} journey × phone read)`);
12993
+ else
12994
+ r.both(`${b}: no problem could be read: ${unread} journey × phone has a stored result that is not current, so nothing is known about its problems.`);
12995
+ r.both(l.note);
12996
+ if (read && unread)
12997
+ r.both(`${unreadWords(unread)}: ${staleResult(unread)}, so nothing is known about ${unread === 1 ? "its" : "their"} problems.`);
12998
+ l.problems.forEach((p, i) => {
12999
+ const keep = i < SHORT_PLACES;
13000
+ r.add(keep, "", `${l.offset + i + 1}. ${p.text}: ${p.place_name}${opts.verbose ? ` (${p.place})` : ""}${p.intermittent ? ` — ${p.note}, not counted` : ""}`);
13001
+ r.add(keep, ` ${p.journey_title ?? p.journey} on ${p.phone_name}, ${p.runs_text}${p.tag_word ? `, ${p.tag_word}` : ""}`);
13002
+ const top = p.top_components[0];
13003
+ const topName = componentReadNames(p.top_components)[0];
13004
+ const detail = [p.work, top ? `heaviest ${topName}${opts.verbose && top.key ? ` (${top.key})` : ""} ${top.self_text}` : null, p.new_component ? NEW_IN_PR_CHIP : null, p.range_text ? `${p.range_text} between runs` : null].filter(Boolean);
13005
+ if (detail.length)
13006
+ r.full(` ${detail.join("; ")}`);
13007
+ if (p.url)
13008
+ r.full(` ${p.url}`);
13009
+ });
13010
+ const shown = l.problems.length ? `Showing ${l.offset + 1}–${l.offset + l.problems.length} of ${l.total}` : "";
13011
+ if (l.next_offset !== null)
13012
+ r.both("", `${shown || `Showing none of ${l.total}`}; the rest from offset ${l.next_offset} (--offset ${l.next_offset}).`);
13013
+ else if (l.offset > 0)
13014
+ r.both("", shown ? `${shown}.` : `Offset ${l.offset} is past the last of ${l.total}.`);
13015
+ const detailLines = l.problems.some((p) => p.work || p.top_components.length || p.new_component || p.range_text);
13016
+ return r.render(opts.form, [
13017
+ ...detailLines ? ["the work and component lines"] : [],
13018
+ ...l.problems.some((p) => p.url) ? ["the dashboard links"] : [],
13019
+ ...l.problems.length > SHORT_PLACES ? [`${plural2(l.problems.length - SHORT_PLACES, "problem")} beyond the fifth`] : []
13020
+ ]);
11917
13021
  }
11918
13022
  var commitText = (c) => !c ? "commit not recorded" : c.title ? `“${c.title}”${c.author ? ` by ${c.author}` : ""}${c.sha ? ` (${c.sha.slice(0, 7)})` : ""}` : c.sha ? c.sha.slice(0, 7) : "commit not recorded";
11919
- function formatCompare(r) {
13023
+ function formatCompare(r, opts = {}) {
11920
13024
  const out = [
11921
13025
  `Run A: ${r.a.external_id} on ${r.a.branch ?? "?"}, ${commitText(r.a.commit ? { sha: r.a.commit_sha, ...r.a.commit } : { sha: r.a.commit_sha, title: null, author: null })}`,
11922
13026
  `Run B: ${r.b.external_id} on ${r.b.branch ?? "?"}, ${commitText(r.b.commit ? { sha: r.b.commit_sha, ...r.b.commit } : { sha: r.b.commit_sha, title: null, author: null })}`,
@@ -11926,9 +13030,42 @@ function formatCompare(r) {
11926
13030
  out.push(`Measured differently: ${r.setup_differs.map((d) => `${d.field} ${JSON.stringify(d.a)} → ${JSON.stringify(d.b)}`).join(", ")}`);
11927
13031
  for (const w of r.windows) {
11928
13032
  const ms = w.metrics.filter((m) => m.a !== null || m.b !== null);
11929
- if (!ms.length)
13033
+ const c = w.component_page ?? w.components;
13034
+ if (!ms.length && !c)
11930
13035
  continue;
11931
- out.push("", w.window_name);
13036
+ out.push("", `${w.window_name}${opts.verbose ? ` (${w.window})` : ""}`);
13037
+ const singles = [...c?.added ?? [], ...c?.removed ?? [], ...w.component_page?.uncompared?.a ?? [], ...w.component_page?.uncompared?.b ?? []];
13038
+ const components = [...c?.rows ?? [], ...singles];
13039
+ const names = componentReadNames(components.map((row) => ({ key: row.key, name: row.component })));
13040
+ const shown = new Map(components.map((row, i) => [row.key, names[i]]));
13041
+ for (const row of c?.rows ?? [])
13042
+ out.push(` ${shown.get(row.key)}: ${row.self_ms_a === null ? "–" : exactComponentMs(row.self_ms_a)} → ${row.self_ms_b === null ? "–" : exactComponentMs(row.self_ms_b)}${opts.verbose ? ` (${row.key})` : ""}`);
13043
+ for (const [label, rows] of [
13044
+ [READ_WORDS.onlyB, c?.added ?? []],
13045
+ [READ_WORDS.onlyA, c?.removed ?? []]
13046
+ ]) {
13047
+ for (const row of rows)
13048
+ out.push(` ${shown.get(row.key)}: ${label}; ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13049
+ }
13050
+ const page = w.component_page;
13051
+ if (page) {
13052
+ for (const side of ["a", "b"]) {
13053
+ if (!page.measured[side])
13054
+ out.push(` ${notMeasuredText(side)}`);
13055
+ for (const row of page.uncompared?.[side] ?? [])
13056
+ out.push(` ${shown.get(row.key)}: ${side === "a" ? READ_WORDS.runA : READ_WORDS.runB} ${row.self_ms === null ? "–" : exactComponentMs(row.self_ms)}${opts.verbose ? ` (${row.key})` : ""}`);
13057
+ }
13058
+ out.push(` ${componentPageText(page.page, components.length)}`);
13059
+ } else if (c?.totals)
13060
+ out.push(` ${componentListsText(c.totals, { rows: c.rows.length, added: c.added?.length ?? 0, removed: c.removed?.length ?? 0 })}`);
13061
+ if (c?.omissions)
13062
+ for (const side of ["a", "b"]) {
13063
+ if (page && !page.measured[side])
13064
+ continue;
13065
+ const o = c.omissions[side];
13066
+ if (o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0)
13067
+ out.push(` ${omissionText({ run_id: side === "a" ? READ_WORDS.runA : READ_WORDS.runB, ...o })}`);
13068
+ }
11932
13069
  for (const m of ms)
11933
13070
  out.push(` ${m.label.padEnd(28)}${m.a_text.padStart(10)} → ${m.b_text}`);
11934
13071
  }
@@ -11937,22 +13074,35 @@ function formatCompare(r) {
11937
13074
  }
11938
13075
  function formatHistory(h, opts = {}) {
11939
13076
  const r = new Rows;
11940
- r.both(`${h.window_name} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
13077
+ r.both(`${h.window_name}${opts.verbose ? ` (${h.window})` : ""} in ${h.journey_title ?? h.journey} on ${h.branch}: ${h.points.length ? `${plural2(h.points.length, "run")}, oldest first` : h.note ?? "no runs"}`);
11941
13078
  const ms = h.measures.slice(0, 4);
13079
+ const renamed = h.renamed;
13080
+ const renamedAt = renamed ? h.points.findIndex((p) => p.run_id === renamed.from_run_id) : -1;
13081
+ const renamedShown = renamedAt >= h.points.length - SHORT_POINTS;
11942
13082
  if (h.points.length) {
11943
13083
  r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
11944
13084
  h.points.forEach((p, i) => {
11945
- r.add(i >= h.points.length - SHORT_POINTS, ` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}`);
13085
+ if (renamed && i === renamedAt) {
13086
+ const full = `${placeNamesChangedTitle(renamed.sdk)}: ${placeWasText(renamed.legacy_window)}`;
13087
+ if (renamedShown)
13088
+ r.twin(full, `${placeChartMarker(sdkTag(renamed.sdk))}: ${placeWasText(renamed.legacy_window)}`);
13089
+ else
13090
+ r.full(full);
13091
+ }
13092
+ r.add(i >= h.points.length - SHORT_POINTS, ` ${(p.at ?? "").slice(0, 16).replace("T", " ").padEnd(17)}${ms.map((m) => fieldValueText(m.metric, p.values[m.metric]).padStart(24)).join("")} ${commitText(p.commit)}${opts.verbose && p.window ? ` (${p.window})` : ""}`);
11946
13093
  });
11947
13094
  }
11948
13095
  if (h.findings.length)
11949
13096
  r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
11950
13097
  const older = Math.max(0, h.points.length - SHORT_POINTS);
11951
- return r.render(opts.form, older ? [plural(older, "older run")] : []);
13098
+ return r.render(opts.form, [
13099
+ ...older ? [plural2(older, "older run")] : [],
13100
+ ...renamed && renamedAt >= 0 && !renamedShown ? [placeNamesChangedOmitted(sdkTag(renamed.sdk))] : []
13101
+ ]);
11952
13102
  }
11953
13103
  function formatCommits(c) {
11954
13104
  return [
11955
- `${plural(c.commits.length, "measured commit")} (${plural(c.sessions, "session")}) on ${c.branch}, oldest first:`,
13105
+ `${plural2(c.commits.length, "measured commit")} (${plural2(c.sessions, "session")}) on ${c.branch}, oldest first:`,
11956
13106
  ...c.commits.map((x) => ` ${when(x.first_measured_at)} ${commitText(x)}${x.pull_request?.number ? `, PR #${x.pull_request.number}` : ""}${x.sessions > 1 ? `; ${x.sessions} sessions, the last ${when(x.last_measured_at)}` : ""}`),
11957
13107
  c.note
11958
13108
  ].join(`
@@ -11994,8 +13144,39 @@ function formatSessionPlaces(p, opts = {}) {
11994
13144
  p.places.forEach((place, i) => {
11995
13145
  const m = place.measures.find((x) => x.measure === by);
11996
13146
  const value = m ? `${fieldValueText(by, m.median)} (p90 ${fieldValueText(by, m.p90)}, n ${m.n})` : `no ${by}`;
11997
- const comps = place.components.map((c) => `${c.name} ${valueText("self_ms", c.self_ms.median)} (p90 ${valueText("self_ms", c.self_ms.p90)}${c.renders_median !== null ? `, ${plural(c.renders_median, "render")}` : ""})`);
11998
- r.both(`${p.offset + i + 1}. ${place.window_name}: ${value}`);
13147
+ const names = componentReadNames(place.components);
13148
+ const comps = place.components.map((c, i) => `${names[i]}${opts.verbose && c.file ? ` (${c.file})` : ""} ${valueText("self_ms", c.self_ms.median)} (p90 ${valueText("self_ms", c.self_ms.p90)}${c.renders_median !== null ? `, ${plural2(c.renders_median, "render")}` : ""})`);
13149
+ r.both(`${p.offset + i + 1}. ${place.window_name}${opts.verbose ? ` (${place.window})` : ""}: ${value}`);
13150
+ if (place.components_page)
13151
+ r.detail(` ${componentPageText(place.components_page, place.components.length)}`);
13152
+ for (const o of (place.omissions ?? []).filter((o) => o.sdkOmitted === null || o.omitted === null || o.sdkOmitted > 0 || o.omitted > 0))
13153
+ r.detail(` ${omissionText(o)}`);
13154
+ const slowest = place.measures.find((m) => m.measure === "commit_max_ms");
13155
+ if (by !== "commit_max_ms" && slowest)
13156
+ r.detail(` ${READ_WORDS.slowest}: ${fieldValueText("commit_max_ms", slowest.median)}`);
13157
+ for (const detail of place.details ?? []) {
13158
+ if (detail.slowestCommit)
13159
+ r.detail(` ${detail.run_id}: ${READ_WORDS.slowest}: ${exactComponentMs(detail.slowestCommit.ms)}`);
13160
+ r.detail(` ${detail.run_id}: ${OUTSIDE_APP_NAME}: ${detail.outside ? exactComponentMs(detail.outside.selfMs) : "–"}; ${OTHER_RENDER_WORK_NAME}: ${detail.remainderMs === null ? "–" : exactComponentMs(detail.remainderMs)}`);
13161
+ for (const l of detail.outside?.rolledUp ?? [])
13162
+ r.detail(` ${libraryLineText(l)}`);
13163
+ if (detail.outside?.rolledUpRest)
13164
+ r.detail(` ${rolledUpRestText(detail.outside.rolledUpRest)}`);
13165
+ }
13166
+ for (const [ci, c] of place.components.entries()) {
13167
+ if (c.detail === "ms-only" && !c.runs?.length)
13168
+ r.detail(` ${names[ci]}: ${READ_WORDS.detailUnavailable}`);
13169
+ for (const d of c.runs ?? [])
13170
+ if (d.detail === "ms-only")
13171
+ r.detail(` ${names[ci]} · ${d.run_id}: ${d.selfMs === null ? "–" : exactComponentMs(d.selfMs)}; ${READ_WORDS.detailUnavailable}`);
13172
+ for (const d of c.runs ?? [])
13173
+ if (d.detail === "full") {
13174
+ if (d.parent)
13175
+ r.detail(` ${names[ci]} · ${d.run_id}: ${READ_WORDS.parent} ${componentReadName({ key: d.parent, name: d.parent })}`);
13176
+ if (d.rolledUpMs !== undefined && d.selfMs !== null)
13177
+ r.detail(` ${names[ci]} · ${d.run_id}: ${rolledUpText(d.rolledUpMs, d.selfMs)}`, ...(d.rolledUp ?? []).map((l) => ` ${libraryLineText(l)}`), ...d.rolledUpRest ? [` ${rolledUpRestText(d.rolledUpRest)}`] : []);
13178
+ }
13179
+ }
11999
13180
  if (comps.length) {
12000
13181
  r.full(` Components: ${list(comps)}`);
12001
13182
  componentLines++;
@@ -12005,7 +13186,7 @@ function formatSessionPlaces(p, opts = {}) {
12005
13186
  r.both(p.total ? `Offset ${p.offset} is past the last of ${p.total} places.` : "No place with stored statistics in this session matches.");
12006
13187
  else if (p.next_offset !== null)
12007
13188
  r.both("", `Showing ${p.offset + 1}–${p.offset + p.places.length} of ${p.total}; the rest from offset ${p.next_offset} (--offset ${p.next_offset}).`);
12008
- return r.render(opts.form, componentLines ? [plural(componentLines, "component line")] : []);
13189
+ return r.render(opts.form, componentLines ? [plural2(componentLines, "component line")] : []);
12009
13190
  }
12010
13191
  function formatLayers(l) {
12011
13192
  return [
@@ -12027,7 +13208,7 @@ function formatMainSessions(h, opts = {}) {
12027
13208
  let otherChanges = 0;
12028
13209
  for (const s of h.sessions) {
12029
13210
  const k = recent.has(s);
12030
- r.add(k, `${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural(s.runs, "run")})`);
13211
+ r.add(k, `${when(s.at)} ${s.run_set_id} ${commitText(s.commit ? { ...s.commit, author: null } : null)} (${plural2(s.runs, "run")})`);
12031
13212
  if (!s.places)
12032
13213
  r.add(k, ` ${MAIN_SUMMARY_PENDING}${s.summary_reason ? ` (${s.summary_reason})` : ""}`);
12033
13214
  else
@@ -12037,15 +13218,35 @@ function formatMainSessions(h, opts = {}) {
12037
13218
  if (s.memory)
12038
13219
  r.add(k, ` Memory: ${MAIN_TIER_WORDS[s.memory.tier]}, ${s.memory.median} MB (n ${s.memory.n})${s.memory.cant_reason ? `; ${mainCantWords(s.memory.cant_reason, baseline)}` : ""}`);
12039
13220
  for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
12040
- r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
13221
+ r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`, ...renamedReleaseLines(v.renamed));
12041
13222
  for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id)) {
12042
- r.add(s === latest, ` Main change: ${c.place} ${c.ref}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
13223
+ r.add(s === latest, ` Main change: ${windowQualified(c.place)}${opts.verbose ? ` (${c.place}; ${c.ref})` : ""} ${storedRefWords(c.ref, h.changes.filter((x) => x.place === c.place && x.from_run_set_id === c.from_run_set_id).map((x) => x.ref))}, ${c.before_median ?? "–"} → ${c.after_median ?? "–"}`);
12043
13224
  if (k && s !== latest)
12044
13225
  otherChanges++;
12045
13226
  }
12046
13227
  }
12047
13228
  const older = h.sessions.length - recent.size;
12048
- return r.render(opts.form, [...older ? [plural(older, "older session")] : [], ...otherChanges ? [plural(otherChanges, "main change") + " of the other recent sessions"] : []]);
13229
+ return r.render(opts.form, [
13230
+ ...older ? [plural2(older, "older session")] : [],
13231
+ ...otherChanges ? [plural2(otherChanges, "main change") + " of the other recent sessions"] : []
13232
+ ]);
13233
+ }
13234
+ function formatMainChanges(m) {
13235
+ const r = new Rows().both("Main changes: confirmed level shifts of main, newest first, with the commits from the session before to the first session at the new level.");
13236
+ if (!m.changes.length)
13237
+ r.both("No main changes recorded for this journey and phone.");
13238
+ const short = (sha) => sha?.slice(0, 7) ?? "–";
13239
+ for (const c of m.changes) {
13240
+ r.both(`${when(c.at)} ${windowQualified(c.place)} ${storedRefWords(c.measure, m.changes.filter((x) => x.place === c.place && x.at === c.at).map((x) => x.measure))}, ${c.from ?? "–"} → ${c.to ?? "–"}, ${MAIN_TIER_WORDS[c.direction]}`);
13241
+ r.both(` Commits ${short(c.commits[0])} → ${short(c.commits[1])}`);
13242
+ if (c.prs === null)
13243
+ r.both(" PRs not known: no stored merges reach the session before (an older agent, a shallow clone, or no session before)");
13244
+ else if (!c.prs.length)
13245
+ r.both(" No PRs in this range: both sessions ran on the same commit");
13246
+ for (const p of c.prs ?? [])
13247
+ r.both(` ${p.number === null ? `${short(p.sha)} ${p.title} (no PR)` : `#${p.number} ${p.title}`}`);
13248
+ }
13249
+ return r.render("full");
12049
13250
  }
12050
13251
  function formatMainSessionPlaces(p, opts = {}) {
12051
13252
  const r = new Rows;
@@ -12054,29 +13255,45 @@ function formatMainSessionPlaces(p, opts = {}) {
12054
13255
  const baselineSessions = p.baseline_sessions;
12055
13256
  r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
12056
13257
  for (const x of p.places)
12057
- r.add(x.tier !== "same", ` ${x.place} ${x.ref}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}`);
13258
+ r.add(x.tier !== "same", ` ${windowQualified(x.place)}${opts.verbose ? ` (${x.place}; ${x.ref})` : ""} ${storedRefWords(x.ref, p.places.filter((row) => row.place === x.place).map((row) => row.ref))}: ${MAIN_TIER_WORDS[x.tier]}${x.reason ? `; ${mainPlaceReasonWords(x.reason, { baseline: { sessions: baselineSessions } })}` : ""}${x.reason && mainUnstableWords(x.reason) ? `. ${mainUnstableWords(x.reason)}` : ""}`);
12058
13259
  const inNorm = p.places.filter((x) => x.tier === "same").length;
12059
- return r.render(opts.form, inNorm ? [plural(inNorm, "place") + " in norm"] : []);
13260
+ return r.render(opts.form, inNorm ? [plural2(inNorm, "place") + " in norm"] : []);
12060
13261
  }
12061
13262
  var signed = (n) => `${n > 0 ? "+" : ""}${Number(n.toFixed(3))}`;
12062
- var previousLine = (p) => ` ${p.place} ${p.ref}${p.devices ? ` [devices ${p.devices.before.join(", ") || "–"} → ${p.devices.after.join(", ") || "–"}]` : ""}: ${p.status === "compared" ? "" : `${p.status}; `}${p.before ?? "–"} → ${p.after ?? "–"}; delta ${p.delta === null ? "–" : signed(p.delta)}; ${p.percent_change === null ? "change undefined" : `${signed(p.percent_change)}%`}; n ${p.n.before ?? "–"} → ${p.n.after ?? "–"}`;
13263
+ var previousLine = (p, verbose = false, peers = []) => ` ${windowQualified(p.place)}${verbose ? ` (${p.place}; ${p.ref})` : ""} ${storedRefWords(p.ref, peers)}${p.devices ? ` [devices ${p.devices.before.join(", ") || "–"} → ${p.devices.after.join(", ") || "–"}]` : ""}: ${p.status === "compared" ? "" : `${p.status === "gone" ? p.ref.startsWith("b:component:") ? JOURNEY_ROW_WORDS.removed : JOURNEY_ROW_WORDS.notInRun : p.status === "new" ? JOURNEY_ROW_WORDS.new : p.status}; `}${p.before ?? "–"} → ${p.after ?? "–"}; delta ${p.delta === null ? "–" : signed(p.delta)}; ${p.percent_change === null ? "change undefined" : `${signed(p.percent_change)}%`}; n ${p.n.before ?? "–"} → ${p.n.after ?? "–"}`;
12063
13264
  function formatMainVsPrevious(m, opts = {}) {
12064
13265
  const r = new Rows().both(`Information only: main session ${m.run_set_id} vs ${m.previous_run_set_id ?? "no previous session"}.`, `Measure: ${m.measure}; ${m.status}.`);
12065
13266
  if (m.cut === "unknown")
12066
13267
  r.both("History restart: unknown; current summaries unavailable.");
12067
13268
  else if (m.cut)
12068
- r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`);
13269
+ r.both(`History restart: ${m.cut.label}; ${m.cut.cuts.join(", ")}. Numbers cross this restart.`, ...renamedReleaseLines(m.cut.renamed));
12069
13270
  if (m.not_computed.length)
12070
13271
  r.both(`not_computed: ${m.not_computed.join(", ")}`);
12071
13272
  if (m.stale.length)
12072
13273
  r.both(`stale: ${m.stale.join(", ")}`);
12073
13274
  if (m.journey)
12074
- r.both("Whole journey:", previousLine(m.journey));
12075
- m.places.forEach((p, i) => r.add(i < SHORT_PLACES || p.status !== "compared", previousLine(p)));
13275
+ r.both("Whole journey:", previousLine(m.journey, opts.verbose));
13276
+ m.places.forEach((p, i) => r.add(i < SHORT_PLACES || p.status !== "compared", previousLine(p, opts.verbose, m.places.filter((row) => row.place === p.place).map((row) => row.ref))));
12076
13277
  if (m.next_offset !== null)
12077
13278
  r.both(`Showing ${m.offset + 1}–${m.offset + m.places.length} of ${m.total}; next offset ${m.next_offset}.`);
12078
13279
  const smaller = m.places.filter((p, i) => i >= SHORT_PLACES && p.status === "compared").length;
12079
- return r.render(opts.form, smaller ? [plural(smaller, "smaller mover")] : []);
13280
+ return r.render(opts.form, smaller ? [plural2(smaller, "smaller mover")] : []);
13281
+ }
13282
+ function renamedReleaseLines(renamed) {
13283
+ if (!renamed)
13284
+ return [];
13285
+ return [...renamed.places.length ? [` ${placeNamesChangedTitle(renamed.sdk)}`] : [], ...renamed.dimensions.includes("component") ? [` ${componentNotJoinedText(renamed.sdk)}`] : []];
13286
+ }
13287
+ function renamedLines(renamed, places) {
13288
+ if (!renamed)
13289
+ return [];
13290
+ const asked = places.filter((p) => p.legacy_window !== undefined);
13291
+ const windows = [...new Map(asked.map((p) => [p.window, p.legacy_window ?? null])).entries()];
13292
+ const component = renamed.dimensions.includes("component") && places.some((p) => p.ref.startsWith("b:component:"));
13293
+ return [
13294
+ ...windows.length ? [` ${placeNamesChangedTitle(renamed.sdk)}`, ...windows.map(([w, old]) => ` ${windowQualified(w)}: ${old ? placeWasText(old) : PLACE_NOT_JOINED_TEXT}`)] : [],
13295
+ ...component ? [` ${componentNotJoinedText(renamed.sdk)}`] : []
13296
+ ];
12080
13297
  }
12081
13298
  function formatPlaceTrend(t, opts = {}) {
12082
13299
  const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
@@ -12089,6 +13306,8 @@ function formatPlaceTrend(t, opts = {}) {
12089
13306
  r.both(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
12090
13307
  for (const restart of t.restarts.filter((x) => x.from_run_set_id === session.run_set_id)) {
12091
13308
  r.add(k, `History restarts: ${restart.label} (${restart.cuts.join(", ")})`);
13309
+ for (const line of renamedLines(restart.renamed, t.places))
13310
+ r.add(k, line);
12092
13311
  if (!k)
12093
13312
  olderRestarts++;
12094
13313
  }
@@ -12099,7 +13318,7 @@ function formatPlaceTrend(t, opts = {}) {
12099
13318
  const unit = place.unit ? ` ${place.unit}` : "";
12100
13319
  if (!k)
12101
13320
  olderSessions.add(session.run_set_id);
12102
- r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${place.window} · ${place.label} (${place.ref}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
13321
+ r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${value.window ?? place.window}; ${place.ref})` : ""} · ${place.label} (${storedRefWords(place.ref, t.places.filter((row) => row.window === place.window).map((row) => row.ref))}): median ${value.median}${unit}, MAD ${value.mad}${unit}, n=${value.n}`);
12103
13322
  }
12104
13323
  }
12105
13324
  if (!t.sessions.length)
@@ -12108,7 +13327,10 @@ function formatPlaceTrend(t, opts = {}) {
12108
13327
  r.both(`Not computed: ${t.not_computed.join(", ")}`);
12109
13328
  if (t.stale.length)
12110
13329
  r.both(`Stale: ${t.stale.join(", ")}`);
12111
- return r.render(opts.form, [...olderSessions.size ? [plural(olderSessions.size, "older session")] : [], ...olderRestarts ? [plural(olderRestarts, "older restart marker")] : []]);
13330
+ return r.render(opts.form, [
13331
+ ...olderSessions.size ? [plural2(olderSessions.size, "older session")] : [],
13332
+ ...olderRestarts ? [plural2(olderRestarts, "older restart marker")] : []
13333
+ ]);
12112
13334
  }
12113
13335
 
12114
13336
  // packages/mcp/dist/src/client.js
@@ -12133,7 +13355,13 @@ async function get(t, path, query = {}) {
12133
13355
  }
12134
13356
  const r = await (t.fetch ?? fetch)(u, { headers: { "x-keepsnappy-key": t.key, accept: "application/json" } });
12135
13357
  const text = await r.text();
12136
- const body = text ? JSON.parse(text) : {};
13358
+ let body;
13359
+ try {
13360
+ body = text ? JSON.parse(text) : {};
13361
+ } catch {
13362
+ const page = text.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
13363
+ throw new ApiError(r.status, "not_json", `the API answered ${r.status} with a body that is not JSON${page ? `: ${page}` : ""}`);
13364
+ }
12137
13365
  if (!r.ok) {
12138
13366
  const code = typeof body.error === "string" ? body.error : String(r.status);
12139
13367
  const message = typeof body.message === "string" ? body.message : code;
@@ -12144,8 +13372,9 @@ async function get(t, path, query = {}) {
12144
13372
  }
12145
13373
  var listFindings = (t, f = {}) => get(t, "v1/findings", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, state: f.state, limit: f.limit, offset: f.offset || undefined });
12146
13374
  var branchStatus = (t, branch) => get(t, "v1/branches/status", { branch });
13375
+ var listProblems = (t, f = {}) => get(t, "v1/problems", { branch: f.branch, main: f.main ? "true" : undefined, journey: f.journey, phone: f.phone, limit: f.limit, offset: f.offset || undefined });
12147
13376
  var getFinding = (t, id) => get(t, `v1/findings/${encodeURIComponent(id)}`);
12148
- var compareRuns = (t, a, b) => get(t, "v1/compare", { a, b });
13377
+ var compareRuns = (t, a, b, page = {}) => get(t, "v1/compare", { a, b, ...page });
12149
13378
  var windowHistory = (t, q) => get(t, "v1/history", q);
12150
13379
  var commitsBetween = (t, q) => get(t, "v1/commits", q);
12151
13380
  var layers = (t) => get(t, "v1/layers");
@@ -12159,10 +13388,14 @@ var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
12159
13388
  window: f.window,
12160
13389
  limit: f.limit,
12161
13390
  offset: f.offset || undefined,
12162
- top_components: f.top_components
13391
+ top_components: f.top_components,
13392
+ detail: f.detail,
13393
+ component_offset: f.component_offset,
13394
+ component_limit: f.component_limit
12163
13395
  });
12164
13396
  var SESSION_SORTS = ["median", "p90", "mad"];
12165
13397
  var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
13398
+ var mainChanges = (t, q) => get(t, "v1/main/changes", { ...q });
12166
13399
  var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
12167
13400
  var mainVsPrevious = (t, q) => {
12168
13401
  const { run_set, ...query } = q;
@@ -12173,6 +13406,8 @@ var placeTrend = (t, q) => get(t, "v1/places/trend", { ...q });
12173
13406
  // apps/agent/src/findings.ts
12174
13407
  var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KEY] [--phone MODEL] [--state present|not-judged|resolved|all] [--limit N] [--offset N] [--short] [--json]
12175
13408
  status --branch NAME [--short] [--json] (is my PR ok? one answer: blocked / nothing blocks / ok, per journey × phone the word, the ids of the worst findings, dashboard links; never measured and not judged are not ok)
13409
+ problems --branch NAME | --main [--journey KEY] [--phone MODEL] [--limit N] [--offset N] [--short] [--json]
13410
+ (the single problems "Over budget" / "Far over budget" in the branch's own runs, whatever main does: not a regression, never blocking)
12176
13411
  finding <id> [--prompt] [--short] [--json] (--prompt: only the hand-off for a coding agent; with --json the whole finding)
12177
13412
  compare <runA> <runB> [--json] (run ids or external ids; one run against one, no statistics)
12178
13413
  history <journey> <window> [--phone MODEL] [--short] [--json] (exact window id, e.g. "press:live.chat.send")
@@ -12180,23 +13415,18 @@ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KE
12180
13415
  places [--run-set ID | --journey KEY [--phone MODEL] [--branch NAME]] [--measure ID] [--sort median|p90|mad] [--window TEXT] [--limit N] [--offset N] [--top-components N] [--short] [--json]
12181
13416
  (every place of one session from its stored statistics: no verdict; the newest closed run set of the journey when --run-set is not given)
12182
13417
  main --journey KEY --phone MODEL [--sessions N | --session ID] [--vs-previous] [--measure REF] [--limit N] [--offset N] [--short] [--json]
13418
+ main --journey KEY --phone MODEL --changes [--limit N] [--json] (main's confirmed changes, newest first: commit range from the session before to the first session at the new level, and the PRs merged in it)
12183
13419
  layers [--json]
12184
13420
  trend <journey> <window words...> --phone MODEL [--window PLACE ...] [--ref REF ...] [--sessions N] [--short] [--json]
12185
13421
  (the words after the journey are one window name; more windows with repeated --window)
12186
- --short (findings, status, finding, history, places, main, trend: what decides, without the evidence detail; the same as the MCP tools' form short; --json is unchanged)
13422
+ ${READ_USAGE.components}
13423
+ ${READ_USAGE.verbose}
13424
+ ${READ_USAGE.detailFlag}
13425
+ --short (findings, status, problems, finding, history, places, main, trend: what decides, without the evidence detail; the same as the MCP tools' form short; --json is unchanged)
12187
13426
  (key: KEEPSNAPPY_KEY or --key-env NAME; API: --api URL or KEEPSNAPPY_API)`;
12188
-
12189
- class UsageError extends ConfigError {
12190
- constructor(lines) {
12191
- super(lines);
12192
- this.message = lines.join(`
12193
- `);
12194
- this.name = "UsageError";
12195
- }
12196
- }
12197
13427
  var STATES = ["present", "not-judged", "resolved", "all"];
12198
- var SHORT_COMMANDS = new Set(["findings", "status", "finding", "history", "places", "main", "trend"]);
12199
- var FINDINGS_COMMANDS = new Set(["findings", "status", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
13428
+ var SHORT_COMMANDS = new Set(["findings", "status", "problems", "finding", "history", "places", "main", "trend"]);
13429
+ var FINDINGS_COMMANDS = new Set(["findings", "status", "problems", "finding", "compare", "history", "commits", "places", "layers", "main", "trend"]);
12200
13430
  async function findingsCommand(cmd, argv, rt) {
12201
13431
  const args = (() => {
12202
13432
  try {
@@ -12204,6 +13434,10 @@ async function findingsCommand(cmd, argv, rt) {
12204
13434
  args: argv,
12205
13435
  allowPositionals: true,
12206
13436
  options: {
13437
+ verbose: { type: "boolean" },
13438
+ detail: { type: "boolean" },
13439
+ page: { type: "string" },
13440
+ all: { type: "boolean" },
12207
13441
  branch: { type: "string" },
12208
13442
  main: { type: "boolean" },
12209
13443
  journey: { type: "string" },
@@ -12211,6 +13445,7 @@ async function findingsCommand(cmd, argv, rt) {
12211
13445
  state: { type: "string" },
12212
13446
  session: { type: "string" },
12213
13447
  "vs-previous": { type: "boolean" },
13448
+ changes: { type: "boolean" },
12214
13449
  limit: { type: "string" },
12215
13450
  offset: { type: "string" },
12216
13451
  "run-set": { type: "string" },
@@ -12237,7 +13472,7 @@ async function findingsCommand(cmd, argv, rt) {
12237
13472
  if (!key)
12238
13473
  throw new ConfigError([`a project key with read scope is needed: set the environment variable ${keyEnv}`]);
12239
13474
  const t = { url: o.api ?? envValue(rt.env, "API") ?? DEFAULT_API_URL, key, ...rt.fetch ? { fetch: rt.fetch } : {} };
12240
- const answerForm = { form: o.short ? "short" : "full" };
13475
+ const answerForm = { form: o.short ? "short" : "full", verbose: o.verbose };
12241
13476
  const out = (v, text) => {
12242
13477
  rt.print(o.json ? JSON.stringify(v, null, 2) : text(v, answerForm));
12243
13478
  return 0;
@@ -12254,6 +13489,11 @@ async function findingsCommand(cmd, argv, rt) {
12254
13489
  const offset = o.offset === undefined ? undefined : Number(o.offset);
12255
13490
  if (offset !== undefined && (!Number.isInteger(offset) || offset < 0))
12256
13491
  throw new UsageError(["--offset must be a whole number >= 0"]);
13492
+ const page = o.page === undefined ? undefined : Number(o.page);
13493
+ if (page !== undefined && (!["places", "compare"].includes(cmd) || !Number.isInteger(page) || page < 1))
13494
+ throw new UsageError([READ_USAGE.page]);
13495
+ if (o.all && (!["places", "compare"].includes(cmd) || page !== undefined || o.offset !== undefined || o.short))
13496
+ throw new UsageError([READ_USAGE.all]);
12257
13497
  switch (cmd) {
12258
13498
  case "main": {
12259
13499
  if (!o.journey)
@@ -12266,6 +13506,11 @@ async function findingsCommand(cmd, argv, rt) {
12266
13506
  if (sessions !== undefined && (o.session || o["vs-previous"]))
12267
13507
  throw new UsageError(["--sessions is for the session list"]);
12268
13508
  const lane = { journey: o.journey, phone: o.phone };
13509
+ if (o.changes) {
13510
+ if (sessions !== undefined || o.session || o["vs-previous"] || o.measure || o.short || offset !== undefined)
13511
+ throw new UsageError(["--changes takes only --journey, --phone, --limit and --json"]);
13512
+ return out(await mainChanges(t, { ...lane, ...limit ? { limit } : {} }), formatMainChanges);
13513
+ }
12269
13514
  if (o["vs-previous"])
12270
13515
  return out(await mainVsPrevious(t, { ...lane, run_set: o.session, measure: o.measure, limit, offset }), formatMainVsPrevious);
12271
13516
  if (o.session)
@@ -12292,6 +13537,19 @@ async function findingsCommand(cmd, argv, rt) {
12292
13537
  if (!o.branch)
12293
13538
  throw new UsageError(["keepsnappy status needs --branch NAME", FINDINGS_USAGE]);
12294
13539
  return out(await branchStatus(t, o.branch), formatBranchStatus);
13540
+ case "problems":
13541
+ if (!o.branch && !o.main)
13542
+ throw new UsageError(["keepsnappy problems needs --branch NAME or --main", FINDINGS_USAGE]);
13543
+ if (o.branch && o.main)
13544
+ throw new UsageError(["give --branch or --main, not both"]);
13545
+ return out(await listProblems(t, {
13546
+ ...o.branch ? { branch: o.branch } : {},
13547
+ ...o.main ? { main: true } : {},
13548
+ ...o.journey ? { journey: o.journey } : {},
13549
+ ...o.phone ? { phone: o.phone } : {},
13550
+ ...limit ? { limit } : {},
13551
+ ...offset ? { offset } : {}
13552
+ }), formatProblemList);
12295
13553
  case "finding": {
12296
13554
  need(1, "<id>");
12297
13555
  const f = await getFinding(t, p[0]);
@@ -12301,9 +13559,15 @@ async function findingsCommand(cmd, argv, rt) {
12301
13559
  }
12302
13560
  return out(f, formatFinding);
12303
13561
  }
12304
- case "compare":
13562
+ case "compare": {
12305
13563
  need(2, "<runA> <runB>");
12306
- return out(await compareRuns(t, p[0], p[1]), formatCompare);
13564
+ const size = o["top-components"] === undefined ? 12 : Number(o["top-components"]);
13565
+ if (!Number.isInteger(size) || size < 1 || size > 200)
13566
+ throw new UsageError([READ_USAGE.compareTop]);
13567
+ const query = page || o.all || o["top-components"] !== undefined ? { component_offset: ((page ?? 1) - 1) * size, component_limit: size } : {};
13568
+ const compared = await compareRuns(t, p[0], p[1], query);
13569
+ return out(o.all ? await allCompare(t, compared, size) : compared, formatCompare);
13570
+ }
12307
13571
  case "history":
12308
13572
  need(2, "<journey> <window>");
12309
13573
  return out(await windowHistory(t, { journey: p[0], window: p.slice(1).join(" "), ...o.phone ? { phone: o.phone } : {}, ...limit ? { limit } : {} }), formatHistory);
@@ -12315,9 +13579,12 @@ async function findingsCommand(cmd, argv, rt) {
12315
13579
  if (sort && !SESSION_SORTS.includes(sort))
12316
13580
  throw new UsageError([`--sort is ${SESSION_SORTS.join(", ")}`]);
12317
13581
  const top = o["top-components"] === undefined ? undefined : Number(o["top-components"]);
12318
- if (top !== undefined && (!Number.isInteger(top) || top < 0 || top > 20))
12319
- throw new UsageError(["--top-components must be a whole number from 0 to 20"]);
12320
- return out(await sessionPlaces(t, {
13582
+ if (top !== undefined && (!Number.isInteger(top) || top < (page || o.all ? 1 : 0) || top > (page || o.all ? 200 : 20)))
13583
+ throw new UsageError([READ_USAGE.top]);
13584
+ if ((o.window?.length ?? 0) > 1)
13585
+ throw new UsageError(["keepsnappy places takes one --window (more windows: keepsnappy trend)", FINDINGS_USAGE]);
13586
+ const query = {
13587
+ ...o.verbose || o.detail ? { detail: true } : {},
12321
13588
  ...o["run-set"] ? { run_set: o["run-set"] } : {},
12322
13589
  ...o.journey ? { journey: o.journey } : {},
12323
13590
  ...o.phone ? { phone: o.phone } : {},
@@ -12327,8 +13594,9 @@ async function findingsCommand(cmd, argv, rt) {
12327
13594
  ...o.window?.length ? { window: o.window[0] } : {},
12328
13595
  ...limit ? { limit } : {},
12329
13596
  ...offset ? { offset } : {},
12330
- ...top !== undefined ? { top_components: top } : {}
12331
- }), formatSessionPlaces);
13597
+ ...page || o.all ? { component_offset: ((page ?? 1) - 1) * (top ?? 5), component_limit: top ?? 5 } : top !== undefined ? { top_components: top } : {}
13598
+ };
13599
+ return out(o.all ? await allSessionPlaces(t, query) : await sessionPlaces(t, query), formatSessionPlaces);
12332
13600
  }
12333
13601
  case "layers":
12334
13602
  return out(await layers(t), formatLayers);
@@ -12347,6 +13615,100 @@ async function findingsCommand(cmd, argv, rt) {
12347
13615
  throw new UsageError([FINDINGS_USAGE]);
12348
13616
  }
12349
13617
  }
13618
+ async function allSessionPlaces(target, query) {
13619
+ let first = await sessionPlaces(target, query);
13620
+ if (first.places.some((p) => !p.components_page))
13621
+ throw new UsageError([READ_USAGE.paging]);
13622
+ if (first.session.not_computed || first.session.stale)
13623
+ return first;
13624
+ const result = { ...first, places: [], offset: 0, next_offset: null };
13625
+ const pinned = { ...query, run_set: first.session.run_set_id, branch: undefined };
13626
+ const visited = new Set;
13627
+ for (;; ) {
13628
+ const places = first.places.map((p) => ({ ...p, components: [...p.components] }));
13629
+ let response = first;
13630
+ for (;; ) {
13631
+ const next = response.places.flatMap((p) => p.components_page?.next_offset ?? []);
13632
+ if (!next.length)
13633
+ break;
13634
+ const component_offset = Math.min(...next);
13635
+ const mark = `${first.offset}:${component_offset}`;
13636
+ if (visited.has(mark))
13637
+ throw new UsageError([READ_USAGE.paging]);
13638
+ visited.add(mark);
13639
+ response = await sessionPlaces(target, { ...pinned, offset: first.offset, component_offset });
13640
+ for (const p of places) {
13641
+ const more = response.places.find((x) => x.window === p.window);
13642
+ if (!more?.components_page)
13643
+ throw new UsageError([READ_USAGE.paging]);
13644
+ const keys = new Set(p.components.map((c) => c.key));
13645
+ p.components.push(...more.components.filter((c) => !keys.has(c.key)));
13646
+ p.components_page = more.components_page;
13647
+ }
13648
+ }
13649
+ result.places.push(...places.map((p) => ({ ...p, ...p.components_page ? { components_page: { ...p.components_page, offset: 0, limit: p.components.length, next_offset: null } } : {} })));
13650
+ if (places.some((p) => p.components_page && p.components.length !== p.components_page.total))
13651
+ throw new UsageError([READ_USAGE.paging]);
13652
+ if (first.next_offset === null) {
13653
+ if (result.places.length !== result.total)
13654
+ throw new UsageError([READ_USAGE.paging]);
13655
+ return result;
13656
+ }
13657
+ const mark = `places:${first.next_offset}`;
13658
+ if (visited.has(mark))
13659
+ throw new UsageError([READ_USAGE.paging]);
13660
+ visited.add(mark);
13661
+ first = await sessionPlaces(target, { ...pinned, offset: first.next_offset, component_offset: 0 });
13662
+ }
13663
+ }
13664
+ async function allCompare(target, result, size) {
13665
+ let response = result;
13666
+ const visited = new Set;
13667
+ for (;; ) {
13668
+ if (response.windows.some((w) => w.components && !w.component_page))
13669
+ throw new UsageError([READ_USAGE.paging]);
13670
+ const offsets = response.windows.flatMap((w) => w.component_page?.page.next_offset ?? []);
13671
+ if (!offsets.length)
13672
+ break;
13673
+ const offset = Math.min(...offsets);
13674
+ if (visited.has(offset))
13675
+ throw new UsageError([READ_USAGE.paging]);
13676
+ visited.add(offset);
13677
+ response = await compareRuns(target, result.a.id, result.b.id, { component_offset: offset, component_limit: size });
13678
+ for (const w of result.windows) {
13679
+ const c = w.component_page;
13680
+ if (!c && !w.components)
13681
+ continue;
13682
+ const more = response.windows.find((x) => x.window === w.window)?.component_page;
13683
+ if (!c || !more)
13684
+ throw new UsageError([READ_USAGE.paging]);
13685
+ const keys = new Set(c.rows.map((row) => row.key));
13686
+ c.rows.push(...more.rows.filter((row) => !keys.has(row.key)));
13687
+ for (const field of ["added", "removed"]) {
13688
+ const existing = c[field] ?? [];
13689
+ const known = new Set(existing.map((row) => row.key));
13690
+ c[field] = [...existing, ...(more[field] ?? []).filter((row) => !known.has(row.key))];
13691
+ }
13692
+ for (const side of ["a", "b"]) {
13693
+ if (!c.uncompared || !more.uncompared)
13694
+ continue;
13695
+ const known = new Set(c.uncompared[side].map((row) => row.key));
13696
+ c.uncompared[side].push(...more.uncompared[side].filter((row) => !known.has(row.key)));
13697
+ }
13698
+ c.page = more.page;
13699
+ }
13700
+ }
13701
+ for (const w of result.windows) {
13702
+ const c = w.component_page;
13703
+ if (!c)
13704
+ continue;
13705
+ const count = c.rows.length + (c.added?.length ?? 0) + (c.removed?.length ?? 0) + (c.uncompared?.a.length ?? 0) + (c.uncompared?.b.length ?? 0);
13706
+ if (count !== c.page.total)
13707
+ throw new UsageError([READ_USAGE.paging]);
13708
+ c.page = { ...c.page, offset: 0, limit: count, next_offset: null };
13709
+ }
13710
+ return result;
13711
+ }
12350
13712
 
12351
13713
  // apps/agent/src/cli.ts
12352
13714
  var USAGE = `keepsnappy <command>
@@ -12354,6 +13716,8 @@ var USAGE = `keepsnappy <command>
12354
13716
  [--upload [--wait] [--fail-on-worse] [--ingest-url URL] [--commit SHA] [--branch NAME] [--base-commit SHA]]
12355
13717
  [--device-model NAME --device-os VERSION] [--scenario NAME] [--replay <out dir>[:ARM] ...]
12356
13718
  [--no-markers] [-- <e2e command...>]
13719
+ (--fail-on-worse: exit 3 only when the verdict computed on that run set is Worse and blocking; closing never computes one,
13720
+ so with no computed verdict it checks nothing: "${UNCHECKED_GATE}", exit 0)
12357
13721
  measure --repeat N [--arms A=<app|bundle>,B=<app|bundle>] [--arm-env 'A:KEY=VAL' ...] [--bundle-id ID] [--platform ios-sim|ios-device]
12358
13722
  [--device UDID] [--out DIR] [--timeout SEC] [--ri-capture observations.ndjson] [--mem-window NAME]
12359
13723
  [--clock-offset-ms N] [--no-markers]
@@ -12366,6 +13730,7 @@ var USAGE = `keepsnappy <command>
12366
13730
  [--driver appium --journey-module FILE] [--driver-version V] [--app-build-id ID] [--harness-version V] [--journey-version V] [--flag-set LABEL] (setup fingerprint, plan 51)
12367
13731
  [--sdk-args k=v&...] (over the run defaults profile=1 diag=1; tokens.js carries them to the flows' activation link)
12368
13732
  farm close --state <file> [--wait [--fail-on-worse]] [--bs-build browserstack.ios.json] [--settle-seconds 60] [--max-wait-seconds 300] [--out DIR]
13733
+ (--fail-on-worse as for run: with no verdict computed on the run set it checks nothing)
12369
13734
  farm run --app <eas:ID|file.ipa|file.apk|bs://ID|custom_id:ID> --journeys a,b [--repeat 5] --device "iPhone 15 Pro-17" [--platform ios|android] [--driver maestro|appium]
12370
13735
  [--tunnel-for localhost:PORT ...] [--env KEY=VALUE ...] (values may use {tunnel:PORT} {run_id} {out} {tokens_js} {run_set_id})
12371
13736
  maestro: --suite-dir DIR --execute FLOW [--tokens-path flows/keepsnappy/tokens.js] [--maestro-version V]
@@ -12382,7 +13747,7 @@ async function bundleIdOf(deps, target, explicit) {
12382
13747
  if (explicit)
12383
13748
  return explicit;
12384
13749
  if (!target)
12385
- throw new Error("give --bundle-id (or an arm whose target is a bundle id or .app)");
13750
+ throw new ConfigError(["give --bundle-id (or an arm whose target is a bundle id or .app)"]);
12386
13751
  if (!isBuildPath(target))
12387
13752
  return target;
12388
13753
  const r = await deps.spawner.run("plutil", ["-extract", "CFBundleIdentifier", "raw", `${target.replace(/\/$/, "")}/Info.plist`]);
@@ -12408,74 +13773,80 @@ async function main2(argv) {
12408
13773
  if (cmd && LINK_COMMANDS.has(cmd))
12409
13774
  return linksCommand(cmd, rest, { print: console.log });
12410
13775
  const deps = nodeDeps();
12411
- const { values: o, positionals } = parseArgs4({
12412
- args: rest,
12413
- allowPositionals: true,
12414
- options: {
12415
- repeat: { type: "string" },
12416
- arms: { type: "string" },
12417
- "arm-env": { type: "string", multiple: true },
12418
- platform: { type: "string" },
12419
- device: { type: "string" },
12420
- out: { type: "string" },
12421
- "bundle-id": { type: "string" },
12422
- timeout: { type: "string" },
12423
- reps: { type: "string" },
12424
- boot: { type: "string" },
12425
- "ri-capture": { type: "string" },
12426
- "mem-window": { type: "string" },
12427
- "clock-offset-ms": { type: "string" },
12428
- "no-markers": { type: "boolean" },
12429
- config: { type: "string" },
12430
- upload: { type: "boolean" },
12431
- "ingest-url": { type: "string" },
12432
- commit: { type: "string" },
12433
- branch: { type: "string" },
12434
- "base-commit": { type: "string" },
12435
- wait: { type: "boolean" },
12436
- "fail-on-worse": { type: "boolean" },
12437
- scenario: { type: "string" },
12438
- "device-model": { type: "string" },
12439
- "device-os": { type: "string" },
12440
- "device-id": { type: "string" },
12441
- replay: { type: "string", multiple: true },
12442
- api: { type: "string" },
12443
- "key-env": { type: "string" },
12444
- "device-provider": { type: "string" },
12445
- journeys: { type: "string" },
12446
- "tokens-js": { type: "string" },
12447
- state: { type: "string" },
12448
- "ttl-seconds": { type: "string" },
12449
- "bs-build": { type: "string" },
12450
- "settle-seconds": { type: "string" },
12451
- "max-wait-seconds": { type: "string" },
12452
- flows: { type: "string" },
12453
- "completion-marker": { type: "boolean" },
12454
- "driver-version": { type: "string" },
12455
- "app-build-id": { type: "string" },
12456
- "harness-version": { type: "string" },
12457
- "journey-version": { type: "string" },
12458
- "flag-set": { type: "string" },
12459
- app: { type: "string" },
12460
- driver: { type: "string" },
12461
- "tunnel-for": { type: "string", multiple: true },
12462
- env: { type: "string", multiple: true },
12463
- "driver-script": { type: "string" },
12464
- "journey-module": { type: "string" },
12465
- "driver-arg": { type: "string", multiple: true },
12466
- "suite-dir": { type: "string" },
12467
- execute: { type: "string" },
12468
- "tokens-path": { type: "string" },
12469
- "maestro-version": { type: "string" },
12470
- "timeout-minutes": { type: "string" },
12471
- "kill-after-seconds": { type: "string" },
12472
- "poll-seconds": { type: "string" },
12473
- "custom-id": { type: "string" },
12474
- "run-id": { type: "string" },
12475
- "dry-run": { type: "boolean" },
12476
- "sdk-args": { type: "string" }
13776
+ const { values: o, positionals } = (() => {
13777
+ try {
13778
+ return parseArgs4({
13779
+ args: rest,
13780
+ allowPositionals: true,
13781
+ options: {
13782
+ repeat: { type: "string" },
13783
+ arms: { type: "string" },
13784
+ "arm-env": { type: "string", multiple: true },
13785
+ platform: { type: "string" },
13786
+ device: { type: "string" },
13787
+ out: { type: "string" },
13788
+ "bundle-id": { type: "string" },
13789
+ timeout: { type: "string" },
13790
+ reps: { type: "string" },
13791
+ boot: { type: "string" },
13792
+ "ri-capture": { type: "string" },
13793
+ "mem-window": { type: "string" },
13794
+ "clock-offset-ms": { type: "string" },
13795
+ "no-markers": { type: "boolean" },
13796
+ config: { type: "string" },
13797
+ upload: { type: "boolean" },
13798
+ "ingest-url": { type: "string" },
13799
+ commit: { type: "string" },
13800
+ branch: { type: "string" },
13801
+ "base-commit": { type: "string" },
13802
+ wait: { type: "boolean" },
13803
+ "fail-on-worse": { type: "boolean" },
13804
+ scenario: { type: "string" },
13805
+ "device-model": { type: "string" },
13806
+ "device-os": { type: "string" },
13807
+ "device-id": { type: "string" },
13808
+ replay: { type: "string", multiple: true },
13809
+ api: { type: "string" },
13810
+ "key-env": { type: "string" },
13811
+ "device-provider": { type: "string" },
13812
+ journeys: { type: "string" },
13813
+ "tokens-js": { type: "string" },
13814
+ state: { type: "string" },
13815
+ "ttl-seconds": { type: "string" },
13816
+ "bs-build": { type: "string" },
13817
+ "settle-seconds": { type: "string" },
13818
+ "max-wait-seconds": { type: "string" },
13819
+ flows: { type: "string" },
13820
+ "completion-marker": { type: "boolean" },
13821
+ "driver-version": { type: "string" },
13822
+ "app-build-id": { type: "string" },
13823
+ "harness-version": { type: "string" },
13824
+ "journey-version": { type: "string" },
13825
+ "flag-set": { type: "string" },
13826
+ app: { type: "string" },
13827
+ driver: { type: "string" },
13828
+ "tunnel-for": { type: "string", multiple: true },
13829
+ env: { type: "string", multiple: true },
13830
+ "driver-script": { type: "string" },
13831
+ "journey-module": { type: "string" },
13832
+ "driver-arg": { type: "string", multiple: true },
13833
+ "suite-dir": { type: "string" },
13834
+ execute: { type: "string" },
13835
+ "tokens-path": { type: "string" },
13836
+ "maestro-version": { type: "string" },
13837
+ "timeout-minutes": { type: "string" },
13838
+ "kill-after-seconds": { type: "string" },
13839
+ "poll-seconds": { type: "string" },
13840
+ "custom-id": { type: "string" },
13841
+ "run-id": { type: "string" },
13842
+ "dry-run": { type: "boolean" },
13843
+ "sdk-args": { type: "string" }
13844
+ }
13845
+ });
13846
+ } catch (e) {
13847
+ throw new UsageError([e.message, "keepsnappy --help lists the commands and their flags"]);
12477
13848
  }
12478
- });
13849
+ })();
12479
13850
  if (cmd === "doctor") {
12480
13851
  console.log(formatChecks(await doctor(deps.spawner, process.version)));
12481
13852
  return 0;
@@ -12495,12 +13866,12 @@ async function main2(argv) {
12495
13866
  if (cmd === "measure") {
12496
13867
  const repeat = Number(o.repeat);
12497
13868
  if (!Number.isInteger(repeat) || repeat < 1)
12498
- throw new Error("--repeat N (>= 1) is required");
13869
+ throw new ConfigError(["--repeat N (>= 1) is required"]);
12499
13870
  const platform = o.platform ?? "ios-sim";
12500
13871
  if (platform !== "ios-sim" && platform !== "ios-device")
12501
- throw new Error(`unknown --platform ${platform}`);
13872
+ throw new ConfigError([`unknown --platform ${platform}`]);
12502
13873
  if (platform === "ios-device" && !o.device)
12503
- throw new Error("--device <udid> is required for ios-device");
13874
+ throw new ConfigError(["--device <udid> is required for ios-device"]);
12504
13875
  const arms = parseArmEnv(o["arm-env"], parseArms(o.arms));
12505
13876
  const out = resolve8(o.out ?? `keepsnappy-${new Date().toISOString().replace(/[:.]/g, "-")}`);
12506
13877
  const udid = o.device ?? "booted";
@@ -12534,7 +13905,7 @@ async function main2(argv) {
12534
13905
  if (cmd === "extract") {
12535
13906
  const dir = positionals[0];
12536
13907
  if (!dir)
12537
- throw new Error("extract needs <out-dir>");
13908
+ throw new ConfigError(["extract needs <out-dir>"]);
12538
13909
  const ri = o["ri-capture"] ? new FileRiSource(deps, resolve8(o["ri-capture"])) : undefined;
12539
13910
  const rows = await extract(deps, resolve8(dir), ri, o["mem-window"], o["clock-offset-ms"] ? Number(o["clock-offset-ms"]) : undefined);
12540
13911
  console.log(`${rows.length} rows -> ${resolve8(dir, "metrics.csv")}`);
@@ -12543,7 +13914,7 @@ async function main2(argv) {
12543
13914
  if (cmd === "aa") {
12544
13915
  const dir = positionals[0];
12545
13916
  if (!dir)
12546
- throw new Error("aa needs <out-dir>");
13917
+ throw new ConfigError(["aa needs <out-dir>"]);
12547
13918
  const config = { ...o.reps ? { reps: Number(o.reps) } : {}, ...o.boot ? { nBoot: Number(o.boot) } : {} };
12548
13919
  console.log((await aa(deps, resolve8(dir), { config })).table);
12549
13920
  return 0;