@keepsnappy/agent 0.1.0-alpha.17 → 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.
@@ -2198,6 +2198,7 @@ function isComponentConfiguration(value) {
2198
2198
  }
2199
2199
 
2200
2200
  // packages/facts/dist/src/places.js
2201
+ var COMMIT_IDENTITY_KINDS = ["screen", "press", "drag", "other"];
2201
2202
  var placeWithoutVisit = (id) => placeRef(id).stem.replace(/#(\d+|repeat)$/, "");
2202
2203
  var AFTER_3S = "+3s";
2203
2204
  var kindOf = (stem) => {
@@ -2323,7 +2324,7 @@ function withLaunchNames(windows) {
2323
2324
  }
2324
2325
 
2325
2326
  // packages/derive/dist/src/model.js
2326
- var DERIVATION_VERSION = 27;
2327
+ var DERIVATION_VERSION = 28;
2327
2328
  // packages/stats/dist/src/core/rng.js
2328
2329
  function splitmix32(seed) {
2329
2330
  let s = seed >>> 0;
@@ -3180,6 +3181,44 @@ function estimateClockOffset(markers, windows, byOrder = false) {
3180
3181
  const negatives = gaps.filter((g) => g < 0);
3181
3182
  return negatives.length * 2 > starts.length ? Math.min(...negatives) : 0;
3182
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
+ }
3183
3222
  function spans(markers, offset) {
3184
3223
  const sorted = [...markers].sort((a, b) => a.t_host - b.t_host);
3185
3224
  const out = [];
@@ -3206,7 +3245,8 @@ function ownWindowEnd(windows, t) {
3206
3245
  }
3207
3246
  function alignSteps(markers, windows, opts = {}) {
3208
3247
  const byOrder = opts.byOrder === true;
3209
- 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;
3210
3250
  const ss = spans(markers, offset).sort((a, b) => a.markerStart - b.markerStart);
3211
3251
  const inputs = windows.map(inputWall).filter((x) => x !== undefined).sort((a, b) => a - b);
3212
3252
  const starts = ss.map((s, i) => {
@@ -3256,7 +3296,7 @@ function clusters(windows) {
3256
3296
  }
3257
3297
  return out;
3258
3298
  }
3259
- function onAppClock(windows, version) {
3299
+ function processSegments(windows, version) {
3260
3300
  const order = windows.map((w, i) => ({ w, i })).sort((a, b) => a.w.endWall - b.w.endWall || a.i - b.i);
3261
3301
  const segment = new Map;
3262
3302
  let seg = 0;
@@ -3274,24 +3314,35 @@ function onAppClock(windows, version) {
3274
3314
  prevId = id;
3275
3315
  segment.set(w, seg);
3276
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;
3277
3322
  const offset = new Map;
3278
- for (const [w, s] of segment) {
3323
+ for (const [w, seg] of process2) {
3279
3324
  const t1 = num(w.raw.t1);
3280
3325
  if (t1 === undefined)
3281
3326
  continue;
3282
- const o = w.endWall - t1;
3283
- if (!offset.has(s) || o < offset.get(s))
3284
- 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);
3285
3333
  }
3286
3334
  return windows.map((w) => {
3287
- const s = segment.get(w);
3288
- const o = s === undefined ? undefined : offset.get(s);
3289
- const t0 = num(w.raw.t0);
3335
+ const seg = process2.get(w);
3290
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);
3291
3340
  if (o === undefined || t0 === undefined || t1 === undefined)
3292
3341
  return w;
3342
+ const st = step(w);
3343
+ const [atStart, atEnd] = [st?.start ?? 0, st?.end ?? 0];
3293
3344
  const touch = version >= 23 ? num(obj2(w.raw.interaction).touchStartT) : undefined;
3294
- 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 } };
3295
3346
  });
3296
3347
  }
3297
3348
 
@@ -3683,7 +3734,7 @@ function profileMetrics(windows, steps, options = {}) {
3683
3734
  const kind = placeRef(key).kind;
3684
3735
  const active = occ.filter((o) => !o.idle);
3685
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);
3686
- 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;
3687
3738
  const openedBy = selected?.window ? options.openedBy?.get(selected.window) : undefined;
3688
3739
  profiles.push({ ...profile, ...identity ? { slowestCommit: identity } : {}, ...openedBy ? { openedBy } : {} });
3689
3740
  }
@@ -3829,7 +3880,7 @@ function withActionWindows(windows, steps = [], version) {
3829
3880
  }
3830
3881
  const durMs = w.raw.endReason === "timeout" ? undefined : num3(w.raw.durMs);
3831
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 };
3832
- 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 } };
3833
3884
  });
3834
3885
  }
3835
3886
  function actionMetrics(windows, steps = [], version) {
@@ -4942,7 +4993,7 @@ function keyFirstOpens(windows) {
4942
4993
  }
4943
4994
 
4944
4995
  // packages/derive/dist/src/derive.js
4945
- 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];
4946
4997
  var windowsForVersion = (windows, version = DERIVATION_VERSION) => version >= 22 ? keyFirstOpens(windows) : windows;
4947
4998
  function stepWindowsFor(windows, version = DERIVATION_VERSION) {
4948
4999
  return windowsForVersion(version >= 16 ? withLaunchNames(windows).filter((w) => !isBackgroundWindow(w)) : windows, version);
@@ -4978,7 +5029,7 @@ function profileOptions(windows, version, processWindows = windows) {
4978
5029
  return { fullRowMs: FULL_ROW_MS, commitIdentity: processes.size <= 1, openedBy };
4979
5030
  }
4980
5031
  function deriveRun(input) {
4981
- const { run, markers = [], ri, clockOffsetMs, samples = [], memWindow } = input;
5032
+ const { run, markers = [], ri, clockOffsetMs, clockErrorMs, samples = [], memWindow } = input;
4982
5033
  const version = input.derivationVersion ?? DERIVATION_VERSION;
4983
5034
  if (!DERIVATION_VERSIONS.includes(version))
4984
5035
  throw new Error(`unknown derivation version ${String(version)}`);
@@ -4987,7 +5038,7 @@ function deriveRun(input) {
4987
5038
  const cpu = input.cpu && renamed.size ? new Map([...input.cpu].map(([k, v]) => [renamed.get(k) ?? k, v])) : input.cpu;
4988
5039
  const background = version >= 16 ? named.filter(isBackgroundWindow) : [];
4989
5040
  const sdkWindows = stepWindowsFor(input.windows, version);
4990
- const off = clockOffsetMs === undefined ? {} : { clockOffsetMs };
5041
+ const off = { ...clockOffsetMs === undefined ? {} : { clockOffsetMs }, ...version >= 28 && clockErrorMs !== undefined ? { clockErrorMs } : {} };
4991
5042
  const preSteps = version >= 11 && markers.length ? alignRunSteps(markers, sdkWindows, off, version) : [];
4992
5043
  const windows = version >= 11 ? withActionWindows(sdkWindows, preSteps, version) : sdkWindows;
4993
5044
  const steps = version >= 11 ? preSteps : alignRunSteps(markers, windows, clockOffsetMs === undefined ? {} : { clockOffsetMs }, version);
@@ -5150,100 +5201,6 @@ var markerEnv = (url, runId) => ({
5150
5201
  MAESTRO_KEEPSNAPPY_RUN_ID: runId
5151
5202
  });
5152
5203
 
5153
- // apps/agent/src/upload.ts
5154
- import { join as join4 } from "node:path";
5155
- var MAX_RATE_RETRIES = 5;
5156
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5157
- class UploadError extends Error {
5158
- }
5159
- async function call(f, base, path, headers, body, method = "POST") {
5160
- let res;
5161
- for (let attempt = 0;; attempt++) {
5162
- try {
5163
- res = await f(`${base.replace(/\/+$/, "")}${path}`, {
5164
- method,
5165
- headers: method === "POST" ? { "content-type": "application/json", ...headers } : headers,
5166
- ...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}
5167
- });
5168
- } catch (e) {
5169
- throw new UploadError(`${method} ${path}: ${e.message}`);
5170
- }
5171
- if (res.status !== 429 || attempt >= MAX_RATE_RETRIES)
5172
- break;
5173
- const wait = Number(res.headers.get("retry-after") ?? "1");
5174
- await res.text().catch(() => "");
5175
- await sleep(Math.min(Math.max(Number.isFinite(wait) ? wait : 1, 1), 60) * 1000);
5176
- }
5177
- const text = await res.text();
5178
- if (res.status < 200 || res.status >= 300)
5179
- throw new UploadError(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
5180
- try {
5181
- return text ? JSON.parse(text) : {};
5182
- } catch {
5183
- throw new UploadError(`${method} ${path} -> ${res.status}, the answer is not JSON (is the URL the KeepSnappy API?): ${text.slice(0, 200)}`);
5184
- }
5185
- }
5186
- async function uploadRuns(deps, outDir, runs, opts) {
5187
- const f = opts.fetch ?? fetch;
5188
- const size = opts.batchSize ?? 200;
5189
- const skipped = [];
5190
- let uploaded = 0;
5191
- for (const r of runs) {
5192
- if (!r.valid) {
5193
- skipped.push({ run_id: r.run_id, why: r.invalid_reason || "invalid" });
5194
- continue;
5195
- }
5196
- const log = await deps.fs.readFile(join4(outDir, r.run_id, "keepsnappy.log"));
5197
- const windows = windowsInRun(parseWindows(log), r.t0, r.t1);
5198
- if (windows.length === 0) {
5199
- skipped.push({ run_id: r.run_id, why: "no-windows" });
5200
- continue;
5201
- }
5202
- const externalId = `${opts.externalPrefix}-${r.run_id}`;
5203
- const created = await call(f, opts.ingestUrl, "/v1/runs", { "x-keepsnappy-key": opts.key }, {
5204
- external_id: externalId,
5205
- started_at: r.t0,
5206
- ended_at: r.t1,
5207
- device: { ...opts.device?.model ? { model: opts.device.model } : {}, ...opts.device?.os ? { os: opts.device.os } : {}, ...opts.device?.id ? { id: opts.device.id } : {}, exclusive: true },
5208
- meta: {
5209
- arm: r.arm,
5210
- driver: opts.driver ?? "keepsnappy-run",
5211
- ...opts.meta,
5212
- ...r.scenario ? { scenario: r.scenario } : {},
5213
- ...opts.runSetId ? { run_set_id: opts.runSetId } : {}
5214
- }
5215
- });
5216
- const runId = String(created.run_id ?? "");
5217
- const token = String(created.run_token ?? "");
5218
- if (!runId || !token)
5219
- throw new UploadError("POST /v1/runs: response has no run_id/run_token");
5220
- const items = windows.map((w) => `${w.endWall} KEEPSNAPPY window ${JSON.stringify(w.raw)}`);
5221
- for (let i = 0;i * size < items.length; i++) {
5222
- await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
5223
- batch_id: `${externalId}-${String(i + 1).padStart(4, "0")}`,
5224
- items: items.slice(i * size, (i + 1) * size)
5225
- });
5226
- }
5227
- const samples = samplesInRun(parseSamples(log), r.t0, r.t1).map((x) => `${x.wall} KEEPSNAPPY sample ${JSON.stringify(x.raw)}`);
5228
- for (let i = 0;i * size < samples.length; i++) {
5229
- await call(f, opts.ingestUrl, "/v1/windows", { "x-keepsnappy-key": opts.key, "x-keepsnappy-run-token": token }, {
5230
- batch_id: `${externalId}-s${String(i + 1).padStart(4, "0")}`,
5231
- items: samples.slice(i * size, (i + 1) * size)
5232
- });
5233
- }
5234
- let markers = [];
5235
- try {
5236
- markers = parseMarkersNdjson(await deps.fs.readFile(join4(outDir, r.run_id, "markers.ndjson")));
5237
- } catch {}
5238
- for (const m of markers) {
5239
- await call(f, opts.ingestUrl, "/v1/markers", { authorization: `Bearer ${token}` }, { step: m.step, phase: m.phase, t_host: m.t_host });
5240
- }
5241
- await call(f, opts.ingestUrl, `/v1/runs/${runId}/finish`, { "x-keepsnappy-key": opts.key }, {});
5242
- uploaded++;
5243
- }
5244
- return { uploaded, skipped };
5245
- }
5246
-
5247
5204
  // packages/present/dist/src/capital.js
5248
5205
  var upperFirst = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5249
5206
 
@@ -5285,7 +5242,12 @@ var STATE_WORDS = {
5285
5242
  var INFO_WORDS = { heat: "Phone heated up" };
5286
5243
  var isWorseState = (state) => state === "worse";
5287
5244
  var NOT_BLOCKING = "not blocking";
5288
- var badgedWord = (word, blocking) => word === STATE_WORDS.worse && blocking === false ? `${word} (${NOT_BLOCKING})` : word;
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";
5289
5251
 
5290
5252
  // packages/present/dist/src/values.js
5291
5253
  var NO_VALUE = "–";
@@ -5314,17 +5276,23 @@ function roundTo(v, decimals) {
5314
5276
  return Number(magnitude.toFixed(decimals)) * Math.sign(v);
5315
5277
  return Number(`${Math.round(scaled)}e-${decimals}`) * Math.sign(v);
5316
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
+ }
5317
5284
  function format(unit, decimals, v) {
5318
5285
  if (v === null || v === undefined || !Number.isFinite(v))
5319
5286
  return NO_VALUE;
5287
+ const { n, seconds } = written(unit, decimals, v);
5288
+ if (seconds)
5289
+ return `${String(n)} s`;
5320
5290
  const shown = shownUnit(unit);
5321
- if (unit === "ms" && Math.abs(v) >= LONG_MS && Math.abs(v) < 1e9)
5322
- return `${String(roundTo(v / 1000, 1) + 0)} s`;
5323
- const text = String(roundTo(v * shown.scale, decimals) + 0);
5324
- return shown.unit ? `${text} ${shown.unit}` : text;
5291
+ return shown.unit ? `${String(n)} ${shown.unit}` : String(n);
5325
5292
  }
5293
+ var defOf = (measure) => isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
5326
5294
  function valueText(measure, v) {
5327
- const def = isMeasureId(measure) ? MEASURES[measure] : BREAKDOWN_MEASURES[measure];
5295
+ const def = defOf(measure);
5328
5296
  return format(def.unit, def.decimals, v);
5329
5297
  }
5330
5298
  var KEY_SEPARATOR = ":";
@@ -5370,6 +5338,14 @@ function notInRunRowsText(c, page) {
5370
5338
  const them = c.screens + c.places === 1 ? "it" : "them";
5371
5339
  return { lead: `${what} ${GONE_TAILS[page].notInRun}:`, rest: ` the ${page === "journey" ? "newest session" : "journey"} did not reach ${them}; not counted.` };
5372
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`;
5373
5349
  var OUTSIDE_APP_NAME = "Outside app components";
5374
5350
  var OTHER_RENDER_WORK_NAME = "Other render work";
5375
5351
  var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
@@ -5544,8 +5520,34 @@ var UNSTABLE_WORDS = {
5544
5520
  line: "Main varies too much here to tell",
5545
5521
  ratioTip: "1.0 = main’s run-to-run noise alone would pass the warning step about once in 100 comparisons.",
5546
5522
  note: "This place varies a lot between runs. Worth checking why: it may be in the code.",
5547
- 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."
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
+ }
5548
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) : "";
5549
5551
 
5550
5552
  // packages/present/dist/src/present.js
5551
5553
  var ASPECT_WORDS = {
@@ -5684,6 +5686,10 @@ var REASON_WORDS = {
5684
5686
  "not-enough-main": "main does not have enough sessions on this phone and journey yet"
5685
5687
  };
5686
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;
5687
5693
  // packages/present/dist/src/main-history.js
5688
5694
  var MAIN_TIER_WORDS = {
5689
5695
  same: "In norm",
@@ -5761,6 +5767,409 @@ function mainCutWords(v) {
5761
5767
  return parts.length ? parts.join(" ") : "Measures alike; no baseline cut.";
5762
5768
  }
5763
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 };
6171
+ }
6172
+
5764
6173
  // apps/agent/src/summary.ts
5765
6174
  var fieldWord = (field) => measureDef(field)?.short ?? field;
5766
6175
  function plainMetric(metric) {
@@ -5799,7 +6208,6 @@ function summarize(input) {
5799
6208
 
5800
6209
  // apps/agent/src/remote.ts
5801
6210
  var isBlocking = (s) => !!s?.verdicts && (s.verdicts.blocking ?? s.verdicts.state === "worse");
5802
- var midLine = (label, metric) => !metric.startsWith("component:") && /^[A-Z][a-z]*(\s|$)/.test(label) ? `${label[0].toLowerCase()}${label.slice(1)}` : label;
5803
6211
  function heatNote(sc) {
5804
6212
  const n = sc.heated_runs ?? 0;
5805
6213
  if (!n)
@@ -5807,30 +6215,6 @@ function heatNote(sc) {
5807
6215
  const phone = sc.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
5808
6216
  return `${INFO_WORDS.heat} in ${n} of ${sc.runs.valid + sc.runs.invalid} runs · ${sc.scenario ?? "journey"} · ${phone}`;
5809
6217
  }
5810
- function movedItems(v, sc) {
5811
- const flat = v.items.filter((i) => i.scenario === sc.scenario && (!sc.device || !i.device_id || i.device_id === sc.device.id));
5812
- const h = sc.headline;
5813
- if (h?.errors === undefined)
5814
- return flat.filter((i) => i.state === "worse" || i.state === "better");
5815
- const graded = [...h.errors, ...h.warnings ?? []].map((g) => {
5816
- const same = flat.find((i) => i.step === g.window && i.metric === g.metric);
5817
- return {
5818
- ...same ?? { kind: "change", low_data: false, low_data_reason: null },
5819
- scenario: sc.scenario,
5820
- step: g.window,
5821
- metric: g.metric,
5822
- state: "worse",
5823
- grade: g.grade,
5824
- baseline: g.baseline ?? same?.baseline ?? null,
5825
- candidate: g.candidate ?? same?.candidate ?? null,
5826
- change_text: g.change,
5827
- ...g.finding ? { finding: g.finding } : {},
5828
- ...g.label ? { label_text: midLine(g.label, g.metric) } : {},
5829
- ...sc.device ? { device_id: sc.device.id } : {}
5830
- };
5831
- });
5832
- return [...graded, ...flat.filter((i) => i.state === "better" && (i.judged ?? "outcome") === "outcome")];
5833
- }
5834
6218
  var placeOfRow = (g) => g.finding ?? placeRef(g.window).stem;
5835
6219
  function movedCounts(sc) {
5836
6220
  const h = sc.headline;
@@ -5840,7 +6224,6 @@ function movedCounts(sc) {
5840
6224
  const slightly = new Set((h.warnings ?? []).map(placeOfRow).filter((p) => !worse.has(p)));
5841
6225
  return { worse: worse.size, warnings: slightly.size, better: h.counts.better, noChange: h.counts.no_change, inconclusive: h.counts.inconclusive };
5842
6226
  }
5843
- var placeCount = (rows) => new Set(rows.map((i) => `${i.scenario}|${i.device_id ?? ""}|${i.finding ?? placeRef(i.step ?? "*").stem}`)).size;
5844
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 });
5845
6228
  var setupLine = (h) => {
5846
6229
  const parts = [
@@ -5887,16 +6270,15 @@ var word = (s, server, blocking) => badgedWord(server || stateWord(s ?? "inconcl
5887
6270
  var setWord = (v) => word(v?.state ?? null, v?.word, v?.blocking ?? v?.headline?.blocking);
5888
6271
  var scenarioWord = (sc) => word(sc.state, sc.word, sc.headline?.blocking);
5889
6272
  var notComputed = (s) => !s.verdicts || s.verdicts.state === null && !s.verdicts.blocking;
5890
- var resultWord = (s) => setWord(s.verdicts);
5891
- var UNCHECKED_GATE = "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing";
5892
- var uncheckedGate = (s) => notComputed(s) ? UNCHECKED_GATE : null;
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;
5893
6275
  var countsLine = (c) => countsWords(c);
5894
6276
  var noCounts = (c) => !c.worse && !c.warnings && !c.better && !c.noChange && !c.inconclusive;
5895
- var change = (metric, a, b) => {
6277
+ var change = (metric, a, b, bounds) => {
5896
6278
  if (a === null || b === null)
5897
6279
  return "";
5898
- const pct = a === 0 ? "" : ` ${b >= a ? "+" : "-"}${Math.round(Math.abs(b / a - 1) * 100)}%`;
5899
- 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)})`;
5900
6282
  };
5901
6283
  function formatRunSet(s, top = 5) {
5902
6284
  const lines = [];
@@ -5926,11 +6308,12 @@ function formatRunSet(s, top = 5) {
5926
6308
  lines.push(...resultContextLines(sc.context).map((line) => ` ${line}`));
5927
6309
  if (heatNote(sc))
5928
6310
  lines.push(` ${heatNote(sc)}`);
6311
+ lines.push(...(sc.headline?.session_speed ?? []).map((line) => ` ${line}`));
5929
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"));
5930
6313
  const moved = allMoved.slice(0, top);
5931
6314
  for (const i of moved) {
5932
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);
5933
- 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)}`);
5934
6317
  }
5935
6318
  if (allMoved.length > moved.length)
5936
6319
  lines.push(` ${allMoved.length - moved.length} more`);
@@ -5947,35 +6330,12 @@ function formatRunSet(s, top = 5) {
5947
6330
  `);
5948
6331
  }
5949
6332
  function markdownRunSet(s, fallback) {
5950
- if (!s || !s.verdicts) {
6333
+ if (!s || !s.verdicts)
5951
6334
  return `### KeepSnappy
5952
6335
 
5953
6336
  Runs uploaded for \`${fallback.branch}\`. The result appears on the dashboard: [open KeepSnappy](${fallback.dashboardUrl})
5954
6337
  `;
5955
- }
5956
- const v = s.verdicts;
5957
- const heated = v.scenarios.flatMap((sc) => heatNote(sc) ?? []);
5958
- const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", sc.device?.model ?? "-"));
5959
- const rows = v.scenarios.map((sc) => {
5960
- const c = movedCounts(sc);
5961
- return `| ${sc.scenario ?? "-"} | ${sc.device ? sc.device.model : "-"} | ${scenarioWord(sc)}${sc.low_data ? ` (${FEW_RUNS})` : ""} | ${noCounts(c) ? "–" : `${c.worse} / ${c.warnings} / ${c.better} / ${c.noChange} / ${c.inconclusive}`} | ${sc.dashboard_url ? `[open](${sc.dashboard_url})` : "-"} |`;
5962
- });
5963
- return [
5964
- `### KeepSnappy: ${setWord(v)}`,
5965
- "",
5966
- ...setupLine(v.headline) ? [`${setupLine(v.headline)}.`, ""] : [],
5967
- `\`${s.branch}\`${s.commit_sha ? ` @ \`${s.commit_sha.slice(0, 7)}\`` : ""}, ${s.runs.valid}/${s.runs.total} runs used.${v.low_data ? ` ${FEW_RUNS}.` : ""}`,
5968
- "",
5969
- `| Journey | Device | Result | ${STATE_WORDS.worse} / ${STATE_WORDS.warning} / ${STATE_WORDS.better} / ${STATE_WORDS["no-change"]} / ${STATE_WORDS["cant-tell"]} | Details |`,
5970
- "|---|---|---|---|---|",
5971
- ...rows,
5972
- ...contextLines.length ? ["", ...contextLines] : [],
5973
- ...heated.length ? ["", ...heated.map((note) => `${note}.`)] : [],
5974
- "",
5975
- `[Open the dashboard](${s.dashboard_url})`,
5976
- ""
5977
- ].join(`
5978
- `);
6338
+ return isPending(s) ? pendingMarkdown(s.branch, linkOf(s) ?? s.dashboard_url) : resultMarkdown(s);
5979
6339
  }
5980
6340
 
5981
6341
  // apps/agent/src/run.ts
@@ -9351,7 +9711,7 @@ function parseArms(spec) {
9351
9711
  return spec.split(",").map((part) => {
9352
9712
  const eq = part.indexOf("=");
9353
9713
  if (eq <= 0)
9354
- 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>`]);
9355
9715
  return { name: part.slice(0, eq).trim(), target: part.slice(eq + 1).trim() };
9356
9716
  });
9357
9717
  }
@@ -9361,10 +9721,10 @@ function parseArmEnv(specs, arms) {
9361
9721
  for (const spec of specs ?? []) {
9362
9722
  const m = /^([^:=]+):([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(spec);
9363
9723
  if (!m)
9364
- throw new Error(`bad --arm-env "${spec}", expected ARM:KEY=VALUE`);
9724
+ throw new ConfigError([`bad --arm-env "${spec}", expected ARM:KEY=VALUE`]);
9365
9725
  const [, arm = "", key = "", value = ""] = m;
9366
9726
  if (!names.has(arm))
9367
- 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(", ")})`]);
9368
9728
  const e = env.get(arm) ?? {};
9369
9729
  e[key] = value;
9370
9730
  if (!key.startsWith("MAESTRO_"))
@@ -9375,12 +9735,15 @@ function parseArmEnv(specs, arms) {
9375
9735
  }
9376
9736
 
9377
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}:`);
9378
9741
  var isBuildPath = (target) => !!target && /\.(app|ipa)\/?$/.test(target);
9379
9742
  async function measure(deps, source, installer, opts) {
9380
9743
  const { fs, clock, spawner, log } = deps;
9381
9744
  const flows = opts.flows?.length ? opts.flows : [{ name: undefined, command: opts.testCommand }];
9382
9745
  if (flows.some((f) => !f.command[0]))
9383
- throw new Error("no test command given (put it after `--`)");
9746
+ throw new ConfigError(["no test command given (put it after `--`)"]);
9384
9747
  await fs.mkdir(opts.outDir);
9385
9748
  const runsPath = join7(opts.outDir, "runs.ndjson");
9386
9749
  await fs.writeFile(runsPath, "");
@@ -9421,9 +9784,9 @@ async function measure(deps, source, installer, opts) {
9421
9784
  --- stderr ---
9422
9785
  ${r.stderr}` : ""}`);
9423
9786
  if (r.error)
9424
- reason = `test-error: ${r.error}`;
9787
+ reason = `${TEST_ERROR}: ${r.error}`;
9425
9788
  else if (r.code !== 0)
9426
- reason = `test-failed: exit ${r.code}`;
9789
+ reason = `${TEST_FAILED}: exit ${r.code}`;
9427
9790
  }
9428
9791
  const t1 = clock.now();
9429
9792
  let windows = [];
@@ -9521,7 +9884,7 @@ async function replayRuns(deps, sources, outDir, scenario) {
9521
9884
  const runs = [];
9522
9885
  for (const src of sources) {
9523
9886
  const text = await deps.fs.readFile(join8(src.dir, "runs.ndjson")).catch(() => {
9524
- 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)`]);
9525
9888
  });
9526
9889
  for (const line of text.split(`
9527
9890
  `)) {
@@ -9541,7 +9904,7 @@ async function replayRuns(deps, sources, outDir, scenario) {
9541
9904
  }
9542
9905
  }
9543
9906
  if (runs.length === 0)
9544
- 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(", ")}`]);
9545
9908
  await deps.fs.writeFile(join8(outDir, "runs.ndjson"), runs.map((r) => JSON.stringify(r)).join(`
9546
9909
  `) + `
9547
9910
  `);
@@ -9555,7 +9918,7 @@ var DEFAULT_API_URL = "https://keepsnappy-api-922918702810.europe-west1.run.app"
9555
9918
  // apps/agent/src/journeys.ts
9556
9919
  import { isAbsolute, relative, resolve as resolve2, sep } from "node:path";
9557
9920
  var charCount = (s) => [...s].length;
9558
- function clip(s, max) {
9921
+ function clip2(s, max) {
9559
9922
  if (charCount(s) <= max)
9560
9923
  return s;
9561
9924
  const parts = typeof Intl.Segmenter === "function" ? Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s), (x) => x.segment) : [...s];
@@ -9589,7 +9952,7 @@ function flowHeader(text) {
9589
9952
  para.push(t);
9590
9953
  }
9591
9954
  const description = para.join(" ").replace(/\s+/g, " ").trim();
9592
- 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) } : {} };
9593
9956
  }
9594
9957
  async function repoRoot(deps) {
9595
9958
  const r = await deps.spawner.run("git", ["rev-parse", "--show-toplevel"]).catch(() => null);
@@ -9643,10 +10006,11 @@ function baseRefs(env) {
9643
10006
  return target ? [`origin/${target}`] : ["origin/main", "main"];
9644
10007
  }
9645
10008
  var SHA = /^[0-9a-f]{40,64}$/;
10009
+ var onMain = (env, branch) => !!branch && baseRefs(env).some((r) => !r.startsWith("-") && r.replace(REMOTE, "") === branch);
9646
10010
  var FIX = "fetch main and the commit, in CI with fetch-depth: 0, or pass --base-commit SHA";
9647
10011
  async function defaultBaseCommit(deps, env, o) {
9648
10012
  const refs = baseRefs(env).filter((r) => !r.startsWith("-"));
9649
- if (o.branch && refs.some((r) => r.replace(REMOTE, "") === o.branch))
10013
+ if (onMain(env, o.branch))
9650
10014
  return {};
9651
10015
  const tip = o.commit?.trim();
9652
10016
  if (!tip)
@@ -9682,6 +10046,34 @@ async function commitFields(deps, env, shas) {
9682
10046
  const pr = await pullRequestInfo(deps, env);
9683
10047
  return { ...commit ? { commit } : {}, ...base ? { base_commit: base } : {}, ...pr ? { pull_request: { ...pr } } : {} };
9684
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
+ }
9685
10077
 
9686
10078
  // apps/agent/src/run-command.ts
9687
10079
  var EXIT = { ok: 0, infra: 1, config: 2, worse: 3 };
@@ -9727,7 +10119,7 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
9727
10119
  api = { url, key, ...rt.fetch ? { fetch: rt.fetch } : {} };
9728
10120
  }
9729
10121
  if (o.wait && !o.upload)
9730
- 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)"]);
9731
10123
  if (o.wait && !git.branch)
9732
10124
  throw new ConfigError(["--wait needs the branch: pass --branch, or run in GitHub Actions (GITHUB_HEAD_REF / GITHUB_REF_NAME)"]);
9733
10125
  if (o["fail-on-worse"] && !o.wait)
@@ -9746,11 +10138,15 @@ async function runCommand(deps, o, positionals, rt = { env: process.env, print:
9746
10138
  let runSet;
9747
10139
  if (api && git.branch) {
9748
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);
9749
10144
  runSet = await createRunSet(api, {
9750
10145
  branch: git.branch,
9751
10146
  ...git.commit ? { commit_sha: git.commit } : {},
9752
10147
  ...base ? { base_commit_sha: base } : {},
9753
10148
  ...await commitFields(deps, rt.env, { commit: git.commit, base }),
10149
+ ...main.merges ? { merges: main.merges } : {},
9754
10150
  device: { ...device, provider },
9755
10151
  scenarios: flows.map((f) => f.name),
9756
10152
  journeys: await journeysFor(flows, { readFile: (p) => deps.fs.readFile(p), root: await repoRoot(deps) }),
@@ -9808,7 +10204,9 @@ ${outcome.summary}`);
9808
10204
  if (up.uploaded === 0) {
9809
10205
  if (runSet)
9810
10206
  await completeRunSet(api, runSet.run_set_id, "on-read").catch(infra("no run was uploaded, and the run set could not be closed"));
9811
- rt.print("Blocked: no run sent any data. Check that the build carries the SDK and that the app reaches the API.");
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."}`);
9812
10210
  if (runSet)
9813
10211
  await writeResult(deps, out, rt, runSet, null, git.branch, true);
9814
10212
  return EXIT.infra;
@@ -9817,7 +10215,7 @@ ${outcome.summary}`);
9817
10215
  return EXIT.ok;
9818
10216
  let summary = null;
9819
10217
  if (o.wait) {
9820
- 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"));
9821
10219
  rt.print(`
9822
10220
  ${formatRunSet(summary)}`);
9823
10221
  } else {
@@ -9854,10 +10252,7 @@ async function writeResult(deps, out, rt, runSet, summary, branch, blocked = fal
9854
10252
  `);
9855
10253
  const step = rt.env.GITHUB_STEP_SUMMARY;
9856
10254
  if (step)
9857
- await deps.fs.appendFile(step, blocked ? `### KeepSnappy: Blocked
9858
-
9859
- No run sent any data. [Open the dashboard](${url})
9860
- ` : 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 }));
9861
10256
  }
9862
10257
 
9863
10258
  // apps/agent/src/doctor.ts
@@ -10319,7 +10714,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
10319
10714
  import { dirname as dirname2, join as join11, relative as relative2, resolve as resolve4 } from "node:path";
10320
10715
  import { fileURLToPath } from "node:url";
10321
10716
  function driverVersion() {
10322
- const built = "0.1.0-alpha.17";
10717
+ const built = "0.1.0-alpha.18";
10323
10718
  if (built)
10324
10719
  return built;
10325
10720
  let dir = dirname2(fileURLToPath(import.meta.url));
@@ -10374,6 +10769,576 @@ var SDK_FLUSH_WORST_CASE_MS = 315000;
10374
10769
  var FLUSH_MARGIN_MS = 30000;
10375
10770
  var FLUSH_WAIT_MS = SDK_FLUSH_WORST_CASE_MS + FLUSH_MARGIN_MS;
10376
10771
  var FLUSH_STATUSES = new Set(["ok", "error", "off"]);
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
+ });
10377
11342
  // apps/agent/src/device-model.ts
10378
11343
  import { tmpdir } from "node:os";
10379
11344
  import { join as join12 } from "node:path";
@@ -10452,173 +11417,10 @@ async function detectSimulator(deps, udid) {
10452
11417
  }
10453
11418
 
10454
11419
  // apps/agent/src/slack.ts
10455
- var COLOR = { error: "#D6334A", warn: "#C47A00", better: "#238359", none: "#7C8492" };
10456
- var MAX_ROWS = 3;
10457
- var NAME_W = 30;
10458
- var COLUMN_GAP = 1;
10459
- function metricWord(item) {
10460
- if (item.label_text)
10461
- return item.label_text;
10462
- if (item.metric === "dur_ms" && placeKindWord(item.step ?? "") !== "Tap")
10463
- return "length";
10464
- return measureDef(item.metric)?.short ?? item.metric.replace(/_/g, " ");
10465
- }
10466
- function change2(item) {
10467
- if (item.change_text)
10468
- return item.change_text.replace(/ \(.*\)$/, "");
10469
- const { baseline: b, candidate: c } = item;
10470
- if (b === null || c === null)
10471
- return "";
10472
- if (b === 0)
10473
- return `${c - b >= 0 ? "+" : ""}${fieldValueText(item.metric, c - b)}`;
10474
- const p = Math.round((c / b - 1) * 100);
10475
- return `${p >= 0 ? "+" : ""}${p} %`;
10476
- }
10477
- 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));
10478
- var bySize = (a, b) => size(a) === size(b) ? 0 : size(a) > size(b) ? -1 : 1;
10479
- var worseOrder = (a, b) => Number(a.grade === "warn") - Number(b.grade === "warn") || bySize(a, b);
10480
- var isWorse = (i) => i.state === "worse";
10481
- var isBetter = (i) => i.state === "better";
10482
- var differs = (sc) => sc?.headline?.measured_differently ?? [];
10483
- var scenarioBlocks = (sc) => sc?.headline?.blocking !== false;
10484
- var isRed = (i, sc) => i.grade !== "warn" && scenarioBlocks(sc);
10485
- var mark = (i, sc) => isBetter(i) ? "\uD83D\uDFE2" : isRed(i, sc) ? "\uD83D\uDD34" : i.grade !== "warn" ? "⚪" : "\uD83D\uDFE1";
10486
- var fit = (s, w) => s.length > w ? `${s.slice(0, w - 1)}…` : s.padEnd(w);
10487
- var right = (s, w) => s.padStart(w);
10488
- var phone = (sc) => sc?.device ? `${sc.device.model}${sc.device.os ? ` · ${sc.device.os}` : ""}` : "unknown phone";
10489
- function commitLine(ctx) {
10490
- const c = ctx.commit;
10491
- if (!c)
10492
- return null;
10493
- const short = c.sha.slice(0, 7);
10494
- return c.title ? `Commit: *${c.title}*${c.author ? ` · ${c.author}` : ""} (${short})` : `Commit ${short}`;
10495
- }
10496
- var section = (text) => ({ type: "section", text: { type: "mrkdwn", text } });
10497
- var context = (text) => ({ type: "context", elements: [{ type: "mrkdwn", text }] });
10498
- function prTable(items, sc) {
10499
- const cells = items.map((i) => ({ i, main: fieldValueText(i.metric, i.baseline), pr: fieldValueText(i.metric, i.candidate), change: change2(i) }));
10500
- const widest = (head, pick) => Math.max(head.length, ...cells.map((c) => pick(c).length)) + COLUMN_GAP;
10501
- const w = [NAME_W, widest("main", (c) => c.main), widest("PR", (c) => c.pr), widest("change", (c) => c.change)];
10502
- const head = `${" ".repeat(3)}${fit("", w[0])}${right("main", w[1])}${right("PR", w[2])}${right("change", w[3])}`;
10503
- const rows = cells.flatMap((c) => [
10504
- `${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])}`,
10505
- ` ${metricWord(c.i)}`
10506
- ]);
10507
- return ["```", head, ...rows, "```"].join(`
10508
- `);
10509
- }
10510
- function mainCard(i) {
10511
- const label = (s) => ` ${s.padEnd(16)}`;
10512
- return [
10513
- "```",
10514
- placeLabel(i.step ?? "*"),
10515
- `${label(metricWord(i))}${right(fieldValueText(i.metric, i.candidate), 9)}`,
10516
- `${label("usually")}${right(fieldValueText(i.metric, i.baseline), 9)}`,
10517
- `${label("change")}${right(change2(i), 9)}`,
10518
- "```"
10519
- ].join(`
10520
- `);
10521
- }
10522
- function blockedMessage(s, ctx) {
10523
- const text = `⚪ No result for ${ctx.branch}: the measurement did not finish`;
10524
- const blocks = [
10525
- section(`*${text}*
10526
- ${ctx.blocked?.reason ?? "No iteration sent any data."}
10527
- Nothing was judged, so this is not a "${STATE_WORDS["no-change"].toLowerCase()}".`),
10528
- ...commitLine(ctx) ? [context(commitLine(ctx))] : [],
10529
- ...s ? [section(`\uD83D\uDC49 <${s.dashboard_url}|See what happened>`)] : []
10530
- ];
10531
- return { text, attachments: [{ color: COLOR.none, blocks }] };
10532
- }
10533
11420
  function slackMessage(s, ctx) {
10534
11421
  if (ctx.blocked)
10535
- return blockedMessage(s, ctx);
10536
- const v = s?.verdicts;
10537
- if (!s || !v)
10538
- return null;
10539
- const items = v.scenarios.flatMap((sc) => sc.state === "inconclusive" ? [] : movedItems(v, sc));
10540
- 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);
10541
- const isPr = v.scenarios.some((sc) => sc.role === "comparison");
10542
- const worse = items.filter(isWorse).sort(worseOrder);
10543
- const better = items.filter(isBetter).sort(bySize);
10544
- const commit = commitLine(ctx);
10545
- const heated = v.scenarios.filter((sc) => heatNote(sc));
10546
- const contextLines = v.scenarios.flatMap((sc) => laneContextLines(sc.context, sc.scenario ?? "-", phone(sc))).map(context);
10547
- const heat = heated.map((sc) => context(heatNote(sc)));
10548
- const errorRows = worse.filter((i) => i.grade !== "warn");
10549
- const warningRows = worse.filter((i) => i.grade === "warn");
10550
- const errors = placeCount(errorRows);
10551
- const warns = placeCount(worse) - errors;
10552
- const red = errorRows.some((i) => isRed(i, scenarioOf(i)));
10553
- const notBlocking = errors > 0 && !red;
10554
- const worseMark = red ? "\uD83D\uDD34" : warns > 0 ? "\uD83D\uDFE1" : "⚪";
10555
- const worseColor = red ? COLOR.error : warns > 0 ? COLOR.warn : COLOR.none;
10556
- if (isPr) {
10557
- if (!worse.length) {
10558
- if (!heat.length)
10559
- return null;
10560
- const head = `⚪ ${ctx.branch}: ${heat.length ? INFO_WORDS.heat : v.word ?? STATE_WORDS["no-change"]}`;
10561
- const blocks = [section(`*${head}*`), ...heat, ...contextLines, ...commit ? [context(commit)] : [], section(`\uD83D\uDC49 <${heated[0]?.dashboard_url ?? s.dashboard_url}|View the comparison>`)];
10562
- return { text: head, attachments: [{ color: COLOR.none, blocks }] };
10563
- }
10564
- const graded = worse.some((i) => i.grade);
10565
- const places = (word, n) => `${word.toLowerCase()} in ${n} place${n > 1 ? "s" : ""}`;
10566
- 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})` : ""}`;
10567
- const head = `${worseMark} ${ctx.branch}: ${counts}`;
10568
- const blocks = [section(`*${head}*`), ...contextLines];
10569
- const groups = new Map;
10570
- for (const i of worse)
10571
- groups.set(scenarioOf(i), [...groups.get(scenarioOf(i)) ?? [], i]);
10572
- let shown = 0;
10573
- for (const [sc, list] of groups) {
10574
- if (shown >= MAX_ROWS)
10575
- break;
10576
- const rows = list.slice(0, MAX_ROWS - shown);
10577
- shown += rows.length;
10578
- const nB = Math.max(...rows.map((r) => r.n_baseline ?? 0));
10579
- const nC = Math.max(...rows.map((r) => r.n_candidate ?? 0)) || (sc?.runs?.valid ?? 0);
10580
- const runs = nB && nC ? ` · ${nC} PR run${nC === 1 ? "" : "s"} vs ${nB} main run${nB === 1 ? "" : "s"}` : "";
10581
- const note = differs(sc).length ? `
10582
- _Measured differently (${differs(sc).join(", ")})${scenarioBlocks(sc) ? "" : ` — ${NOT_BLOCKING}`}_` : "";
10583
- const nonblocking = !scenarioBlocks(sc) && rows.some((i) => i.grade !== "warn") && !differs(sc).length ? ` · ${NOT_BLOCKING}` : "";
10584
- blocks.push(section(`*${sc?.scenario ?? "journey"}*${nonblocking} · ${phone(sc)}${runs}${note}
10585
- ${prTable(rows, sc)}`));
10586
- }
10587
- const more = worse.length - shown;
10588
- blocks.push(...heat);
10589
- if (commit)
10590
- blocks.push(context(commit));
10591
- blocks.push(section(`\uD83D\uDC49 <${groups.keys().next().value?.dashboard_url ?? s.dashboard_url}|View the comparison>${more > 0 ? ` · ${more} more` : ""}`));
10592
- return { text: head, attachments: [{ color: worseColor, blocks }] };
10593
- }
10594
- const improved = better.length > 0 || v.state === "better";
10595
- if (!worse.length && !improved && !ctx.always && !heat.length)
10596
- return null;
10597
- 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);
10598
- const quiet = improved ? STATE_WORDS.better.toLowerCase() : (v.state === null || v.state === "inconclusive") && v.word ? v.word : STATE_WORDS["no-change"].toLowerCase();
10599
- 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}`;
10600
- const head = `${worse.length ? worseMark : improved ? "\uD83D\uDFE2" : v.state === "inconclusive" && (ctx.always || !heat.length) ? "❔" : "⚪"} ${status}`;
10601
- const journeys = new Set(v.scenarios.map((sc) => `${sc.scenario}|${sc.device?.id}`)).size;
10602
- const blocks = [section(`*${head}*
10603
- ${journeys} journey${journeys === 1 ? "" : "s"} checked`), ...contextLines];
10604
- let shown = 0;
10605
- for (const [title, list] of [[STATE_WORDS.worse, errorRows], [STATE_WORDS.warning, warningRows], [STATE_WORDS.better, better]]) {
10606
- for (const i of list) {
10607
- if (shown >= MAX_ROWS)
10608
- break;
10609
- shown++;
10610
- const sc = scenarioOf(i);
10611
- blocks.push(section(`*${title}*${i.grade !== "warn" && isWorse(i) && !scenarioBlocks(sc) ? ` (${NOT_BLOCKING})` : ""} · *${sc?.scenario ?? "journey"}* · ${phone(sc)}
10612
- ${mainCard(i)}`));
10613
- }
10614
- }
10615
- const more = worse.length + better.length - shown;
10616
- blocks.push(...heat);
10617
- if (commit)
10618
- blocks.push(context(commit));
10619
- blocks.push(section(`\uD83D\uDC49 <${s.dashboard_url}|Open KeepSnappy>${more > 0 ? ` · ${more} more changes` : ""}`));
10620
- const color = worse.length ? worseColor : improved ? COLOR.better : COLOR.none;
10621
- 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;
10622
11424
  }
10623
11425
  async function postToSlack(url, payload, fetchImpl = fetch) {
10624
11426
  try {
@@ -10703,7 +11505,7 @@ function instrumentFlags(f) {
10703
11505
  return { ...driver ? { "driver-version": driver } : {}, ...journey ? { "journey-version": journey } : {} };
10704
11506
  }
10705
11507
  var TOKENS_STUB = "// Replaced by `keepsnappy farm open` for one upload. Never commit real tokens.\noutput.keepsnappyTokens = {};\n";
10706
- 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}$/;
10707
11509
  var DEFAULT_TTL = 21600;
10708
11510
  var POLL_MS = 5000;
10709
11511
  function tokensJs(api, runSetId, tokens, links = maestroLinkFields()) {
@@ -10790,7 +11592,7 @@ async function farmOpen(deps, f, rt) {
10790
11592
  if (journeys.length === 0)
10791
11593
  problems.push("--journeys a,b is required (the flow names the device runs, one history per journey)");
10792
11594
  for (const j of journeys)
10793
- if (!JOURNEY.test(j))
11595
+ if (!JOURNEY2.test(j))
10794
11596
  problems.push(`--journeys: "${j}" is not a journey name (letters, digits, . _ -)`);
10795
11597
  if (new Set(journeys).size !== journeys.length)
10796
11598
  problems.push("--journeys lists a journey twice");
@@ -10818,11 +11620,15 @@ async function farmOpen(deps, f, rt) {
10818
11620
  const withInstrument = { ...f, ...instrumentFlags(f) };
10819
11621
  const setup = Object.fromEntries(SETUP_FLAGS.flatMap(([flag, key]) => withInstrument[flag]?.trim() ? [[key, withInstrument[flag].trim().slice(0, 200)]] : []));
10820
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);
10821
11626
  const rs = await createRunSet(api, {
10822
11627
  branch: git.branch,
10823
11628
  ...git.commit ? { commit_sha: git.commit } : {},
10824
11629
  ...base ? { base_commit_sha: base } : {},
10825
11630
  ...await commitFields(deps, rt.env, { commit: git.commit, base }),
11631
+ ...main.merges ? { merges: main.merges } : {},
10826
11632
  device: { model: device.model, ...device.os ? { os: device.os } : {}, provider: device.provider, ...f["device-id"]?.trim() ? { id: f["device-id"].trim() } : {} },
10827
11633
  scenarios: journeys,
10828
11634
  journeys: journeyInfo,
@@ -10939,7 +11745,7 @@ async function settle(api, runSetId, o) {
10939
11745
  }
10940
11746
  }
10941
11747
  var FAILED_SESSION = new Set(["failed", "error", "timedout", "stopped", "skipped"]);
10942
- var median5 = (xs) => {
11748
+ var median6 = (xs) => {
10943
11749
  const v = [...xs].sort((a, b) => a - b);
10944
11750
  return v.length % 2 ? v[(v.length - 1) / 2] : (v[v.length / 2 - 1] + v[v.length / 2]) / 2;
10945
11751
  };
@@ -10961,10 +11767,11 @@ function brokenRun(state, runs, session) {
10961
11767
  const others = ordered.filter((r) => r !== last && r.journey === last.journey && has(r)).map((r) => byId.get(r.run_id).windows ?? 0);
10962
11768
  const journey = typeof s.usual_windows === "number" && s.usual_windows > 0 ? s.usual_windows : null;
10963
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);
10964
- const m = mine.length >= 2 ? median5(mine) : null;
10965
- const changed = m !== null && mine.every((n) => n >= 0.9 * m && 0.9 * n <= m) && (journey === null || m < 0.9 * journey);
10966
- const usual = changed ? m : journey ?? (others.length ? median5(others) : null);
10967
- 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)
10968
11775
  return last;
10969
11776
  return next;
10970
11777
  }
@@ -11000,6 +11807,7 @@ async function farmClose(deps, f, rt) {
11000
11807
  await scrubTokens(deps, state.tokens_js, rt);
11001
11808
  }
11002
11809
  }
11810
+ var blockedReason = (session) => `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.`;
11003
11811
  async function closeRunSet(deps, f, rt, state, statePath) {
11004
11812
  const settleSeconds = Number(f["settle-seconds"] ?? 60);
11005
11813
  const maxWaitSeconds = Number(f["max-wait-seconds"] ?? Math.max(300, settleSeconds * 3));
@@ -11054,7 +11862,7 @@ async function closeRunSet(deps, f, rt, state, statePath) {
11054
11862
  const blocked = summary.runs.valid === 0 && withData === 0;
11055
11863
  if (blocked) {
11056
11864
  rt.print("");
11057
- 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})` : ""}.`);
11058
11866
  rt.print(" Check that the build carries the SDK, the activation link ran, and the device reached the API.");
11059
11867
  rt.print(`Dashboard: ${summary.dashboard_url}`);
11060
11868
  } else if (f.wait) {
@@ -11075,36 +11883,14 @@ async function closeRunSet(deps, f, rt, state, statePath) {
11075
11883
  `);
11076
11884
  await githubOutputs(deps, rt, { state: state2.result.state ?? "none", dashboard_url: summary.dashboard_url, run_set_id: state.run_set_id });
11077
11885
  const step = rt.env.GITHUB_STEP_SUMMARY;
11078
- if (step) {
11079
- const head = blocked ? `### KeepSnappy: Blocked
11080
-
11081
- No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}. [Open the dashboard](${summary.dashboard_url})
11082
- ` : markdownRunSet(f.wait ? summary : null, { dashboardUrl: summary.dashboard_url, branch: state.branch });
11083
- const noData = summary.runs.no_data?.length ?? 0;
11084
- const tail = [
11085
- session.known && !session.passed ? `Device session ${session.status}${session.message ? `: ${session.message}` : ""}.` : "",
11086
- noData ? `${noData} of ${state.runs.length} iterations sent no data.` : ""
11087
- ].filter(Boolean);
11088
- await deps.fs.appendFile(step, `${head}${tail.length ? `
11089
- ${tail.join(" ")}
11090
- ` : ""}`);
11091
- }
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 }));
11092
11888
  const hook = envValue(rt.env, "SLACK_WEBHOOK");
11093
- if (hook) {
11094
- const commit = state.commit ? await commitInfo(deps, state.commit) : undefined;
11095
- const msg = slackMessage(f.wait || blocked ? summary : null, {
11096
- branch: state.branch,
11097
- ...commit ? { commit } : {},
11098
- ...blocked ? { blocked: { reason: `No iteration sent any data${session.known ? ` (device session ${session.status})` : ""}.` } } : {},
11099
- always: envValue(rt.env, "SLACK_ALWAYS") === "1"
11100
- });
11101
- if (!msg) {
11102
- rt.print(!f.wait ? "Slack: skipped, the message needs --wait" : notComputed(summary) ? "Slack: nothing to report, the verdict is not computed yet" : `Slack: nothing to report (${resultWord(summary).toLowerCase()})`);
11103
- } else {
11104
- const r = await postToSlack(hook, msg, rt.fetch ?? fetch);
11105
- rt.print(r.ok ? "Slack: posted" : `Slack: not posted (${r.error}); the job is not affected`);
11106
- }
11107
- }
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");
11108
11894
  if (blocked)
11109
11895
  return EXIT.infra;
11110
11896
  if (f["fail-on-worse"] && isBlocking(summary)) {
@@ -11417,7 +12203,7 @@ function nodeFarmRunPorts(deps, rt) {
11417
12203
  }
11418
12204
  var TUNNEL_TARGET = /^(?:localhost|127\.0\.0\.1):(\d{1,5})$/;
11419
12205
  var ENV_PAIR = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
11420
- var DEVICE = /^(.+)-([^-]+)$/;
12206
+ var DEVICE2 = /^(.+)-([^-]+)$/;
11421
12207
  var SECRETISH = /KEY|TOKEN|SECRET|PASSWORD/i;
11422
12208
  var RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
11423
12209
  function planFarmRun(f) {
@@ -11427,7 +12213,7 @@ function planFarmRun(f) {
11427
12213
  const platform = f.platform ?? "ios";
11428
12214
  if (platform !== "ios" && platform !== "android")
11429
12215
  problems.push("--platform must be ios or android");
11430
- if (!f.device || !DEVICE.test(f.device))
12216
+ if (!f.device || !DEVICE2.test(f.device))
11431
12217
  problems.push('--device "<model>-<os>" is required, e.g. "iPhone 15 Pro-17"');
11432
12218
  const driver = f.driver ?? "maestro";
11433
12219
  if (driver !== "maestro" && driver !== "appium")
@@ -11436,7 +12222,7 @@ function planFarmRun(f) {
11436
12222
  if (journeys.length === 0)
11437
12223
  problems.push("--journeys a,b is required");
11438
12224
  for (const j of journeys)
11439
- if (!JOURNEY.test(j))
12225
+ if (!JOURNEY2.test(j))
11440
12226
  problems.push(`--journeys: "${j}" is not a journey name (letters, digits, . _ -)`);
11441
12227
  const repeat = Number(f.repeat ?? 5);
11442
12228
  if (!Number.isInteger(repeat) || repeat < 1 || repeat > 100)
@@ -11533,7 +12319,7 @@ function printPlan(p, f, rt) {
11533
12319
  line(` env ${k}=${substitute(v, {})}`);
11534
12320
  line(` 6. farm close${f.wait ? " --wait" : ""}${f["fail-on-worse"] ? " --fail-on-worse" : ""} with ${join16(p.out, `browserstack.${p.platform}.json`)}`);
11535
12321
  line(" 7. always: stop the tunnel, put the empty stub back over the tokens file");
11536
- line(" exit: 0 any verdict, 1 infrastructure / driver failed / Blocked, 2 configuration, 3 --fail-on-worse (a computed blocking Worse only; no computed verdict: nothing checked)");
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)`);
11537
12323
  }
11538
12324
  var ANNOTATION = /^::(error|warning|notice)( [^:]*)?::/;
11539
12325
  async function runDriver2(deps, rt, f, p, ports, ctx) {
@@ -11636,7 +12422,7 @@ async function farmRun(deps, f, rt, ports) {
11636
12422
  let driver = { ok: true, message: null };
11637
12423
  let closeCode = EXIT.ok;
11638
12424
  try {
11639
- 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]];
11640
12426
  const openCode = await farmOpen(deps, {
11641
12427
  ...f,
11642
12428
  journeys: p.journeys.join(","),
@@ -11992,9 +12778,13 @@ class Rows {
11992
12778
  this.rows.push({ text: line, short: false, notice: false });
11993
12779
  return this;
11994
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
+ }
11995
12785
  render(form, left = []) {
11996
12786
  if (form !== "short")
11997
- return this.rows.map((r) => r.text).join(`
12787
+ return this.rows.filter((r) => !r.shortOnly).map((r) => r.text).join(`
11998
12788
  `);
11999
12789
  const kept = [];
12000
12790
  for (const r of this.rows) {
@@ -12023,7 +12813,7 @@ function formatFinding(f, opts = {}) {
12023
12813
  r.both(...laneContextLines(f.lane.context, f.lane.journey, f.lane.phone_name, opts.verbose, opts.form === "short"));
12024
12814
  r.full(line("Why", f.answer.why));
12025
12815
  r.both(line("What to do", f.answer.what_to_do));
12026
- 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("; ")}` : ""}`));
12027
12817
  r.both(line("Id", f.id));
12028
12818
  if (f.why.components.length) {
12029
12819
  r.full(line("Components", ""));
@@ -12051,7 +12841,7 @@ function formatFinding(f, opts = {}) {
12051
12841
  }
12052
12842
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
12053
12843
  var LANES_SHOWN = 8;
12054
- var STATE_WORDS2 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
12844
+ var STATE_WORDS3 = { present: "still present", resolved: "resolved", "not-judged": "not judged in the newest run set" };
12055
12845
  function measuredLines(l, r, opts) {
12056
12846
  const f = l.filters;
12057
12847
  const scope = [f.branch ?? "all branches", f.journey, f.phone].filter(Boolean).join(" · ");
@@ -12069,6 +12859,7 @@ function measuredLines(l, r, opts) {
12069
12859
  };
12070
12860
  if (m.lanes.length === 1) {
12071
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}`));
12072
12863
  r.both(...laneContextLines(n.context, n.journey, n.phone_name, opts.verbose, opts.form === "short"));
12073
12864
  return [];
12074
12865
  }
@@ -12078,6 +12869,7 @@ function measuredLines(l, r, opts) {
12078
12869
  const keep = !x.judged || !!x.context;
12079
12870
  r.add(keep, ` ${x.journey} on ${x.phone_name}${f.branch ? "" : ` (${x.branch})`}: ${lane(x)}`);
12080
12871
  r.add(keep, ...resultContextLines(x.context, opts.verbose, opts.form === "short").map((line) => ` ${line}`));
12872
+ r.full(...(x.session_speed ?? []).map((line) => ` ${line}`));
12081
12873
  }
12082
12874
  if (m.lanes.length > shown.length)
12083
12875
  r.full(` … ${m.lanes.length - shown.length} more (--journey / --phone narrow it)`);
@@ -12096,7 +12888,7 @@ function countsLine2(l) {
12096
12888
  ].filter(Boolean);
12097
12889
  const state = l.filters.state;
12098
12890
  const unjudged = l.measured.lanes.filter((x) => !x.judged);
12099
- 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()}`;
12100
12892
  const heated = c.heat_runs ?? 0;
12101
12893
  const more = [
12102
12894
  heated ? `${INFO_WORDS.heat.toLowerCase()} in ${plural2(heated, "run")} (run health, layer 3)` : "",
@@ -12144,6 +12936,7 @@ function formatBranchStatus(s, opts = {}) {
12144
12936
  const sha = l.commit?.sha?.slice(0, 7);
12145
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)}` : ""}`);
12146
12938
  r.both(...resultContextLines(l.context, opts.verbose, opts.form === "short"));
12939
+ r.full(...(l.session_speed ?? []).map((line) => ` ${line}`));
12147
12940
  for (const f of l.worst)
12148
12941
  r.both(` ${f.id} ${f.headline}`);
12149
12942
  if (l.present_findings > l.worst.length)
@@ -12283,16 +13076,29 @@ function formatHistory(h, opts = {}) {
12283
13076
  const r = new Rows;
12284
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"}`);
12285
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;
12286
13082
  if (h.points.length) {
12287
13083
  r.both(` ${"when".padEnd(17)}${ms.map((m) => (m.label.length > 22 ? `${m.label.slice(0, 21)}…` : m.label).padStart(24)).join("")} commit`);
12288
13084
  h.points.forEach((p, i) => {
12289
- 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})` : ""}`);
12290
13093
  });
12291
13094
  }
12292
13095
  if (h.findings.length)
12293
13096
  r.both("", "Findings on this window:", ...h.findings.map((f) => ` ${f.answer.headline} — ${f.where.branch} (${f.id})`));
12294
13097
  const older = Math.max(0, h.points.length - SHORT_POINTS);
12295
- return r.render(opts.form, older ? [plural2(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
+ ]);
12296
13102
  }
12297
13103
  function formatCommits(c) {
12298
13104
  return [
@@ -12412,7 +13218,7 @@ function formatMainSessions(h, opts = {}) {
12412
13218
  if (s.memory)
12413
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)}` : ""}`);
12414
13220
  for (const v of h.versions.filter((v) => v.from_run_set_id === s.run_set_id))
12415
- r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`);
13221
+ r.add(k, ` Version: ${v.label}; ${mainCutWords(v)}`, ...renamedReleaseLines(v.renamed));
12416
13222
  for (const c of h.changes.filter((c) => c.from_run_set_id === s.run_set_id)) {
12417
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 ?? "–"}`);
12418
13224
  if (k && s !== latest)
@@ -12425,6 +13231,23 @@ function formatMainSessions(h, opts = {}) {
12425
13231
  ...otherChanges ? [plural2(otherChanges, "main change") + " of the other recent sessions"] : []
12426
13232
  ]);
12427
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");
13250
+ }
12428
13251
  function formatMainSessionPlaces(p, opts = {}) {
12429
13252
  const r = new Rows;
12430
13253
  if (p.status !== "ok")
@@ -12432,7 +13255,7 @@ function formatMainSessionPlaces(p, opts = {}) {
12432
13255
  const baselineSessions = p.baseline_sessions;
12433
13256
  r.both(`Main session places: tiers are verdicts vs ${p.baseline_sessions === undefined ? "earlier sessions" : `the ${p.baseline_sessions} sessions before`}.`);
12434
13257
  for (const x of p.places)
12435
- 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 } })}` : ""}`);
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)}` : ""}`);
12436
13259
  const inNorm = p.places.filter((x) => x.tier === "same").length;
12437
13260
  return r.render(opts.form, inNorm ? [plural2(inNorm, "place") + " in norm"] : []);
12438
13261
  }
@@ -12443,7 +13266,7 @@ function formatMainVsPrevious(m, opts = {}) {
12443
13266
  if (m.cut === "unknown")
12444
13267
  r.both("History restart: unknown; current summaries unavailable.");
12445
13268
  else if (m.cut)
12446
- 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));
12447
13270
  if (m.not_computed.length)
12448
13271
  r.both(`not_computed: ${m.not_computed.join(", ")}`);
12449
13272
  if (m.stale.length)
@@ -12456,6 +13279,22 @@ function formatMainVsPrevious(m, opts = {}) {
12456
13279
  const smaller = m.places.filter((p, i) => i >= SHORT_PLACES && p.status === "compared").length;
12457
13280
  return r.render(opts.form, smaller ? [plural2(smaller, "smaller mover")] : []);
12458
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
+ ];
13297
+ }
12459
13298
  function formatPlaceTrend(t, opts = {}) {
12460
13299
  const r = new Rows().both(`${t.journey} · ${t.phone.model} · ${t.baseline_branch}: stored per-session statistics`);
12461
13300
  const newest = new Set(t.sessions.slice(-SHORT_SESSIONS).map((s) => s.run_set_id));
@@ -12467,6 +13306,8 @@ function formatPlaceTrend(t, opts = {}) {
12467
13306
  r.both(`History restarts unknown: ${session.run_set_id} (no current main summary in the history window)`);
12468
13307
  for (const restart of t.restarts.filter((x) => x.from_run_set_id === session.run_set_id)) {
12469
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);
12470
13311
  if (!k)
12471
13312
  olderRestarts++;
12472
13313
  }
@@ -12477,7 +13318,7 @@ function formatPlaceTrend(t, opts = {}) {
12477
13318
  const unit = place.unit ? ` ${place.unit}` : "";
12478
13319
  if (!k)
12479
13320
  olderSessions.add(session.run_set_id);
12480
- r.add(k, `${session.completed_at?.slice(0, 10) ?? "date unknown"} ${session.commit.sha?.slice(0, 7) ?? "commit unknown"} · ${windowQualified(place.window)}${opts.verbose ? ` (${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}`);
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}`);
12481
13322
  }
12482
13323
  }
12483
13324
  if (!t.sessions.length)
@@ -12554,6 +13395,7 @@ var sessionPlaces = (t, f) => get(t, "v1/sessions/places", {
12554
13395
  });
12555
13396
  var SESSION_SORTS = ["median", "p90", "mad"];
12556
13397
  var mainSessions = (t, q) => get(t, "v1/main/sessions", { ...q });
13398
+ var mainChanges = (t, q) => get(t, "v1/main/changes", { ...q });
12557
13399
  var mainSessionPlaces = (t, runSetId, q) => get(t, `v1/main/sessions/${encodeURIComponent(runSetId)}/places`, { ...q });
12558
13400
  var mainVsPrevious = (t, q) => {
12559
13401
  const { run_set, ...query } = q;
@@ -12573,6 +13415,7 @@ var FINDINGS_USAGE = `keepsnappy findings [--branch NAME | --main] [--journey KE
12573
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]
12574
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)
12575
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)
12576
13419
  layers [--json]
12577
13420
  trend <journey> <window words...> --phone MODEL [--window PLACE ...] [--ref REF ...] [--sessions N] [--short] [--json]
12578
13421
  (the words after the journey are one window name; more windows with repeated --window)
@@ -12602,6 +13445,7 @@ async function findingsCommand(cmd, argv, rt) {
12602
13445
  state: { type: "string" },
12603
13446
  session: { type: "string" },
12604
13447
  "vs-previous": { type: "boolean" },
13448
+ changes: { type: "boolean" },
12605
13449
  limit: { type: "string" },
12606
13450
  offset: { type: "string" },
12607
13451
  "run-set": { type: "string" },
@@ -12662,6 +13506,11 @@ async function findingsCommand(cmd, argv, rt) {
12662
13506
  if (sessions !== undefined && (o.session || o["vs-previous"]))
12663
13507
  throw new UsageError(["--sessions is for the session list"]);
12664
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
+ }
12665
13514
  if (o["vs-previous"])
12666
13515
  return out(await mainVsPrevious(t, { ...lane, run_set: o.session, measure: o.measure, limit, offset }), formatMainVsPrevious);
12667
13516
  if (o.session)
@@ -12868,7 +13717,7 @@ var USAGE = `keepsnappy <command>
12868
13717
  [--device-model NAME --device-os VERSION] [--scenario NAME] [--replay <out dir>[:ARM] ...]
12869
13718
  [--no-markers] [-- <e2e command...>]
12870
13719
  (--fail-on-worse: exit 3 only when the verdict computed on that run set is Worse and blocking; closing never computes one,
12871
- so with no computed verdict it checks nothing: "Verdict not computed yet (computed on demand): --fail-on-worse checked nothing", exit 0)
13720
+ so with no computed verdict it checks nothing: "${UNCHECKED_GATE}", exit 0)
12872
13721
  measure --repeat N [--arms A=<app|bundle>,B=<app|bundle>] [--arm-env 'A:KEY=VAL' ...] [--bundle-id ID] [--platform ios-sim|ios-device]
12873
13722
  [--device UDID] [--out DIR] [--timeout SEC] [--ri-capture observations.ndjson] [--mem-window NAME]
12874
13723
  [--clock-offset-ms N] [--no-markers]
@@ -12898,7 +13747,7 @@ async function bundleIdOf(deps, target, explicit) {
12898
13747
  if (explicit)
12899
13748
  return explicit;
12900
13749
  if (!target)
12901
- 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)"]);
12902
13751
  if (!isBuildPath(target))
12903
13752
  return target;
12904
13753
  const r = await deps.spawner.run("plutil", ["-extract", "CFBundleIdentifier", "raw", `${target.replace(/\/$/, "")}/Info.plist`]);