@stage5/lumine 0.3.46 → 0.3.47

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.
@@ -13,6 +13,8 @@ export const OWNER_TRACE_PERF_TYPES = [
13
13
  "slow-request",
14
14
  "stall",
15
15
  "page-load",
16
+ "scroll-perf",
17
+ "scroll-frame",
16
18
  ];
17
19
 
18
20
  const MAX_LIMIT = 2000;
@@ -134,6 +136,10 @@ export function describeOwnerTraceEvent(event) {
134
136
  ].filter(Boolean);
135
137
  return `${data.m || "?"} ${data.api || "?"} → ${data.status ?? "?"} in ${data.ms}ms${extra.length ? ` (${extra.join(", ")})` : ""}`;
136
138
  }
139
+ case "scroll-perf":
140
+ return describeScrollPerf(data);
141
+ case "scroll-frame":
142
+ return describeScrollFrame(data);
137
143
  case "stall":
138
144
  return `main thread blocked ${data.ms}ms${data.n > 1 ? ` (${data.n} stalls, ${data.total}ms blocked over ${data.span}ms)` : ""}`;
139
145
  case "page-load": {
@@ -154,6 +160,55 @@ export function describeOwnerTraceEvent(event) {
154
160
  }
155
161
  }
156
162
 
163
+ // One Home/page scroll burst: frame pacing plus what was on screen.
164
+ function describeScrollPerf(data) {
165
+ const cards = (data.context?.cards || [])
166
+ .map((card) => card.key)
167
+ .filter(Boolean);
168
+ const source = data.frameSource
169
+ ? `${data.frameSource.source === "loaf" ? `LoAF ${data.frameSource.loafFrames ?? 0} long frame(s)` : "no LoAF"} · card renders ${data.frameSource.cardRenders} mounts ${data.frameSource.cardMounts} · img loads ${data.frameSource.imgLoads}`
170
+ : "";
171
+ return [
172
+ `${data.frames ?? 0} frames over ${data.spanMs ?? 0}ms`,
173
+ `mean ${data.meanFrameMs ?? "?"}ms max ${data.maxFrameMs ?? "?"}ms`,
174
+ `>50ms ${data.over50 ?? 0} >100ms ${data.over100 ?? 0}`,
175
+ cards.length ? `cards ${cards.join(",")}` : "",
176
+ source,
177
+ data.reason ? `(${data.reason})` : "",
178
+ ]
179
+ .filter(Boolean)
180
+ .join(" · ");
181
+ }
182
+
183
+ // One slow frame of a scroll burst: Long Animation Frame scripts (Chrome) or
184
+ // the feed card types that rendered in it (every browser).
185
+ function describeScrollFrame(data) {
186
+ const counts = (label, map) => {
187
+ const entries = Object.entries(map || {});
188
+ return entries.length
189
+ ? `${label} ${entries.map(([type, n]) => `${type}×${n}`).join(",")}`
190
+ : "";
191
+ };
192
+ const loaf = data.loaf;
193
+ const head = loaf
194
+ ? `slow frame ${loaf.ms}ms (LoAF, blocking ${loaf.blockingMs ?? "?"}ms${loaf.styleLayoutShare != null ? `, style/layout ${Math.round(loaf.styleLayoutShare * 100)}%` : ""}${data.gapMs != null ? `, rAF gap ${data.gapMs}ms` : ""})`
195
+ : `slow frame ${data.gapMs}ms (rAF gap, no LoAF)`;
196
+ const scripts = (loaf?.scripts || []).map(
197
+ (script) =>
198
+ `${script.ms}ms ${script.src || "?"}${script.fn ? ` ${script.fn}` : ""} [${script.type || "?"}${script.invoker ? ` ${script.invoker}` : ""}]${script.forcedLayoutMs ? ` forced layout ${script.forcedLayoutMs}ms` : ""}`,
199
+ );
200
+ return [
201
+ head,
202
+ data.offsetMs != null ? `+${data.offsetMs}ms` : "",
203
+ scripts.length ? `scripts: ${scripts.join("; ")}` : "",
204
+ counts("rendered", data.renders),
205
+ counts("mounted", data.mounts),
206
+ data.imgLoads ? `${data.imgLoads} img load(s)` : "",
207
+ ]
208
+ .filter(Boolean)
209
+ .join(" · ");
210
+ }
211
+
157
212
  // "tap→page 4210ms · PUSH via nav · url +90 · gate +4180 (probe timeout,
158
213
  // waited 4100ms) · commit +4210 · ready +4600": offsets are ms from the tap.
159
214
  function describeNavTiming(data) {
package/lib/admin.js CHANGED
@@ -7622,6 +7622,12 @@ function printAdminResult({ operation, result }) {
7622
7622
  `Confirmed Build #${data.review.buildId} runtime at published artifact #${data.review.publishedArtifactVersionId}.`,
7623
7623
  );
7624
7624
  console.log(`Screenshot: ${data.screenshotPath}`);
7625
+ const teardown = data.review.browser?.teardownWarning;
7626
+ if (teardown) {
7627
+ console.log(
7628
+ `Teardown warning: Chrome ended with ${teardown.signal ? `signal ${teardown.signal}` : `code ${teardown.exitCode}`} (${teardown.signature}) after the evidence was captured and Browser.close was sent; the review is still confirmed.`,
7629
+ );
7630
+ }
7625
7631
  if (data.review.interaction) {
7626
7632
  console.log(
7627
7633
  `Interaction script: ${data.review.interaction.stepsCompleted}/${data.review.interaction.stepsPlanned} step(s) ${data.review.interaction.status}.`,
@@ -745,6 +745,9 @@ async function runChromeReview({
745
745
  });
746
746
  let stdout = "";
747
747
  let stderr = "";
748
+ // Only what Chrome printed after our own Browser.close, so a teardown
749
+ // signature is never matched against a message from before the close.
750
+ let stderrAfterClose = null;
748
751
  const consoleLines = [];
749
752
  const append = (current, chunk) => `${current}${chunk}`.slice(-2_000_000);
750
753
  child.stdout.on("data", (chunk) => {
@@ -752,6 +755,9 @@ async function runChromeReview({
752
755
  });
753
756
  child.stderr.on("data", (chunk) => {
754
757
  stderr = append(stderr, chunk);
758
+ if (stderrAfterClose !== null) {
759
+ stderrAfterClose = `${stderrAfterClose}${chunk}`.slice(-200_000);
760
+ }
755
761
  });
756
762
  const closed = new Promise((resolve, reject) => {
757
763
  child.once("error", reject);
@@ -908,22 +914,36 @@ async function runChromeReview({
908
914
  }
909
915
  }
910
916
  }
917
+ // Everything the receipt relies on is in hand before the close; a
918
+ // nonzero exit can only be read as teardown when that is true and Chrome
919
+ // was still running when we asked it to close.
920
+ const evidenceCompleteBeforeClose =
921
+ Boolean(screenshot.data) &&
922
+ isRuntimeReady(readiness) &&
923
+ (!interaction || interactionResult?.status === "completed");
924
+ const exitedBeforeClose =
925
+ child.exitCode !== null || child.signalCode !== null;
926
+ const closeRequestedAt = Date.now();
927
+ stderrAfterClose = "";
911
928
  await cdp.send("Browser.close").catch(() => {});
912
929
  let forcedTermination = false;
913
930
  const closeResult = await Promise.race([
914
931
  closed,
915
932
  delay(5_000).then(() => {
916
933
  forcedTermination = true;
917
- child.kill("SIGTERM");
918
- return closed;
934
+ return stopChrome(child, closed);
919
935
  }),
920
936
  ]);
921
937
  return {
922
938
  ...closeResult,
923
939
  closeRequested: true,
924
940
  forcedTermination,
941
+ evidenceCompleteBeforeClose,
942
+ exitedBeforeClose,
943
+ closeDurationMs: Date.now() - closeRequestedAt,
925
944
  stdout,
926
945
  stderr,
946
+ stderrAfterClose,
927
947
  consoleLines: consoleLines.slice(-MAX_CAPTURED_LOG_LINES),
928
948
  runtimeReadiness: {
929
949
  ...(readiness || {}),
@@ -934,24 +954,85 @@ async function runChromeReview({
934
954
  };
935
955
  } finally {
936
956
  clearTimeout(timer);
937
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
957
+ if (child.exitCode === null && child.signalCode === null)
958
+ await stopChrome(child, closed).catch(() => {});
938
959
  }
939
960
  }
940
961
 
962
+ // SIGTERM, then SIGKILL if Chrome is still there after 3 s: a browser wedged
963
+ // in teardown (the case this review must tolerate) can ignore SIGTERM, and
964
+ // the review must never hang or leave Chrome running.
965
+ async function stopChrome(child, closed) {
966
+ child.kill("SIGTERM");
967
+ const ended = await Promise.race([closed, delay(3_000).then(() => null)]);
968
+ if (ended) return ended;
969
+ child.kill("SIGKILL");
970
+ return closed;
971
+ }
972
+
941
973
  const CHROME_KILLED_AFTER_CLOSE_CODE = 2;
942
974
 
943
- // Chrome sometimes exits 2 (RESULT_CODE_KILLED) while tearing down a
944
- // GPU-heavy page after our own Browser.close. Every piece of evidence was
945
- // captured before that close, so that one shutdown result is cleanup, not a
946
- // failed review. Any other code, a signal, or our forced kill still fails.
947
- export function reviewBrowserExitedCleanly(browser) {
948
- return (
949
- browser?.code === 0 ||
950
- (browser?.code === CHROME_KILLED_AFTER_CLOSE_CODE &&
951
- !browser.signal &&
952
- browser.closeRequested === true &&
953
- browser.forcedTermination === false)
954
- );
975
+ // Lines Chrome prints when its own shutdown, not the page, went wrong.
976
+ // "Teardown watchdog expired" is the network-service watchdog that kills a
977
+ // browser whose services do not stop in time after Browser.close.
978
+ // Anchored to the start of the line: Chrome prints the bare message (seen
979
+ // 10-19) or behind its own ERROR/FATAL prefix. A page console line is
980
+ // "[pid:tid:...:INFO:CONSOLE:n] \"...\"", so page text cannot match.
981
+ const CHROME_TEARDOWN_SIGNATURES = [
982
+ {
983
+ id: "teardown_watchdog_expired",
984
+ pattern:
985
+ /^(?:\[[^\]]*:(?:ERROR|FATAL):[^\]]*\]\s*)?teardown watchdog expired/i,
986
+ },
987
+ ];
988
+
989
+ function chromeTeardownSignature(stderrAfterClose) {
990
+ for (const line of String(stderrAfterClose || "").split(/\r?\n/)) {
991
+ const match = CHROME_TEARDOWN_SIGNATURES.find(({ pattern }) =>
992
+ pattern.test(line),
993
+ );
994
+ if (match) return { id: match.id, line: line.trim().slice(0, 400) };
995
+ }
996
+ return null;
997
+ }
998
+
999
+ // Capture validity and process cleanup are separate questions. Exit 0 is
1000
+ // clean. A nonzero exit (or our forced stop) is accepted as a teardown
1001
+ // warning only when all page evidence was complete before our own
1002
+ // Browser.close, Chrome was still running when we sent it, and the exit is
1003
+ // a known shutdown outcome: exit 2 (RESULT_CODE_KILLED) on its own, or any
1004
+ // exit with a teardown signature printed after the close. Anything else,
1005
+ // including every exit before the evidence was complete, fails the review.
1006
+ export function classifyReviewBrowserExit(browser) {
1007
+ if (browser?.code === 0 && !browser?.signal) {
1008
+ return { accepted: true, teardownWarning: null };
1009
+ }
1010
+ const afterOwnClose =
1011
+ browser?.closeRequested === true &&
1012
+ browser?.exitedBeforeClose === false &&
1013
+ browser?.evidenceCompleteBeforeClose === true;
1014
+ if (!afterOwnClose) return { accepted: false, teardownWarning: null };
1015
+ const signature = chromeTeardownSignature(browser.stderrAfterClose);
1016
+ const killedAfterClose =
1017
+ browser.code === CHROME_KILLED_AFTER_CLOSE_CODE &&
1018
+ !browser.signal &&
1019
+ browser.forcedTermination === false;
1020
+ if (!signature && !killedAfterClose) {
1021
+ return { accepted: false, teardownWarning: null };
1022
+ }
1023
+ return {
1024
+ accepted: true,
1025
+ teardownWarning: {
1026
+ exitCode: browser.code,
1027
+ signal: browser.signal || null,
1028
+ forcedTermination: browser.forcedTermination === true,
1029
+ closeDurationMs: Number.isFinite(browser.closeDurationMs)
1030
+ ? browser.closeDurationMs
1031
+ : null,
1032
+ signature: signature?.id || "exit_2_after_close",
1033
+ stderrLine: signature?.line || null,
1034
+ },
1035
+ };
955
1036
  }
956
1037
 
957
1038
  // The last few Chrome stderr lines, for diagnosing a nonzero exit.
@@ -1032,7 +1113,8 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
1032
1113
  const interactionOutcome = browser.interaction || null;
1033
1114
  const interactionCompleted =
1034
1115
  !interaction || interactionOutcome?.status === "completed";
1035
- const browserExitedCleanly = reviewBrowserExitedCleanly(browser);
1116
+ const browserExit = classifyReviewBrowserExit(browser);
1117
+ const browserExitedCleanly = browserExit.accepted;
1036
1118
  const confirmed =
1037
1119
  browserExitedCleanly &&
1038
1120
  screenshotBytes > 0 &&
@@ -1057,7 +1139,15 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
1057
1139
  signal: browser.signal,
1058
1140
  closeRequested: browser.closeRequested === true,
1059
1141
  forcedTermination: browser.forcedTermination === true,
1060
- exitedCleanly: browserExitedCleanly,
1142
+ exitedCleanly: browser.code === 0 && !browser.signal,
1143
+ evidenceCompleteBeforeClose: browser.evidenceCompleteBeforeClose === true,
1144
+ exitedBeforeClose: browser.exitedBeforeClose === true,
1145
+ closeDurationMs: Number.isFinite(browser.closeDurationMs)
1146
+ ? browser.closeDurationMs
1147
+ : null,
1148
+ // Set when the page evidence is valid but Chrome's own shutdown ended
1149
+ // nonzero; the review stays confirmed and this says what happened.
1150
+ teardownWarning: browserExit.teardownWarning,
1061
1151
  stderrTail: browser.code === 0 ? null : chromeStderrTail(browser.stderr),
1062
1152
  runtimeReadiness: browser.runtimeReadiness,
1063
1153
  },
@@ -1097,7 +1187,9 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
1097
1187
  screenshotBytes > 0 &&
1098
1188
  browser.runtimeReadiness?.ready === true &&
1099
1189
  interactionCompleted
1100
- ? `Chrome exited with ${browser.signal ? `signal ${browser.signal}` : `code ${browser.code}`}${browser.forcedTermination ? " after the CLI had to stop it" : ""} once the review evidence was captured. The app loaded and the screenshots are in the receipt, but the receipt is not confirmed; see browser.stderrTail and review again.`
1190
+ ? browser.exitedBeforeClose === true
1191
+ ? `Chrome exited with ${browser.signal ? `signal ${browser.signal}` : `code ${browser.code}`} before the CLI asked it to close, so the run is not trusted. The screenshots are in the receipt; see browser.stderrTail and review again.`
1192
+ : `Chrome exited with ${browser.signal ? `signal ${browser.signal}` : `code ${browser.code}`}${browser.forcedTermination ? " after the CLI had to stop it" : ""} while closing, with no known teardown signature. The app loaded and the screenshots are in the receipt, but the receipt is not confirmed; see browser.stderrTail and review again.`
1101
1193
  : interactionFailure
1102
1194
  ? `Interaction step ${interactionOutcome?.failedStep?.index ?? "?"} (${interactionOutcome?.failedStep?.kind ?? "?"}) failed: ${interactionOutcome?.failedStep?.error ?? "unknown"} The receipt lists the ${interactionOutcome?.stepsCompleted ?? 0} completed step(s) and their screenshots; fix the script and review again.`
1103
1195
  : "The isolated Build runtime did not become visibly reviewable before the observation window ended.",
package/lib/commands.js CHANGED
@@ -3311,7 +3311,7 @@ export function printHelp() {
3311
3311
  lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
3312
3312
  lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
3313
3313
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3314
- lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load] [--perf] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load; --perf shows only those; kept 30 days)
3314
+ lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
3315
3315
  lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
3316
3316
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3317
3317
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.46",
3
+ "version": "0.3.47",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {