@stage5/lumine 0.3.54 → 0.3.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin-daily.js +76 -3
- package/lib/admin-duties.js +9 -1
- package/lib/admin-owner-trace.js +23 -4
- package/lib/admin-owner-viewport.js +113 -0
- package/lib/admin-spending.js +472 -0
- package/lib/admin.js +46 -7
- package/lib/commands.js +14 -1
- package/lib/sdk.js +2 -2
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +13 -5
- package/sdk/LUMINE_ADMIN.md +174 -74
package/lib/admin-daily.js
CHANGED
|
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
import { adminWorkDirectory } from "./admin-work-directory.js";
|
|
8
8
|
import { readAdminJsonFile, writeAdminJsonFile } from "./admin-news.js";
|
|
9
9
|
import { createDailyReviewState, dailyRunProgress } from "./admin-duties.js";
|
|
10
|
+
import { summarizeOwnerViewport, formatOwnerViewportReport } from "./admin-owner-viewport.js";
|
|
10
11
|
export { dailyRunProgress };
|
|
11
12
|
|
|
12
13
|
// Multi-step conveniences for the full daily management run. Each one runs
|
|
@@ -66,12 +67,15 @@ export function runAdmin(
|
|
|
66
67
|
result.status === 0 && parsed?.ok === true && !error && !result.error;
|
|
67
68
|
const reads = {
|
|
68
69
|
"energy-budget": [null],
|
|
70
|
+
"owner-trace": [null],
|
|
69
71
|
news: [null],
|
|
70
72
|
brief: [null],
|
|
71
73
|
"ai-costs": ["monthly", "day"],
|
|
72
74
|
"media-costs": ["monthly"],
|
|
73
75
|
"reward-activity": [null],
|
|
76
|
+
"reward-diagnostics": [null],
|
|
74
77
|
"reward-review": ["list"],
|
|
78
|
+
review: ["list"],
|
|
75
79
|
approvals: ["list"],
|
|
76
80
|
todo: ["list"],
|
|
77
81
|
followup: ["list"],
|
|
@@ -162,7 +166,7 @@ export function renderBotOutput({ chatMessages, comments }, file) {
|
|
|
162
166
|
fs.writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
|
|
163
167
|
}
|
|
164
168
|
|
|
165
|
-
export async function dailyRunGather(options) {
|
|
169
|
+
export async function dailyRunGather(options, { run = runAdmin } = {}) {
|
|
166
170
|
const startedAt = performance.now();
|
|
167
171
|
const dir = ensureDir(
|
|
168
172
|
path.resolve(options.adminOutputDir || options.adminDir || "."),
|
|
@@ -171,7 +175,7 @@ export async function dailyRunGather(options) {
|
|
|
171
175
|
const steps = [];
|
|
172
176
|
const step = (name, args, file) => {
|
|
173
177
|
const outputFile = path.join(dir, file);
|
|
174
|
-
const res =
|
|
178
|
+
const res = run(args, { outputFile, retryRead: true });
|
|
175
179
|
steps.push({
|
|
176
180
|
name,
|
|
177
181
|
file,
|
|
@@ -195,13 +199,75 @@ export async function dailyRunGather(options) {
|
|
|
195
199
|
["reward-activity", "--date", yesterday, "--days", "7"],
|
|
196
200
|
"reward-activity.json",
|
|
197
201
|
);
|
|
198
|
-
step("reward-
|
|
202
|
+
step("reward-diagnostics", ["reward-diagnostics", "--days", "1"], "reward-diagnostics.json");
|
|
203
|
+
// One pending queue covers every Build approval type. Exhaust its cursor;
|
|
204
|
+
// an empty/malformed receipt must not look like an empty queue.
|
|
205
|
+
const buildReviews = { complete: false, pages: 0, items: 0 };
|
|
206
|
+
let reviewCursor = null;
|
|
207
|
+
const reviewCursors = new Set();
|
|
208
|
+
for (;;) {
|
|
209
|
+
buildReviews.pages += 1;
|
|
210
|
+
const args = ["review", "list", "--status", "pending"];
|
|
211
|
+
if (reviewCursor) args.push("--cursor", reviewCursor);
|
|
212
|
+
const res = step(
|
|
213
|
+
`build-reviews-page-${buildReviews.pages}`,
|
|
214
|
+
args,
|
|
215
|
+
`build-reviews-${buildReviews.pages}.json`,
|
|
216
|
+
);
|
|
217
|
+
if (!res.ok) break;
|
|
218
|
+
const data = res.json?.data;
|
|
219
|
+
if (
|
|
220
|
+
data?.filter !== "pending" ||
|
|
221
|
+
!Array.isArray(data?.items) ||
|
|
222
|
+
!(data.nextCursor === null ||
|
|
223
|
+
(typeof data.nextCursor === "string" && /^\d+(\.\d\.\d+)?$/.test(data.nextCursor)))
|
|
224
|
+
) {
|
|
225
|
+
Object.assign(steps[steps.length - 1], {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: "Invalid pending Build review page: expected filter, items and nextCursor.",
|
|
228
|
+
});
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
buildReviews.items += data.items.length;
|
|
232
|
+
if (data.nextCursor === null) {
|
|
233
|
+
buildReviews.complete = true;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
if (reviewCursors.has(data.nextCursor) || buildReviews.pages >= 100) {
|
|
237
|
+
steps.push({
|
|
238
|
+
name: "build-reviews-pagination",
|
|
239
|
+
ok: false,
|
|
240
|
+
error: reviewCursors.has(data.nextCursor)
|
|
241
|
+
? "Build review cursor repeated before exhaustion."
|
|
242
|
+
: "Build review pagination reached the 100-page limit before exhaustion.",
|
|
243
|
+
});
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
reviewCursors.add(data.nextCursor);
|
|
247
|
+
reviewCursor = data.nextCursor;
|
|
248
|
+
}
|
|
199
249
|
// management approvals (birthdates, teacher signups, meetups) with their
|
|
200
250
|
// flags: a required report section (agent-guides/lumine-admin.md)
|
|
201
251
|
step("approvals-pending", ["approvals", "list", "--status", "pending"], "approvals-pending.json");
|
|
202
252
|
step("todos", ["todo", "list"], "todos.json");
|
|
203
253
|
step("personal-followups", ["followup", "list"], "personal-followups.json");
|
|
204
254
|
step("escalations", ["escalation", "list"], "escalations.json");
|
|
255
|
+
const wake = step("owner-wake", ["owner-trace", "--since", "24h", "--wake", "--limit", "2000"], "owner-wake.json");
|
|
256
|
+
// A phone tab can live longer than the daily window. Retain its version,
|
|
257
|
+
// browser and device context instead of silently calling it unknown.
|
|
258
|
+
const wakeSessions = step("owner-wake-sessions", ["owner-trace", "--since", "30d", "--type", "session", "--limit", "2000"], "owner-wake-sessions.json");
|
|
259
|
+
const ownerWake = summarizeOwnerViewport(
|
|
260
|
+
wake.ok ? wake.json.data : null,
|
|
261
|
+
wakeSessions.ok ? wakeSessions.json.data : null,
|
|
262
|
+
);
|
|
263
|
+
ownerWake.collectionFailures = [
|
|
264
|
+
...(!wake.ok ? ["owner-wake"] : []),
|
|
265
|
+
...(!wakeSessions.ok ? ["owner-wake-sessions"] : []),
|
|
266
|
+
];
|
|
267
|
+
if (ownerWake.collectionFailures.length) ownerWake.coverage = ownerWake.usableSamples ? "partial" : "unavailable";
|
|
268
|
+
writeAdminJsonFile(path.join(dir, "owner-wake-summary.json"), ownerWake, { privateFile: true });
|
|
269
|
+
fs.writeFileSync(path.join(dir, "owner-wake-summary.md"), formatOwnerViewportReport(ownerWake), { mode: 0o600 });
|
|
270
|
+
steps.push({ name: "owner-wake-summary", file: "owner-wake-summary.json", ok: true });
|
|
205
271
|
step("featured-list", ["featured", "list"], "featured-start.json");
|
|
206
272
|
step(
|
|
207
273
|
"sponsor-applications",
|
|
@@ -250,6 +316,13 @@ export async function dailyRunGather(options) {
|
|
|
250
316
|
durationMs: Math.round(performance.now() - startedAt),
|
|
251
317
|
dir,
|
|
252
318
|
yesterdayUtc: yesterday,
|
|
319
|
+
buildReviews,
|
|
320
|
+
ownerWake: {
|
|
321
|
+
coverage: ownerWake.coverage,
|
|
322
|
+
truncated: ownerWake.truncated,
|
|
323
|
+
largeGapCycles: ownerWake.largeGapCycles,
|
|
324
|
+
reportFile: path.join(dir, "owner-wake-summary.md"),
|
|
325
|
+
},
|
|
253
326
|
botOutput: {
|
|
254
327
|
complete: botOk,
|
|
255
328
|
pages,
|
package/lib/admin-duties.js
CHANGED
|
@@ -37,12 +37,18 @@ export function createDailyReviewState(dir, gather, previous = {}) {
|
|
|
37
37
|
read: false,
|
|
38
38
|
});
|
|
39
39
|
for (const source of previous.sources || []) {
|
|
40
|
+
// Keep prior receipts on disk as history, not as current queue evidence or
|
|
41
|
+
// duplicate reading duties after a new (possibly partial) collection.
|
|
42
|
+
if (gather.buildReviews && (
|
|
43
|
+
source.file === "rr-pending.json" || /^build-reviews-\d+\.json$/.test(source.file)
|
|
44
|
+
)) continue;
|
|
40
45
|
if (!sources.some((current) => current.file === source.file)) sources.push({ ...source, retained: true, read: false });
|
|
41
46
|
}
|
|
42
47
|
return {
|
|
43
48
|
protocol: 1,
|
|
44
49
|
scope: previous.scope || "collection",
|
|
45
50
|
sources,
|
|
51
|
+
collectionFailures: gather.failed || [],
|
|
46
52
|
requiredDuties: [...new Set(["effort-levels", "report-browser", ...(previous.requiredDuties || [])])],
|
|
47
53
|
note: "Collection is not a full review. Add every duty required by the run scope and its carryovers. Reading and browser checks require explicit evidence acknowledgments.",
|
|
48
54
|
};
|
|
@@ -150,14 +156,16 @@ export function summarizeDailyProgress(dir, state, evidence = {}) {
|
|
|
150
156
|
const incomplete = duties
|
|
151
157
|
.filter((item) => item.status !== "completed")
|
|
152
158
|
.map((item) => item.id);
|
|
159
|
+
const collectionFailures = state.collectionFailures || [];
|
|
153
160
|
const complete =
|
|
154
|
-
sources.every((item) => item.read) && incomplete.length === 0;
|
|
161
|
+
collectionFailures.length === 0 && sources.every((item) => item.read) && incomplete.length === 0;
|
|
155
162
|
return {
|
|
156
163
|
scope: state.scope || "declared-duties",
|
|
157
164
|
coverageBasis: "declared sources and duties only",
|
|
158
165
|
canonicalRun,
|
|
159
166
|
effortAssignments,
|
|
160
167
|
sources,
|
|
168
|
+
collectionFailures,
|
|
161
169
|
duties,
|
|
162
170
|
complete,
|
|
163
171
|
reading: {
|
package/lib/admin-owner-trace.js
CHANGED
|
@@ -16,12 +16,13 @@ export const OWNER_TRACE_PERF_TYPES = [
|
|
|
16
16
|
"scroll-perf",
|
|
17
17
|
"scroll-frame",
|
|
18
18
|
"wake",
|
|
19
|
+
"wake-viewport",
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
// --wake: the session headers and the wake profiles (what the site did in the
|
|
22
23
|
// 20 s after each load or return to the tab), to compare Wi-Fi and cellular
|
|
23
|
-
// wakes
|
|
24
|
-
export const OWNER_TRACE_WAKE_TYPES = ["session", "wake"];
|
|
24
|
+
// wakes. Server network tags describe the upload, which can follow a switch.
|
|
25
|
+
export const OWNER_TRACE_WAKE_TYPES = ["session", "wake", "wake-viewport", "trace-dropped"];
|
|
25
26
|
|
|
26
27
|
const MAX_LIMIT = 2000;
|
|
27
28
|
|
|
@@ -159,6 +160,8 @@ export function describeOwnerTraceEvent(event) {
|
|
|
159
160
|
return describeScrollFrame(data);
|
|
160
161
|
case "wake":
|
|
161
162
|
return describeWake(data);
|
|
163
|
+
case "wake-viewport":
|
|
164
|
+
return describeViewport(data);
|
|
162
165
|
case "stall":
|
|
163
166
|
return `main thread blocked ${data.ms}ms${data.n > 1 ? ` (${data.n} stalls, ${data.total}ms blocked over ${data.span}ms)` : ""}`;
|
|
164
167
|
case "page-load": {
|
|
@@ -179,13 +182,29 @@ export function describeOwnerTraceEvent(event) {
|
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
|
|
182
|
-
// The network an upload came from: v4/v6 plus an opaque prefix hash.
|
|
183
|
-
//
|
|
185
|
+
// The network an upload came from: v4/v6 plus an opaque prefix hash. This does
|
|
186
|
+
// not establish whether the device used Wi-Fi or cellular when it captured.
|
|
184
187
|
function describeNet(net) {
|
|
185
188
|
if (!net?.id) return "";
|
|
186
189
|
return `net v${net.v}:${net.id}`;
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
function describeViewport(data) {
|
|
193
|
+
if (data.error) return `geometry unavailable (${data.error})`;
|
|
194
|
+
const visual = isPlainObject(data.visual) ? data.visual : {};
|
|
195
|
+
const nav = isPlainObject(data.nav) ? data.nav : {};
|
|
196
|
+
const focus = isPlainObject(data.focus) ? data.focus : {};
|
|
197
|
+
return [
|
|
198
|
+
`v${data.version || "?"} cycle ${data.cycle ?? "?"} ${data.phase || "?"} +${data.elapsedMs ?? "?"}ms`,
|
|
199
|
+
`window ${data.window?.height ?? "?"} root ${data.root?.height ?? "?"}`,
|
|
200
|
+
`visual ${visual.height ?? "?"} top ${visual.offsetTop ?? "?"} scale ${visual.scale ?? "?"}`,
|
|
201
|
+
`nav bottom ${nav.bottom ?? "?"} (${nav.position || "absent"})`,
|
|
202
|
+
`inset ${data.inset ?? "?"} focus ${focus.tag || "none"}${focus.type ? `:${focus.type}` : ""}`,
|
|
203
|
+
`scroll ${data.root?.scrollTop ?? "?"}`,
|
|
204
|
+
data._trimmed ? `trimmed ${JSON.stringify(data._trimmed)}` : "",
|
|
205
|
+
].filter(Boolean).join(" · ");
|
|
206
|
+
}
|
|
207
|
+
|
|
189
208
|
// What the site did in the window after a load or a return to the tab.
|
|
190
209
|
// A field the server cut to "[cut N chars]" arrives as a string: every list or
|
|
191
210
|
// object is checked before use, so one trimmed row never breaks the output.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Read-only interpretation of bounded wake geometry. Large gaps are leads,
|
|
2
|
+
// never a diagnosis or evidence that a browser workaround is safe.
|
|
3
|
+
export function summarizeOwnerViewport(data, sessionData = data) {
|
|
4
|
+
const events = Array.isArray(data?.events) ? data.events : [];
|
|
5
|
+
const sessions = new Map(
|
|
6
|
+
[...(sessionData?.events || []), ...events].filter(e => e.type === "session").map(e => [e.sessionId, e.data || {}]),
|
|
7
|
+
);
|
|
8
|
+
const cycles = new Map();
|
|
9
|
+
const usableBySession = new Map();
|
|
10
|
+
let unavailable = 0;
|
|
11
|
+
let dropped = 0;
|
|
12
|
+
for (const event of events) {
|
|
13
|
+
if (event.type === "trace-dropped") dropped += Number(event.data?.count) || 0;
|
|
14
|
+
if (event.type !== "wake-viewport") continue;
|
|
15
|
+
const d = event.data || {};
|
|
16
|
+
if (d.error || d.v !== 1 || d._trimmed || !d.window || !d.root || !d.focus) {
|
|
17
|
+
unavailable += 1;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
usableBySession.set(event.sessionId, (usableBySession.get(event.sessionId) || 0) + 1);
|
|
21
|
+
const key = `${event.sessionId}:${d.cycle}`;
|
|
22
|
+
const header = sessions.get(event.sessionId);
|
|
23
|
+
let cycle = cycles.get(key);
|
|
24
|
+
if (!cycle) {
|
|
25
|
+
cycle = {
|
|
26
|
+
sessionId: event.sessionId, cycle: d.cycle, version: d.version || header?.version || "unknown",
|
|
27
|
+
platform: header?.platform || "unknown", ua: header?.ua || null,
|
|
28
|
+
sessionHeaderAvailable: Boolean(header), samples: 0, settledSamples: 0,
|
|
29
|
+
largeGapSamples: 0, backgroundSamples: 0, lastScheduledMs: 0,
|
|
30
|
+
maxRootGap: null, maxVisualGap: null, firstLargeGapMs: null, lastLargeGapMs: null, examples: [],
|
|
31
|
+
};
|
|
32
|
+
cycles.set(key, cycle);
|
|
33
|
+
}
|
|
34
|
+
cycle.samples += 1;
|
|
35
|
+
if (d.phase === "background") cycle.backgroundSamples += 1;
|
|
36
|
+
cycle.lastScheduledMs = Math.max(cycle.lastScheduledMs, Number(d.scheduledMs) || 0);
|
|
37
|
+
if (d.phase !== "settle" || d.scheduledMs < 1000 || d.visibility !== "visible") continue;
|
|
38
|
+
cycle.settledSamples += 1;
|
|
39
|
+
const nav = d.nav;
|
|
40
|
+
// The desktop header is fixed at the TOP. Only a nav anchored to bottom:0
|
|
41
|
+
// can support this candidate. Keyboard/zoom/hidden states stay in raw data.
|
|
42
|
+
if (!nav || nav.position !== "fixed" || parseFloat(nav.bottomStyle) !== 0 || nav.height <= 0 || nav.display === "none" ||
|
|
43
|
+
nav.visibility === "hidden" || nav.transform !== "none" ||
|
|
44
|
+
!d.visual || !Number.isFinite(d.visual.scale) || Math.abs(d.visual.scale - 1) > 0.01 ||
|
|
45
|
+
d.focus.editable || ["input", "textarea", "iframe"].includes(d.focus.tag) || d.inset !== 0) continue;
|
|
46
|
+
if (![nav.bottom, d.root.height, d.visual.height, d.visual.offsetTop, d.elapsedMs].every(Number.isFinite)) continue;
|
|
47
|
+
const rootGap = Math.round(d.root.height - nav.bottom);
|
|
48
|
+
const visualGap = Math.round(d.visual.height + d.visual.offsetTop - nav.bottom);
|
|
49
|
+
cycle.maxRootGap = Math.max(cycle.maxRootGap ?? -Infinity, rootGap);
|
|
50
|
+
cycle.maxVisualGap = Math.max(cycle.maxVisualGap ?? -Infinity, visualGap);
|
|
51
|
+
if (Math.max(rootGap, visualGap) >= 100) {
|
|
52
|
+
cycle.largeGapSamples += 1;
|
|
53
|
+
cycle.firstLargeGapMs = Math.min(cycle.firstLargeGapMs ?? Infinity, d.elapsedMs);
|
|
54
|
+
cycle.lastLargeGapMs = Math.max(cycle.lastLargeGapMs ?? -Infinity, d.elapsedMs);
|
|
55
|
+
if (cycle.examples.length < 3) cycle.examples.push({
|
|
56
|
+
seq: event.seq, clientTime: event.clientTime, path: event.path,
|
|
57
|
+
elapsedMs: d.elapsedMs, rootGap, visualGap,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const rows = [...cycles.values()];
|
|
62
|
+
const viewportEvents = events.filter(e => e.type === "wake-viewport").length;
|
|
63
|
+
const clients = [...new Set(events.map(e => e.sessionId))].map(sessionId => {
|
|
64
|
+
const own = events.filter(e => e.sessionId === sessionId);
|
|
65
|
+
const header = sessions.get(sessionId);
|
|
66
|
+
return {
|
|
67
|
+
sessionId, version: header?.version || own.find(e => e.type === "wake-viewport")?.data?.version || "unknown",
|
|
68
|
+
platform: header?.platform || "unknown",
|
|
69
|
+
viewportSupport: header ? header.viewportTrace === 1 : null,
|
|
70
|
+
geometrySamples: own.filter(e => e.type === "wake-viewport").length,
|
|
71
|
+
usableSamples: usableBySession.get(sessionId) || 0,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
const missingHeaders = rows.filter(c => !c.sessionHeaderAvailable).length;
|
|
75
|
+
const clientsWithoutGeometry = clients.filter(c => !c.usableSamples).length;
|
|
76
|
+
const usableSamples = viewportEvents - unavailable;
|
|
77
|
+
const partial = Boolean(data?.truncated || sessionData?.truncated || unavailable || dropped || missingHeaders || clientsWithoutGeometry);
|
|
78
|
+
return {
|
|
79
|
+
schema: 1, since: data?.since ?? null,
|
|
80
|
+
coverage: !usableSamples ? "unavailable" : partial ? "partial" : "observed",
|
|
81
|
+
truncated: Boolean(data?.truncated), sessionHeadersTruncated: Boolean(sessionData?.truncated),
|
|
82
|
+
viewportEvents, usableSamples, unavailableSamples: unavailable, droppedEvents: dropped,
|
|
83
|
+
wakeProfiles: events.filter(e => e.type === "wake").length,
|
|
84
|
+
clients, clientsWithoutGeometry,
|
|
85
|
+
cycles: rows, cyclesMissingHeaders: missingHeaders,
|
|
86
|
+
cyclesReaching20s: rows.filter(c => c.lastScheduledMs >= 20_000).length,
|
|
87
|
+
largeGapCycles: rows.filter(c => c.largeGapSamples > 0).length,
|
|
88
|
+
// Timers can all be released together after a stall. Scheduled times alone
|
|
89
|
+
// do not establish separate observations; require actual time separation.
|
|
90
|
+
repeatedLargeGapCycles: rows.filter(c => c.largeGapSamples >= 2 && c.lastLargeGapMs - c.firstLargeGapMs >= 500).length,
|
|
91
|
+
note: "A >=100px settled gap is an investigation lead, not a proven defect. Normal browser chrome, viewport offsets and rendering state still require comparison. No samples means unavailable, not healthy. Wake network tags identify the upload network, not necessarily the network at capture.",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatOwnerViewportReport(summary) {
|
|
96
|
+
const lines = [
|
|
97
|
+
"# Owner wake viewport follow-up", "",
|
|
98
|
+
`Coverage: **${summary.coverage}**. ${summary.usableSamples} usable geometry samples (${summary.viewportEvents} raw events) across ${summary.cycles.length} load/return cycles; ${summary.cyclesReaching20s} reached the 20-second sample.`, "",
|
|
99
|
+
`Observed clients: ${[...new Set(summary.clients.map(c => `${c.version} (${c.platform}; geometry support ${c.viewportSupport === null ? "unknown" : c.viewportSupport ? "yes" : "no"})`))].join(", ") || "none"}.`, "",
|
|
100
|
+
`Collection failures: ${summary.collectionFailures?.join(", ") || "none"}.`, "",
|
|
101
|
+
`Large-gap candidates: ${summary.largeGapCycles} cycles; ${summary.repeatedLargeGapCycles} had settled bottom-nav gaps >=100px at least 500ms apart in actual time, no focused input, no app keyboard inset and scale 1.`, "",
|
|
102
|
+
`Missing/trimmed geometry: ${summary.unavailableSamples}; clients without usable geometry: ${summary.clientsWithoutGeometry}; reported dropped events: ${summary.droppedEvents}; cycles without session context: ${summary.cyclesMissingHeaders}; truncated trace: ${summary.truncated}; truncated session history: ${summary.sessionHeadersTruncated}.`, "",
|
|
103
|
+
summary.note, "",
|
|
104
|
+
"Read owner-wake.json and owner-wake-sessions.json alongside this summary. Compare affected and ordinary cycles within the same client/browser version. Distinguish viewport height changes, viewport panning and nav displacement; correlate the wake performance profile and its upload network tag. Record the evidence and next action in the daily report even when coverage is unavailable.", "",
|
|
105
|
+
"| Client | Platform | Cycle | Samples | Settled | Large gap | Max root gap | Max visual gap |",
|
|
106
|
+
"| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
|
107
|
+
];
|
|
108
|
+
for (const c of summary.cycles) {
|
|
109
|
+
const safe = v => String(v).replace(/[|\r\n]/g, " ");
|
|
110
|
+
lines.push(`| ${safe(c.version)} | ${safe(c.platform)} | ${safe(c.sessionId).slice(0, 8)}:${c.cycle} | ${c.samples} | ${c.settledSamples} | ${c.largeGapSamples} | ${c.maxRootGap ?? "unavailable"} | ${c.maxVisualGap ?? "unavailable"} |`);
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n") + "\n";
|
|
113
|
+
}
|