fix: split web sentinel ambiguous check codes
This commit is contained in:
@@ -939,3 +939,67 @@ sentinel:
|
||||
actionZh: 先修复目标 node/lane 的 Web 访问或 Workbench 壳加载,再重新启动观察。
|
||||
blocking: true
|
||||
order: 1150
|
||||
- code: WBC-116
|
||||
id: account-session-command-failed-before-observation
|
||||
level: error
|
||||
titleZh: 账号/会话准备命令失败
|
||||
summaryZh: 快速验证的账号、会话或 Workbench 准备命令在业务轮次观察前失败。
|
||||
actionZh: 查看首个失败命令的 type、phase、status、stdout/stderr 摘要和 artifact 引用。
|
||||
blocking: true
|
||||
order: 1160
|
||||
- code: WBC-117
|
||||
id: observer-graceful-stop-timeout-force-stop-succeeded
|
||||
level: warning
|
||||
titleZh: 观察优雅停止超时但强停成功
|
||||
summaryZh: 观察程序优雅停止未及时完成,但强制停止已确认进程不再存活。
|
||||
actionZh: 作为清理耗时和遗留命令上下文记录;只有强停后仍存活才按阻塞处理。
|
||||
blocking: false
|
||||
order: 1170
|
||||
- code: WBC-118
|
||||
id: observer-force-stop-left-process-alive
|
||||
level: error
|
||||
titleZh: 观察强停后进程仍存活
|
||||
summaryZh: 观察程序强制停止后仍有存活证据,可能留下浏览器或 runner 进程。
|
||||
actionZh: 检查 observe stop/force-stop 输出和目标节点进程树,清理后再恢复周期巡检。
|
||||
blocking: true
|
||||
order: 1180
|
||||
- code: WBC-119
|
||||
id: observer-cleanup-evidence-missing
|
||||
level: error
|
||||
titleZh: 观察清理证据缺失
|
||||
summaryZh: 观察停止状态缺少 aliveAfter、term/kill 或命令结果证据,不能判定是否真正停止。
|
||||
actionZh: 先修复 observe stop 证据采集或 report 索引回填,再判断是否有进程泄漏。
|
||||
blocking: true
|
||||
order: 1190
|
||||
- code: WBC-120
|
||||
id: observer-cleanup-index-mismatch
|
||||
level: error
|
||||
titleZh: 观察清理索引不一致
|
||||
summaryZh: 原始 cleanup payload 与 report/indexed finding 对同一次停止结果的分类不一致。
|
||||
actionZh: 对齐 cleanup payload、recordRunResult 和 report visible findings 的同一证据来源。
|
||||
blocking: true
|
||||
order: 1200
|
||||
- code: WBC-121
|
||||
id: no-samples-after-observer-command-failed
|
||||
level: error
|
||||
titleZh: 命令失败后无采样
|
||||
summaryZh: 本次观察没有样本,且已有阻塞性的页面控制命令失败作为确定前置条件。
|
||||
actionZh: 优先处理 WBC-008 的首个失败命令,再重新启动采样。
|
||||
blocking: true
|
||||
order: 1210
|
||||
- code: WBC-122
|
||||
id: no-samples-artifact-evidence-missing
|
||||
level: error
|
||||
titleZh: 无采样且证据不足
|
||||
summaryZh: 本次观察没有样本,也没有可归因的命令失败,属于采样或 artifact 证据缺口。
|
||||
actionZh: 检查 observer start、samples.jsonl、analysis/report.json 和 report fallback 读取路径。
|
||||
blocking: true
|
||||
order: 1220
|
||||
- code: WBC-123
|
||||
id: observer-command-failure-evidence-missing
|
||||
level: error
|
||||
titleZh: 页面操作失败证据缺失
|
||||
summaryZh: 报告称页面控制命令失败,但缺少首个失败命令的 type、phase、status 或输出摘要。
|
||||
actionZh: 修复 analyzer/report 对 commandFailures 的 compact 输出,先补齐命令证据再判断页面业务状态。
|
||||
blocking: true
|
||||
order: 1230
|
||||
|
||||
@@ -37,7 +37,7 @@ Web 哨兵 dashboard/API 展示问题的第一事实源是 sentinel runner 的 `
|
||||
|
||||
Web 哨兵 findings 可见性要同时核对 runner API 和已有 observe artifact。若某个 run 的公开 `/api/report?view=findings&run=<id>` 只显示 WBC-003,但 `web-probe sentinel report --run <id> --view findings --raw` 能从 `analysis/report.json` 读出 red/amber analyzer findings,根因是索引或 artifact 可见性遮盖,不是业务没有产生 warning/error。此时应回填或重建这条既有 run 的 report index,并保留原有 report views;不要通过启动新的哨兵 run 来解释旧记录。
|
||||
|
||||
Web 哨兵 check code 是排障和验收合同,必须保持语义单一且确定。一个 code/id 只能对应一种可执行处置路径;如果一个 finding 可能同时表示“没有采集到业务轮次”“目标轮次缺 traceId”“trace rows/projection 缺失”“Final Response 为空但仍在运行/取消”“Final Response 为空且已失败/终止”等多种状态,就必须拆成多个固定 code/id。不得用同一个 code 的动态标题、动态 summary 或 rootCause 文案来承载多种互斥语义;动态字段只能补充证据,不能改变 code 的语义。
|
||||
Web 哨兵 check code 是排障和验收合同,必须保持语义单一且确定。一个 code/id 只能对应一种可执行处置路径;如果一个 finding 可能同时表示“没有采集到业务轮次”“目标轮次缺 traceId”“trace rows/projection 缺失”“Final Response 为空但仍在运行/取消”“Final Response 为空且已失败/终止”等多种状态,就必须拆成多个固定 code/id。不得用同一个 code 的动态标题、动态 summary 或 rootCause 文案来承载多种互斥语义;动态字段只能补充证据,不能改变 code 的语义。`quick-verify` 只能作为运行阶段或证据上下文,不能作为 root-cause code 的宽泛语义;账号/会话命令失败、observer start/stop/cleanup、turn-summary、trace-frame、report analyze、无采样和 artifact/index 缺口必须各自用固定 code 表示。
|
||||
|
||||
Web 哨兵请求频率曲线的验收事实源是 runner `/api/runs/{id}.requestRate`、已有 observe artifact 中的 request-rate summary,以及 `web-probe sentinel dashboard screenshot` 的远程浏览器证据。阈值、采样间隔、bucket 大小和红黄线只从 YAML/source-of-truth 读取,长期文档只记录字段族与验证入口。验收时应核对 `bucketSeconds`、总请求曲线、页面曲线、API path 曲线、峰值每分钟计数、数据来源和 chart/DOM 是否显示在内存曲线上方并共享时间轴。若 quick-verify 的业务链路失败,但同一 run 的 `requestRate` API 和截图已经有曲线数据,应把请求频率能力验收与业务阻塞分开记录;反之,`requestRate.source=unavailable` 或曲线为空时要继续检查 analyzer compact 输出、artifact summary、索引回填和 report fallback。除非 `dashboard verify` 已显式输出 request-rate 专用字段,不得把旧的 `API_PAGES` / `API_SAMPLES` 列当作请求频率曲线验收结果。
|
||||
|
||||
|
||||
@@ -5,7 +5,16 @@ export function nodeWebObserveAnalyzerFindingsSource(): string {
|
||||
return String.raw`function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = null, browserProcess = null, frontendPerformance = null) {
|
||||
const findings = [];
|
||||
const effectiveApiDomLag = apiDomLag || buildApiDomLagReport(samples, network);
|
||||
if (commandFailures.length > 0) findings.push({ id: "observer-command-failed", severity: "red", summary: "observer control commands failed; analyze must surface command failure instead of hiding it in command artifacts", count: commandFailures.length, commands: commandFailures.slice(0, 20) });
|
||||
const compactCommandFailures = commandFailures.map(compactObserverCommandFailure);
|
||||
const firstCommandFailure = compactCommandFailures[0] || null;
|
||||
if (commandFailures.length > 0) findings.push({
|
||||
id: "observer-command-failed",
|
||||
severity: "red",
|
||||
summary: "observer control commands failed; analyze must surface the first failed command with phase/status/stdout/stderr evidence.",
|
||||
count: commandFailures.length,
|
||||
firstCommandFailure,
|
||||
commands: compactCommandFailures.slice(0, 20)
|
||||
});
|
||||
findings.push(...buildFrontendFreezeFindings(errors, control));
|
||||
findings.push(...buildBrowserProcessFindings(browserProcess, runtimeAlerts));
|
||||
findings.push(...buildFrontendPerformanceFindings(frontendPerformance));
|
||||
@@ -386,10 +395,56 @@ export function nodeWebObserveAnalyzerFindingsSource(): string {
|
||||
groups: Array.isArray(effectiveApiDomLag?.groups) ? effectiveApiDomLag.groups.slice(0, 12) : [],
|
||||
});
|
||||
if (errors.length > 0) findings.push({ id: "browser-console-or-page-errors", severity: "amber", summary: "pageerror/runner errors were captured", count: errors.length, first: errors.slice(0, 5) });
|
||||
if (samples.length === 0) findings.push({ id: "no-samples", severity: "red", summary: "observer produced no samples" });
|
||||
if (samples.length === 0) {
|
||||
if (commandFailures.length > 0) {
|
||||
findings.push({
|
||||
id: "no-samples-after-observer-command-failed",
|
||||
severity: "red",
|
||||
summary: "observer produced zero samples after a blocking control command failed; the command failure is the deterministic precondition.",
|
||||
count: 1,
|
||||
firstCommandFailure,
|
||||
rootCause: "observer-command-failed-before-sampling",
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
evidenceSummary: firstCommandFailure ? "command=" + (firstCommandFailure.commandId || "-") + " type=" + (firstCommandFailure.type || "-") + " phase=" + (firstCommandFailure.phase || "-") + " status=" + (firstCommandFailure.status || "-") + " exit=" + (firstCommandFailure.exitCode ?? "-") : "commandFailures>0",
|
||||
valuesRedacted: true
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
id: "no-samples-artifact-evidence-missing",
|
||||
severity: "red",
|
||||
summary: "observer produced zero samples and analyzer has no failed command precondition; sampling/source evidence is missing.",
|
||||
count: 1,
|
||||
rootCause: "sample-artifact-evidence-missing",
|
||||
rootCauseStatus: "evidence-gap",
|
||||
rootCauseConfidence: "medium",
|
||||
evidenceSummary: "samples=0 control=" + (Array.isArray(control) ? control.length : 0) + " errors=" + (Array.isArray(errors) ? errors.length : 0),
|
||||
valuesRedacted: true
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function compactObserverCommandFailure(item) {
|
||||
const result = item && typeof item.result === "object" ? item.result : {};
|
||||
const output = item && typeof item.output === "object" ? item.output : {};
|
||||
const stdout = item?.stdoutPreview ?? item?.stdoutTail ?? result.stdoutPreview ?? result.stdoutTail ?? output.stdoutPreview ?? output.stdoutTail ?? null;
|
||||
const stderr = item?.stderrPreview ?? item?.stderrTail ?? result.stderrPreview ?? result.stderrTail ?? output.stderrPreview ?? output.stderrTail ?? null;
|
||||
return {
|
||||
commandId: item?.commandId ?? item?.id ?? item?.command_id ?? null,
|
||||
type: item?.type ?? item?.commandType ?? item?.command_type ?? null,
|
||||
phase: item?.phase ?? item?.statusPhase ?? null,
|
||||
status: item?.status ?? item?.resultStatus ?? null,
|
||||
exitCode: item?.exitCode ?? result.exitCode ?? null,
|
||||
timedOut: item?.timedOut === true || result.timedOut === true,
|
||||
stdoutPreview: stdout == null ? null : String(stdout).slice(0, 240),
|
||||
stderrPreview: stderr == null ? null : String(stderr).slice(0, 240),
|
||||
artifactRef: item?.artifactRef ?? item?.artifactPath ?? item?.path ?? null,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function buildCrossPageProjectionDrilldown(rows) {
|
||||
const sourceRows = Array.isArray(rows) ? rows.filter(Boolean) : [];
|
||||
const traceIds = uniqueSorted(sourceRows.flatMap((row) => collectIdsFromUnknown(row, "trace")).filter(Boolean)).slice(0, 12);
|
||||
|
||||
@@ -37,10 +37,13 @@ test("quick verify cleanup downgrades abandoned graceful stop when force stop co
|
||||
assert.equal(cleanup.forceStopOk, true);
|
||||
assert.equal(cleanup.aliveAfter, false);
|
||||
assert.equal(cleanup.pendingAbandoned, 1);
|
||||
assert.deepEqual(quickVerifyCleanupFindings(step), []);
|
||||
const findings = quickVerifyCleanupFindings(step);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0]?.id, "observer-graceful-stop-timeout-force-stop-succeeded");
|
||||
assert.equal(findings[0]?.severity, "warning");
|
||||
});
|
||||
|
||||
test("quick verify cleanup emits WBC-096 when force stop leaves observer alive", () => {
|
||||
test("quick verify cleanup emits process-alive code when force stop leaves observer alive", () => {
|
||||
const step = forceStopStep({
|
||||
aliveBefore: true,
|
||||
aliveAfter: true,
|
||||
@@ -56,11 +59,11 @@ test("quick verify cleanup emits WBC-096 when force stop leaves observer alive",
|
||||
assert.equal(cleanup.forceStopOk, false);
|
||||
assert.equal(cleanup.aliveAfter, true);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0]?.id, "quick-verify-observer-stop-failed");
|
||||
assert.equal(findings[0]?.id, "observer-force-stop-left-process-alive");
|
||||
assert.match(String(findings[0]?.evidenceSummary), /aliveAfter=true/u);
|
||||
});
|
||||
|
||||
test("quick verify cleanup emits WBC-096 when stopped state cannot be confirmed", () => {
|
||||
test("quick verify cleanup emits evidence-gap code when stopped state cannot be confirmed", () => {
|
||||
const step = forceStopStep({
|
||||
aliveBefore: true,
|
||||
termSent: true,
|
||||
@@ -74,5 +77,5 @@ test("quick verify cleanup emits WBC-096 when stopped state cannot be confirmed"
|
||||
assert.equal(cleanup.cleanupClass, "observer-stop-failed");
|
||||
assert.equal(cleanup.aliveAfter, null);
|
||||
assert.equal(findings.length, 1);
|
||||
assert.equal(findings[0]?.id, "quick-verify-observer-stop-failed");
|
||||
assert.equal(findings[0]?.id, "observer-cleanup-evidence-missing");
|
||||
});
|
||||
|
||||
@@ -647,12 +647,30 @@ function stopQuickVerifyObserver(state: SentinelCicdState, observerId: string, p
|
||||
|
||||
export function quickVerifyCleanupFindings(cleanupStep: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const cleanup = classifyQuickVerifyCleanupStep(cleanupStep);
|
||||
if (stringAtNullable(cleanup, "cleanupClass") !== "observer-stop-failed") return [];
|
||||
const cleanupClass = stringAtNullable(cleanup, "cleanupClass");
|
||||
if (cleanupClass === "bounded-force-stop") {
|
||||
return [{
|
||||
id: "observer-graceful-stop-timeout-force-stop-succeeded",
|
||||
severity: "warning",
|
||||
count: 1,
|
||||
summary: "observer graceful stop did not drain before timeout, but force stop confirmed aliveAfter=false.",
|
||||
cleanupPhase: cleanupStep.phase,
|
||||
cleanup,
|
||||
evidenceSummary: formatQuickVerifyCleanupLine(cleanup),
|
||||
valuesRedacted: true,
|
||||
}];
|
||||
}
|
||||
if (cleanupClass !== "observer-stop-failed") return [];
|
||||
const aliveAfter = booleanAtNullable(cleanup, "aliveAfter");
|
||||
const id = aliveAfter === true ? "observer-force-stop-left-process-alive" : "observer-cleanup-evidence-missing";
|
||||
const summary = aliveAfter === true
|
||||
? "observer force stop completed but process liveness evidence still reports aliveAfter=true."
|
||||
: "observer cleanup failed because stop-state evidence is missing or incomplete; this is an evidence gap, not confirmed process leakage.";
|
||||
return [{
|
||||
id: "quick-verify-observer-stop-failed",
|
||||
id,
|
||||
severity: "red",
|
||||
count: 1,
|
||||
summary: "quick verify completed target analysis but failed to stop its observer runner; this can leak Chrome process trees on cadence runs.",
|
||||
summary,
|
||||
cleanupPhase: cleanupStep.phase,
|
||||
cleanup,
|
||||
evidenceSummary: formatQuickVerifyCleanupLine(cleanup),
|
||||
@@ -1993,9 +2011,11 @@ function isQuickVerifyBlockingFinding(item: Record<string, unknown>): boolean {
|
||||
"quick-verify-final-response-empty-status-unknown",
|
||||
"quick-verify-diagnostics-inconclusive",
|
||||
"quick-verify-analysis-summary-unreadable",
|
||||
"quick-verify-command-sequence-failed",
|
||||
"account-session-command-failed-before-observation",
|
||||
"quick-verify-observer-start-failed",
|
||||
"quick-verify-observer-stop-failed",
|
||||
"observer-force-stop-left-process-alive",
|
||||
"observer-cleanup-evidence-missing",
|
||||
"observer-cleanup-index-mismatch",
|
||||
"quick-verify-account-secret-missing",
|
||||
"prompt-chat-submit-failed",
|
||||
"workbench-turn-state-triad-inconsistent",
|
||||
@@ -2004,6 +2024,9 @@ function isQuickVerifyBlockingFinding(item: Record<string, unknown>): boolean {
|
||||
"round-completion-final-response-missing",
|
||||
"turn-trace-id-missing",
|
||||
"no-samples",
|
||||
"no-samples-after-observer-command-failed",
|
||||
"no-samples-artifact-evidence-missing",
|
||||
"observer-command-failure-evidence-missing",
|
||||
"jsonl-read-issues",
|
||||
].includes(id);
|
||||
}
|
||||
@@ -2123,15 +2146,22 @@ function quickVerifyControlFindings(failure: string | null, promptIndex: number,
|
||||
const observerStartFailure = failure === "observe-start-failed";
|
||||
const displayTitleZh = stringAtNullable(display ?? {}, "titleZh");
|
||||
return [{
|
||||
id: observerStartFailure ? "quick-verify-observer-start-failed" : "quick-verify-command-sequence-failed",
|
||||
id: observerStartFailure ? "quick-verify-observer-start-failed" : "account-session-command-failed-before-observation",
|
||||
severity: "red",
|
||||
count: 1,
|
||||
summary: displayTitleZh ?? (observerStartFailure
|
||||
? "quick verify observer failed to start before the no-prompt scenario could run."
|
||||
: "quick verify no-prompt command sequence failed before the account/session workflow completed."),
|
||||
: "account/session setup command failed before any business turn could be observed."),
|
||||
displayTitleZh,
|
||||
errorTitleZh: displayTitleZh,
|
||||
timeoutDisplay: display,
|
||||
rootCause: observerStartFailure ? "observer-start-failed" : `account-session-command-stage-failed:${failure}`,
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
evidenceSummary: observerStartFailure ? `failure=${failure}` : `stage=account-session-command failure=${failure}`,
|
||||
nextAction: observerStartFailure
|
||||
? "Inspect observer start output and fix the reported degradedReason before interpreting samples."
|
||||
: "Inspect the first failed account/session/workbench command and rerun quick verify after that command succeeds.",
|
||||
failure,
|
||||
promptIndex,
|
||||
valuesRedacted: true,
|
||||
|
||||
@@ -264,7 +264,7 @@ function compactSentinelReportRawPayload(
|
||||
const artifact = record(artifactSummary);
|
||||
const findings = Array.isArray(body.findings) ? body.findings.map(record) : [];
|
||||
const artifactFindings = Array.isArray(artifact.findings) ? artifact.findings.map(record) : [];
|
||||
const visibleFindings = mergeSentinelReportFindings(findings, artifactFindings);
|
||||
const visibleFindings = reclassifySentinelReportFindings(state, run, mergeSentinelReportFindings(findings, artifactFindings));
|
||||
const offlineReclassify = offlineQuickVerifyReclassify(state, run, visibleFindings);
|
||||
const storedFindingCount = numberAtNullable(run, "finding_count") ?? numberAtNullable(run, "findingCount") ?? findings.length;
|
||||
const artifactFindingCount = numberAtNullable(artifact, "findingCount") ?? artifactFindings.length;
|
||||
@@ -337,7 +337,14 @@ function compactSentinelReportRawPayload(
|
||||
}
|
||||
|
||||
function offlineQuickVerifyReclassify(state: SentinelCicdState, run: Record<string, unknown>, findings: readonly Record<string, unknown>[]): Record<string, unknown> | null {
|
||||
const hasLegacyQuickVerifyControl = findings.some((item) => sentinelReportFindingIdentityCandidates(item).includes("quick-verify-no-business-turn"));
|
||||
const hasLegacyQuickVerifyControl = findings.some((item) => sentinelReportFindingIdentityCandidates(item).some((id) => [
|
||||
"quick-verify-no-business-turn",
|
||||
"quick-verify-command-sequence-failed",
|
||||
"account-session-command-failed-before-observation",
|
||||
"no-samples",
|
||||
"no-samples-after-observer-command-failed",
|
||||
"no-samples-artifact-evidence-missing",
|
||||
].includes(id)));
|
||||
if (!hasLegacyQuickVerifyControl) return null;
|
||||
const catalog = sentinelReportCheckCatalogById(state);
|
||||
const result = reclassifyQuickVerifyControlFindings(state, {
|
||||
@@ -357,6 +364,114 @@ function offlineQuickVerifyReclassify(state: SentinelCicdState, run: Record<stri
|
||||
};
|
||||
}
|
||||
|
||||
function reclassifySentinelReportFindings(state: SentinelCicdState, run: Record<string, unknown>, findings: readonly Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
const hasObserverCommandFailure = findings.some((item) => sentinelReportFindingIdentityCandidates(item).includes("observer-command-failed"));
|
||||
const catalog = sentinelReportCheckCatalogById(state);
|
||||
return findings.map((item) => enrichSentinelReportFindingWithCatalog(reclassifySentinelReportFinding(rowFailure(run), item, hasObserverCommandFailure), catalog));
|
||||
}
|
||||
|
||||
function rowFailure(row: Record<string, unknown>): string | null {
|
||||
return stringAtNullable(record(row.summary), "failure")
|
||||
?? stringAtNullable(row, "failure")
|
||||
?? stringAtNullable(record(row.businessStatus), "failure");
|
||||
}
|
||||
|
||||
function reclassifySentinelReportFinding(failure: string | null, item: Record<string, unknown>, hasObserverCommandFailure: boolean): Record<string, unknown> {
|
||||
const ids = sentinelReportFindingIdentityCandidates(item);
|
||||
if (ids.includes("quick-verify-command-sequence-failed")) {
|
||||
return retagSentinelReportFinding(item, "account-session-command-failed-before-observation", {
|
||||
summary: "account/session setup command failed before any business turn could be observed.",
|
||||
rootCause: `account-session-command-stage-failed:${failure ?? "unknown"}`,
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
evidenceSummary: `stage=account-session-command failure=${failure ?? "-"}`,
|
||||
nextAction: "Inspect the first failed account/session/workbench command and rerun quick verify after that command succeeds.",
|
||||
});
|
||||
}
|
||||
if (ids.includes("observer-command-failed") && !hasCommandFailureEvidence(item)) {
|
||||
return retagSentinelReportFinding(item, "observer-command-failure-evidence-missing", {
|
||||
summary: "report says observer control commands failed, but the first failed command evidence is missing.",
|
||||
rootCause: "observer-command-failure-evidence-missing",
|
||||
rootCauseStatus: "evidence-gap",
|
||||
rootCauseConfidence: "high",
|
||||
evidenceSummary: "firstCommandFailure missing from indexed and artifact findings",
|
||||
nextAction: "Fix analyzer/report commandFailures compact output before using this finding as a page root cause.",
|
||||
});
|
||||
}
|
||||
if (ids.includes("quick-verify-observer-stop-failed")) {
|
||||
const evidence = `${stringAtNullable(item, "evidenceSummary") ?? ""} ${stringAtNullable(item, "summary") ?? ""}`;
|
||||
if (/aliveAfter\s*=\s*true/u.test(evidence)) {
|
||||
return retagSentinelReportFinding(item, "observer-force-stop-left-process-alive", {
|
||||
summary: "observer force stop completed but process liveness evidence still reports aliveAfter=true.",
|
||||
rootCause: "observer-process-still-alive-after-force-stop",
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
});
|
||||
}
|
||||
if (/aliveAfter\s*=\s*false/u.test(evidence)) {
|
||||
return retagSentinelReportFinding(item, "observer-graceful-stop-timeout-force-stop-succeeded", {
|
||||
severity: "warning",
|
||||
level: "warning",
|
||||
summary: "observer graceful stop did not drain before timeout, but force stop confirmed aliveAfter=false.",
|
||||
rootCause: "observer-graceful-stop-timeout-force-stop-succeeded",
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
});
|
||||
}
|
||||
return retagSentinelReportFinding(item, "observer-cleanup-evidence-missing", {
|
||||
summary: "observer cleanup stop-state evidence is missing or incomplete; this is an evidence gap, not confirmed process leakage.",
|
||||
rootCause: "observer-cleanup-evidence-missing",
|
||||
rootCauseStatus: "evidence-gap",
|
||||
rootCauseConfidence: "medium",
|
||||
evidenceSummary: stringAtNullable(item, "evidenceSummary") ?? "cleanup evidence missing",
|
||||
});
|
||||
}
|
||||
if (ids.includes("no-samples")) {
|
||||
if (hasObserverCommandFailure) {
|
||||
return retagSentinelReportFinding(item, "no-samples-after-observer-command-failed", {
|
||||
summary: "observer produced zero samples after a blocking control command failed; the command failure is the deterministic precondition.",
|
||||
rootCause: "observer-command-failed-before-sampling",
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
});
|
||||
}
|
||||
return retagSentinelReportFinding(item, "no-samples-artifact-evidence-missing", {
|
||||
summary: "observer produced zero samples and analyzer has no failed command precondition; sampling/source evidence is missing.",
|
||||
rootCause: "sample-artifact-evidence-missing",
|
||||
rootCauseStatus: "evidence-gap",
|
||||
rootCauseConfidence: "medium",
|
||||
});
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function hasCommandFailureEvidence(item: Record<string, unknown>): boolean {
|
||||
const first = record(item.firstCommandFailure ?? firstArrayRecord(item.commands));
|
||||
return stringAtNullable(first, "type") !== null
|
||||
|| stringAtNullable(first, "phase") !== null
|
||||
|| stringAtNullable(first, "status") !== null
|
||||
|| stringAtNullable(first, "stdoutPreview") !== null
|
||||
|| stringAtNullable(first, "stderrPreview") !== null
|
||||
|| numberAtNullable(first, "exitCode") !== null;
|
||||
}
|
||||
|
||||
function retagSentinelReportFinding(item: Record<string, unknown>, id: string, extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...item,
|
||||
...extra,
|
||||
id,
|
||||
kind: id,
|
||||
code: id,
|
||||
finding_id: id,
|
||||
findingId: id,
|
||||
check: null,
|
||||
checkId: null,
|
||||
checkCode: null,
|
||||
checkTitleZh: null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function sentinelReportCheckCatalogById(state: SentinelCicdState): Map<string, Record<string, unknown>> {
|
||||
try {
|
||||
const reportViews = record(readWebProbeSentinelConfigRefTarget(state.spec, state.configRefs.reportViews));
|
||||
@@ -465,6 +580,8 @@ function compactSentinelReportFinding(value: Record<string, unknown>): Record<st
|
||||
if (nextAction !== null) result.nextAction = nextAction;
|
||||
const evidenceSummary = reportText(value.evidenceSummary, 240);
|
||||
if (evidenceSummary !== null) result.evidenceSummary = evidenceSummary;
|
||||
const firstCommandFailure = compactSentinelReportCommandFailure(value.firstCommandFailure ?? firstArrayRecord(value.commands));
|
||||
if (firstCommandFailure !== null) result.firstCommandFailure = firstCommandFailure;
|
||||
const timingSourceOfTruth = stringAtNullable(value, "timingSourceOfTruth");
|
||||
if (timingSourceOfTruth !== null) result.timingSourceOfTruth = timingSourceOfTruth;
|
||||
const timingStatus = stringAtNullable(value, "timingStatus");
|
||||
@@ -485,6 +602,27 @@ function compactSentinelReportFinding(value: Record<string, unknown>): Record<st
|
||||
return result;
|
||||
}
|
||||
|
||||
function compactSentinelReportCommandFailure(value: unknown): Record<string, unknown> | null {
|
||||
const item = record(value);
|
||||
if (Object.keys(item).length === 0) return null;
|
||||
return {
|
||||
commandId: stringAtNullable(item, "commandId"),
|
||||
type: stringAtNullable(item, "type"),
|
||||
phase: stringAtNullable(item, "phase"),
|
||||
status: stringAtNullable(item, "status"),
|
||||
exitCode: numberAtNullable(item, "exitCode"),
|
||||
timedOut: item.timedOut === true ? true : item.timedOut === false ? false : null,
|
||||
stdoutPreview: reportText(item.stdoutPreview, 160),
|
||||
stderrPreview: reportText(item.stderrPreview, 160),
|
||||
artifactRef: stringAtNullable(item, "artifactRef"),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function firstArrayRecord(value: unknown): Record<string, unknown> | null {
|
||||
return Array.isArray(value) && value.length > 0 ? record(value[0]) : null;
|
||||
}
|
||||
|
||||
function readSentinelReportArtifactSummary(state: SentinelCicdState, body: Record<string, unknown>, timeoutSeconds: number): Record<string, unknown> | null {
|
||||
const run = record(body.run);
|
||||
const stateDir = stringAtNullable(run, "state_dir") ?? stringAtNullable(run, "stateDir");
|
||||
@@ -501,13 +639,14 @@ function readSentinelReportArtifactSummary(state: SentinelCicdState, body: Recor
|
||||
"const sha=(buf)=>buf?`sha256:${crypto.createHash('sha256').update(buf).digest('hex')}`:null;",
|
||||
"const rec=(v)=>v&&typeof v==='object'&&!Array.isArray(v)?v:{}; const arr=(v)=>Array.isArray(v)?v:[]; const clip=(v,n=180)=>v==null?null:String(v).slice(0,n);",
|
||||
"const compactRootCauseSignals=(value)=>{const v=rec(value); const keys=['sessionListReadCount','traceEventsReadCount','webPerformanceBeaconFailureCount','eventSourceFailureCount','requestFailedCount','httpErrorCount','consoleAlertCount','requestfailedTop','httpStatusTop']; const out={}; for(const key of keys){if(v[key]!=null)out[key]=Array.isArray(v[key])?v[key].slice(0,8):v[key];} if(Object.keys(out).length===0)return null; out.valuesRedacted=true; return out;};",
|
||||
"const compactCommandFailure=(value)=>{const v=rec(value); if(Object.keys(v).length===0)return null; return {commandId:clip(v.commandId,80),type:clip(v.type,48),phase:clip(v.phase,80),status:clip(v.status,80),exitCode:Number.isFinite(Number(v.exitCode))?Number(v.exitCode):null,timedOut:v.timedOut===true?true:v.timedOut===false?false:null,stdoutPreview:clip(v.stdoutPreview,160),stderrPreview:clip(v.stderrPreview,160),artifactRef:clip(v.artifactRef,160),valuesRedacted:true};};",
|
||||
"const sleep=(ms)=>{try{Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms)}catch{}};",
|
||||
"let jsonBuf=read(reportPath); let report=null; let reportParseError=null; const deadline=Date.now()+waitMs;",
|
||||
"while(true){ if(jsonBuf){try{report=JSON.parse(jsonBuf.toString('utf8')); break}catch(err){reportParseError=String(err&&err.message?err.message:err)}} if(Date.now()>=deadline)break; sleep(Math.min(500, Math.max(50, deadline-Date.now()))); jsonBuf=read(reportPath); }",
|
||||
"let artifactCount=0; let screenshot=null;",
|
||||
"function walk(dir){let entries=[]; try{entries=fs.readdirSync(dir,{withFileTypes:true})}catch{return}; for(const e of entries){const p=path.join(dir,e.name); if(e.isDirectory()) walk(p); else { artifactCount++; if(/\\.png$/i.test(e.name)){const b=read(p); screenshot={path:p,sha256:sha(b),bytes:b?b.length:0}; } } }}",
|
||||
"walk(stateDir);",
|
||||
"const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),rootCause:clip(v.rootCause,140),rootCauseStatus:clip(v.rootCauseStatus,90),rootCauseConfidence:clip(v.rootCauseConfidence,40),nextAction:clip(v.nextAction,240),evidenceSummary:v.evidence?clip(JSON.stringify(rec(v.evidence)),220):clip(v.evidenceSummary,220),timingSourceOfTruth:clip(v.timingSourceOfTruth??v.expectedElapsedSource??v.evidenceKind,100),timingStatus:clip(v.timingStatus,60),timingAlert:v.timingAlert===true,rootCauseSignals:compactRootCauseSignals(v.rootCauseSignals),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});",
|
||||
"const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),rootCause:clip(v.rootCause,140),rootCauseStatus:clip(v.rootCauseStatus,90),rootCauseConfidence:clip(v.rootCauseConfidence,40),nextAction:clip(v.nextAction,240),evidenceSummary:v.evidence?clip(JSON.stringify(rec(v.evidence)),220):clip(v.evidenceSummary,220),firstCommandFailure:compactCommandFailure(v.firstCommandFailure || arr(v.commands)[0]),timingSourceOfTruth:clip(v.timingSourceOfTruth??v.expectedElapsedSource??v.evidenceKind,100),timingStatus:clip(v.timingStatus,60),timingAlert:v.timingAlert===true,rootCauseSignals:compactRootCauseSignals(v.rootCauseSignals),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});",
|
||||
"const slow=arr(report?.pagePerformanceSlowApi ?? report?.archivePagePerformanceSlowApi).slice(0,8).map((item)=>{const v=rec(item); return {path:clip(v.path??v.route,120),sampleCount:v.sampleCount??null,p95Ms:v.p95Ms??null,maxMs:v.maxMs??null,overFiveSecondCount:v.overFiveSecondCount??null};});",
|
||||
"console.log(JSON.stringify({ok:!!report,reason:report?null:(jsonBuf?'report-json-parse-failed':'report-json-missing'),reportReadWaitMs:waitMs,reportParseError:clip(reportParseError,220),reportOk:!!report&&report.ok!==false,stateDir,reportJsonPath:reportPath,reportJsonSha256:sha(jsonBuf),reportMdPath,reportMdSha256:sha(read(reportMdPath)),findingCount:Number(report?.findingCount??findings.length),artifactCount,screenshot,findings,counts:rec(report?.counts),analysisWindow:rec(report?.analysisWindow??report?.windows?.recent?.summary),pagePerformanceSlowApi:slow,valuesRedacted:true}));",
|
||||
"NODE",
|
||||
|
||||
@@ -1760,7 +1760,7 @@ function visibleFindingsForRun(
|
||||
const artifactFindings = arrayRecords(artifactSummary.findings)
|
||||
.slice(0, limit)
|
||||
.map((item) => enrichFindingWithCheck(config, compactStoredFinding(item)));
|
||||
return mergeVisibleFindings(config, storedFindings, artifactFindings, limit);
|
||||
return reclassifyVisibleFindings(config, row, mergeVisibleFindings(config, storedFindings, artifactFindings, limit), limit);
|
||||
}
|
||||
|
||||
function mergeVisibleFindings(config: WebProbeSentinelServiceConfig, primary: readonly Record<string, unknown>[], artifact: readonly Record<string, unknown>[], limit: number): readonly Record<string, unknown>[] {
|
||||
@@ -1793,6 +1793,126 @@ function canonicalFindingIdentity(config: WebProbeSentinelServiceConfig, item: R
|
||||
return stringOrNull(entry?.code) ?? stringOrNull(entry?.id) ?? findingId;
|
||||
}
|
||||
|
||||
function reclassifyVisibleFindings(config: WebProbeSentinelServiceConfig, row: Record<string, unknown>, findings: readonly Record<string, unknown>[], limit: number): readonly Record<string, unknown>[] {
|
||||
const hasObserverCommandFailure = findings.some((item) => findingIdentityCandidates(item).includes("observer-command-failed"));
|
||||
const failure = stringOrNull(record(row.summary).failure) ?? stringOrNull(row.failure) ?? stringOrNull(record(row.businessStatus).failure);
|
||||
return findings
|
||||
.map((item) => enrichFindingWithCheck(config, reclassifyVisibleFinding(item, failure, hasObserverCommandFailure)))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function findingIdentityCandidates(item: Record<string, unknown>): readonly string[] {
|
||||
const check = record(item.check);
|
||||
const candidates = [
|
||||
stringOrNull(item.finding_id),
|
||||
stringOrNull(item.findingId),
|
||||
stringOrNull(item.id),
|
||||
stringOrNull(item.kind),
|
||||
stringOrNull(item.code),
|
||||
stringOrNull(item.checkId),
|
||||
stringOrNull(item.checkCode),
|
||||
stringOrNull(check.id),
|
||||
stringOrNull(check.code),
|
||||
];
|
||||
return [...new Set(candidates.filter((value): value is string => value !== null))];
|
||||
}
|
||||
|
||||
function reclassifyVisibleFinding(item: Record<string, unknown>, failure: string | null, hasObserverCommandFailure: boolean): Record<string, unknown> {
|
||||
const ids = findingIdentityCandidates(item);
|
||||
if (ids.includes("quick-verify-command-sequence-failed")) {
|
||||
return retagVisibleFinding(item, "account-session-command-failed-before-observation", {
|
||||
summary: "account/session setup command failed before any business turn could be observed.",
|
||||
rootCause: `account-session-command-stage-failed:${failure ?? "unknown"}`,
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
evidenceSummary: `stage=account-session-command failure=${failure ?? "-"}`,
|
||||
nextAction: "Inspect the first failed account/session/workbench command and rerun quick verify after that command succeeds.",
|
||||
});
|
||||
}
|
||||
if (ids.includes("observer-command-failed") && !hasCommandFailureEvidence(item)) {
|
||||
return retagVisibleFinding(item, "observer-command-failure-evidence-missing", {
|
||||
summary: "report says observer control commands failed, but the first failed command evidence is missing.",
|
||||
rootCause: "observer-command-failure-evidence-missing",
|
||||
rootCauseStatus: "evidence-gap",
|
||||
rootCauseConfidence: "high",
|
||||
evidenceSummary: "firstCommandFailure missing from indexed and artifact findings",
|
||||
nextAction: "Fix analyzer/report commandFailures compact output before using this finding as a page root cause.",
|
||||
});
|
||||
}
|
||||
if (ids.includes("quick-verify-observer-stop-failed")) {
|
||||
const evidence = `${stringOrNull(item.evidenceSummary) ?? ""} ${stringOrNull(item.summary) ?? ""}`;
|
||||
if (/aliveAfter\s*=\s*true/u.test(evidence)) {
|
||||
return retagVisibleFinding(item, "observer-force-stop-left-process-alive", {
|
||||
summary: "observer force stop completed but process liveness evidence still reports aliveAfter=true.",
|
||||
rootCause: "observer-process-still-alive-after-force-stop",
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
});
|
||||
}
|
||||
if (/aliveAfter\s*=\s*false/u.test(evidence)) {
|
||||
return retagVisibleFinding(item, "observer-graceful-stop-timeout-force-stop-succeeded", {
|
||||
severity: "warning",
|
||||
level: "warning",
|
||||
summary: "observer graceful stop did not drain before timeout, but force stop confirmed aliveAfter=false.",
|
||||
rootCause: "observer-graceful-stop-timeout-force-stop-succeeded",
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
});
|
||||
}
|
||||
return retagVisibleFinding(item, "observer-cleanup-evidence-missing", {
|
||||
summary: "observer cleanup stop-state evidence is missing or incomplete; this is an evidence gap, not confirmed process leakage.",
|
||||
rootCause: "observer-cleanup-evidence-missing",
|
||||
rootCauseStatus: "evidence-gap",
|
||||
rootCauseConfidence: "medium",
|
||||
evidenceSummary: stringOrNull(item.evidenceSummary) ?? "cleanup evidence missing",
|
||||
});
|
||||
}
|
||||
if (ids.includes("no-samples")) {
|
||||
if (hasObserverCommandFailure) {
|
||||
return retagVisibleFinding(item, "no-samples-after-observer-command-failed", {
|
||||
summary: "observer produced zero samples after a blocking control command failed; the command failure is the deterministic precondition.",
|
||||
rootCause: "observer-command-failed-before-sampling",
|
||||
rootCauseStatus: "confirmed",
|
||||
rootCauseConfidence: "high",
|
||||
});
|
||||
}
|
||||
return retagVisibleFinding(item, "no-samples-artifact-evidence-missing", {
|
||||
summary: "observer produced zero samples and analyzer has no failed command precondition; sampling/source evidence is missing.",
|
||||
rootCause: "sample-artifact-evidence-missing",
|
||||
rootCauseStatus: "evidence-gap",
|
||||
rootCauseConfidence: "medium",
|
||||
});
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function hasCommandFailureEvidence(item: Record<string, unknown>): boolean {
|
||||
const first = record(item.firstCommandFailure ?? firstArrayRecord(item.commands));
|
||||
return stringOrNull(first.type) !== null
|
||||
|| stringOrNull(first.phase) !== null
|
||||
|| stringOrNull(first.status) !== null
|
||||
|| stringOrNull(first.stdoutPreview) !== null
|
||||
|| stringOrNull(first.stderrPreview) !== null
|
||||
|| numberOrNull(first.exitCode) !== null;
|
||||
}
|
||||
|
||||
function retagVisibleFinding(item: Record<string, unknown>, id: string, extra: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...item,
|
||||
...extra,
|
||||
id,
|
||||
kind: id,
|
||||
code: id,
|
||||
finding_id: id,
|
||||
findingId: id,
|
||||
check: null,
|
||||
checkId: null,
|
||||
checkCode: null,
|
||||
checkTitleZh: null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function findingIdentity(item: Record<string, unknown>): string | null {
|
||||
return stringOrNull(item.finding_id)
|
||||
?? stringOrNull(item.findingId)
|
||||
@@ -1871,6 +1991,7 @@ function enrichFindingRowWithStoredDetail(config: WebProbeSentinelServiceConfig,
|
||||
rootCauseConfidence: stringOrNull(detail?.rootCauseConfidence),
|
||||
nextAction: stringOrNull(detail?.nextAction),
|
||||
evidenceSummary: stringOrNull(detail?.evidenceSummary),
|
||||
firstCommandFailure: compactCommandFailureEvidence(detail?.firstCommandFailure ?? firstArrayRecord(detail?.commands)),
|
||||
rootCauseSignals: record(detail?.rootCauseSignals),
|
||||
timingSourceOfTruth: stringOrNull(detail?.timingSourceOfTruth),
|
||||
timingStatus: stringOrNull(detail?.timingStatus),
|
||||
@@ -1918,6 +2039,7 @@ function compactStoredFinding(value: unknown): Record<string, unknown> {
|
||||
rootCauseConfidence: stringOrNull(item.rootCauseConfidence),
|
||||
nextAction: stringOrNull(item.nextAction),
|
||||
evidenceSummary: stringOrNull(item.evidenceSummary) ?? compactFindingEvidenceSummary(item.evidence),
|
||||
firstCommandFailure: compactCommandFailureEvidence(item.firstCommandFailure ?? firstArrayRecord(item.commands)),
|
||||
rootCauseSignals: compactFindingRootCauseSignals(item.rootCauseSignals),
|
||||
timingSourceOfTruth: stringOrNull(item.timingSourceOfTruth) ?? stringOrNull(item.expectedElapsedSource) ?? stringOrNull(item.evidenceKind),
|
||||
timingStatus: stringOrNull(item.timingStatus),
|
||||
@@ -1926,6 +2048,27 @@ function compactStoredFinding(value: unknown): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function compactCommandFailureEvidence(value: unknown): Record<string, unknown> | null {
|
||||
const item = record(value);
|
||||
if (Object.keys(item).length === 0) return null;
|
||||
return {
|
||||
commandId: stringOrNull(item.commandId),
|
||||
type: stringOrNull(item.type),
|
||||
phase: stringOrNull(item.phase),
|
||||
status: stringOrNull(item.status),
|
||||
exitCode: numberOrNull(item.exitCode),
|
||||
timedOut: item.timedOut === true ? true : item.timedOut === false ? false : null,
|
||||
stdoutPreview: stringOrNull(item.stdoutPreview)?.slice(0, 240) ?? null,
|
||||
stderrPreview: stringOrNull(item.stderrPreview)?.slice(0, 240) ?? null,
|
||||
artifactRef: stringOrNull(item.artifactRef),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function firstArrayRecord(value: unknown): Record<string, unknown> | null {
|
||||
return Array.isArray(value) && value.length > 0 ? record(value[0]) : null;
|
||||
}
|
||||
|
||||
function compactFindingRootCauseSignals(value: unknown): Record<string, unknown> | null {
|
||||
const item = record(value);
|
||||
const keys = [
|
||||
|
||||
Reference in New Issue
Block a user