diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 55f54d7c..19c5149c 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -8873,7 +8873,8 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec }); } -function recoverWebObserveAnalyzeTurnDetails(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, analysis: Record): Record { +function recoverWebObserveAnalyzeTurnDetails(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, analysis: Record | null): Record | null { + if (analysis === null || typeof analysis !== "object") return analysis; const sampleMetrics = record(analysis.sampleMetrics); const hasRounds = Array.isArray(sampleMetrics?.rounds) && sampleMetrics.rounds.length > 0; const hasColumns = Array.isArray(sampleMetrics?.turnColumns) && sampleMetrics.turnColumns.length > 0; diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index b560b74a..beb22e25 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -2046,7 +2046,12 @@ import { createInterface } from "node:readline"; const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || process.argv[2] || ".state/web-observe/manual"); const archivePrefix = safeArchivePrefix(process.env.UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX || ""); -const analyzeTailSamples = (() => { const parsed = Number(process.env.UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES); return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 360; })(); +const analyzeTailSamples = (() => { + const raw = process.env.UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES; + if (raw === undefined || raw === null || String(raw).trim() === "") return 360; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 360; +})(); const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON); const dataDir = archivePrefix ? path.join(stateDir, "archive") : stateDir; const dataFile = (name) => path.join(dataDir, archivePrefix ? archivePrefix + "-" + name : name); @@ -3978,6 +3983,8 @@ function buildSampleMetrics(samples, control) { const diagnostics = timeline.filter((item) => item.diagnosticSeen).length; const loading = buildLoadingMetrics(samples, timeline); const sessionRailTitles = buildSessionRailTitleMetrics(samples, timeline); + const reportTurnTimingRows = boundedTurnTimingRowsForReport(turnTiming.rows); + const reportTimeline = boundedRowsForReport(timeline); const rounds = buildRoundMetricSummaries(timeline, promptCommands, { columns: turnTiming.columns, rows: turnTiming.rows, @@ -4053,7 +4060,8 @@ function buildSampleMetrics(samples, control) { traceOrder, rounds, turnColumns: turnTiming.columns, - turnTimingTable: turnTiming.rows, + turnTimingTable: reportTurnTimingRows.rows, + turnTimingTableDisclosure: reportTurnTimingRows.disclosure, turnTimingNonMonotonic, turnTimingElapsedZeroResets, turnTimingTotalElapsedForwardJumps, @@ -4062,7 +4070,29 @@ function buildSampleMetrics(samples, control) { turnTimingRecentUpdateSteps, turnTimingRecentUpdateLargestSteps, turnTimingRecentUpdateResets, - timeline + timeline: reportTimeline.rows, + timelineDisclosure: reportTimeline.disclosure + }; +} + +function boundedRowsForReport(rows) { + const sourceRows = Array.isArray(rows) ? rows : []; + const maxRows = 1200; + const headRows = 120; + if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } }; + const tailRows = Math.max(0, maxRows - headRows); + return { + rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)], + disclosure: { + truncated: true, + totalRows: sourceRows.length, + includedRows: maxRows, + omittedRows: Math.max(0, sourceRows.length - maxRows), + headRows, + tailRows, + policy: "report-bounded-head-tail; summary metrics are computed before truncation", + valuesRedacted: true + } }; } @@ -4408,6 +4438,27 @@ function finalizeLoadingSegment(segment, absentEvent) { }; } +function boundedTurnTimingRowsForReport(rows) { + const sourceRows = Array.isArray(rows) ? rows : []; + const maxRows = 1200; + const headRows = 120; + if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } }; + const tailRows = Math.max(0, maxRows - headRows); + return { + rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)], + disclosure: { + truncated: true, + totalRows: sourceRows.length, + includedRows: maxRows, + omittedRows: Math.max(0, sourceRows.length - maxRows), + headRows, + tailRows, + policy: "report-bounded-head-tail; full anomaly counters are computed before truncation", + valuesRedacted: true + } + }; +} + function buildTurnTimingTable(samples, timeline) { const columns = []; const registry = new Map(); @@ -6408,6 +6459,7 @@ function compactHeartbeat(value) { function renderTurnTimingTable(sampleMetrics) { const columns = Array.isArray(sampleMetrics?.turnColumns) ? sampleMetrics.turnColumns : []; const rows = Array.isArray(sampleMetrics?.turnTimingTable) ? sampleMetrics.turnTimingTable : []; + const disclosure = sampleMetrics?.turnTimingTableDisclosure || null; if (columns.length === 0 || rows.length === 0) return "- 无 turn 时间表。"; const header = ["时间戳"]; for (const column of columns) { @@ -6461,7 +6513,10 @@ function renderTurnTimingTable(sampleMetrics) { const resetLines = recentUpdateResets.length > 0 ? recentUpdateResets.slice(0, 80).map((item) => "- " + (item.columnLabel || item.columnId || "-") + " reset " + (item.fromValue ?? "-") + " -> " + (item.toValue ?? "-") + " delta=" + (item.delta ?? "-") + " sampleDelta=" + (item.sampleDeltaSeconds ?? "-") + " seq " + (item.fromSeq ?? "-") + " -> " + (item.toSeq ?? "-") + " ts " + (item.fromTs || "-") + " -> " + (item.toTs || "-") + " traceId=" + (item.traceId || "-")).join("\n") : "- 未观察到最近更新归零/回落。"; - return lines.join("\n") + "\n\n列说明:\n" + columnLines + "\n\n异常事件(仅报表暴露,不做下游 repair;最近更新按三角波模型检测异常跳增):\n" + nonMonotonicLines + "\n\nTerminal 后总耗时增长事件(终态 turn 的总耗时应 sealed,不应继续增长):\n" + terminalGrowthLines + "\n\n总耗时归零跳变事件(例如已显示真实耗时后又变成 0 秒):\n" + elapsedZeroResetLines + "\n\n总耗时异常前跳事件(预期按采样间隔近似递增;例如 14 秒 -> 1137 秒应被列入这里):\n" + totalElapsedForwardJumpLines + "\n\n最近更新 sawtooth jump 事件(预期每秒增长约 1,遇到新活动归零;例如 1 秒 -> 1 分 4 秒应被列入这里):\n" + sawtoothJumpLines + "\n\n最近更新相邻采样 step(按 delta 降序;用于人工识别一秒跳几十秒/一分钟的瞬态):\n" + stepLines + "\n\n最近更新 reset 事件(预期三角波归零,不计为异常):\n" + resetLines; + const disclosureLine = disclosure?.truncated + ? "表格披露:已按 head/tail 有界输出,totalRows=" + disclosure.totalRows + " includedRows=" + disclosure.includedRows + " omittedRows=" + disclosure.omittedRows + ";异常计数在截断前基于全量采样计算。" + : "表格披露:完整输出当前分析窗口的采样点。"; + return disclosureLine + "\n\n" + lines.join("\n") + "\n\n列说明:\n" + columnLines + "\n\n异常事件(仅报表暴露,不做下游 repair;最近更新按三角波模型检测异常跳增):\n" + nonMonotonicLines + "\n\nTerminal 后总耗时增长事件(终态 turn 的总耗时应 sealed,不应继续增长):\n" + terminalGrowthLines + "\n\n总耗时归零跳变事件(例如已显示真实耗时后又变成 0 秒):\n" + elapsedZeroResetLines + "\n\n总耗时异常前跳事件(预期按采样间隔近似递增;例如 14 秒 -> 1137 秒应被列入这里):\n" + totalElapsedForwardJumpLines + "\n\n最近更新 sawtooth jump 事件(预期每秒增长约 1,遇到新活动归零;例如 1 秒 -> 1 分 4 秒应被列入这里):\n" + sawtoothJumpLines + "\n\n最近更新相邻采样 step(按 delta 降序;用于人工识别一秒跳几十秒/一分钟的瞬态):\n" + stepLines + "\n\n最近更新 reset 事件(预期三角波归零,不计为异常):\n" + resetLines; } function formatMetricCell(value) {