583 lines
27 KiB
TypeScript
583 lines
27 KiB
TypeScript
// SPEC: PJ2026-010401080313 Workbench 实时权威。
|
||
// 职责:在持久 Web observer 中验证产品 Workbench Trace 的可读性。
|
||
|
||
export function nodeWebObserveRunnerTraceReadabilitySource(): string {
|
||
return String.raw`
|
||
function parseWorkbenchTraceReadabilityConfig(raw) {
|
||
if (!raw) return null;
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch (error) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_WORKBENCH_TRACE_READABILITY_JSON 不是合法 JSON:" + (error instanceof Error ? error.message : String(error)));
|
||
}
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_WORKBENCH_TRACE_READABILITY_JSON 必须是对象");
|
||
}
|
||
if (typeof parsed.enabled !== "boolean") throw new Error("Workbench Trace 可读性 enabled 必须由 owning YAML 显式声明为布尔值");
|
||
const positiveInteger = (key) => {
|
||
const value = Number(parsed[key]);
|
||
if (!Number.isInteger(value) || value <= 0) throw new Error("Workbench Trace 可读性 " + key + " 必须是 owning YAML 中的正整数");
|
||
return value;
|
||
};
|
||
return {
|
||
enabled: parsed.enabled,
|
||
runningCardTimeoutMs: positiveInteger("runningCardTimeoutMs"),
|
||
disclosureActionTimeoutMs: positiveInteger("disclosureActionTimeoutMs"),
|
||
disclosureOpenTimeoutMs: positiveInteger("disclosureOpenTimeoutMs"),
|
||
runningReadableTimeoutMs: positiveInteger("runningReadableTimeoutMs"),
|
||
terminalTimeoutMs: positiveInteger("terminalTimeoutMs"),
|
||
domStableQuietMs: positiveInteger("domStableQuietMs"),
|
||
domStableTimeoutMs: positiveInteger("domStableTimeoutMs"),
|
||
pollIntervalMs: positiveInteger("pollIntervalMs"),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function workbenchTraceReadabilityTerminalStatus(value) {
|
||
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"]
|
||
.includes(String(value || "").trim().toLowerCase().replace(/_/gu, "-"));
|
||
}
|
||
|
||
async function validateWorkbenchTraceReadability(command) {
|
||
const config = workbenchTraceReadability;
|
||
if (!config || config.enabled !== true) throw new Error("owning YAML 未启用 validateWorkbenchTraceReadability");
|
||
const reportDir = path.join(stateDir, "artifacts", "workbench-trace-readability", safeId(command.id));
|
||
const report = {
|
||
ok: false,
|
||
contract: "web-probe-workbench-trace-readability-v1",
|
||
commandId: command.id,
|
||
origin,
|
||
expected: {
|
||
runningCardTimeoutMs: config.runningCardTimeoutMs,
|
||
disclosureActionTimeoutMs: config.disclosureActionTimeoutMs,
|
||
disclosureOpenTimeoutMs: config.disclosureOpenTimeoutMs,
|
||
runningReadableTimeoutMs: config.runningReadableTimeoutMs,
|
||
terminalTimeoutMs: config.terminalTimeoutMs,
|
||
domStableQuietMs: config.domStableQuietMs,
|
||
domStableTimeoutMs: config.domStableTimeoutMs,
|
||
pollIntervalMs: config.pollIntervalMs,
|
||
scope: "#conversation-list product WorkbenchMessageCard",
|
||
sequenceAuthority: "source",
|
||
valuesRedacted: true
|
||
},
|
||
startedAt: new Date().toISOString(),
|
||
valuesRedacted: true
|
||
};
|
||
try {
|
||
const controlRecovery = await ensureControlPageResponsiveForCommand("validateWorkbenchTraceReadability");
|
||
Object.assign(report, { controlRecovery });
|
||
const currentPath = safeUrlPath(currentPageUrl()) || "";
|
||
if (!isWorkbenchPathname(currentPath)) throw new Error("validateWorkbenchTraceReadability 必须在原 Workbench 页面执行");
|
||
const session = await workbenchSessionSnapshot();
|
||
const sessionId = session?.activeSessionId || session?.routeSessionId || routeSessionIdFromUrl(currentPageUrl());
|
||
if (!sessionId) throw new Error("validateWorkbenchTraceReadability 需要当前已有 Workbench session");
|
||
Object.assign(report, { sessionId });
|
||
|
||
const target = await workbenchTraceReadabilityWaitForCurrentProductCard(
|
||
config.runningCardTimeoutMs,
|
||
config.pollIntervalMs
|
||
);
|
||
Object.assign(report, { traceId: target.traceId, scope: target.scope });
|
||
const before = await workbenchTraceReadabilitySnapshot(target.card, target.traceId);
|
||
const startedDuringRunning = !workbenchTraceReadabilityTerminalStatus(before.status);
|
||
const openBefore = before.disclosure.open === true;
|
||
let forcedExpansion = false;
|
||
Object.assign(report, {
|
||
before,
|
||
startedDuringRunning,
|
||
forcedExpansion,
|
||
disclosure: {
|
||
openBefore,
|
||
openAfterExpansion: null,
|
||
openAfterTerminal: null,
|
||
autoReadable: false,
|
||
valuesRedacted: true
|
||
}
|
||
});
|
||
if (!openBefore) {
|
||
const summary = target.card.locator("details.trace-disclosure > summary.trace-disclosure-summary").first();
|
||
await summary.waitFor({ state: "visible", timeout: config.disclosureActionTimeoutMs });
|
||
await summary.click({ timeout: config.disclosureActionTimeoutMs });
|
||
forcedExpansion = true;
|
||
report.forcedExpansion = true;
|
||
}
|
||
const afterExpansion = await workbenchTraceReadabilityWaitForDisclosureOpen(
|
||
target.card,
|
||
target.traceId,
|
||
config.disclosureOpenTimeoutMs,
|
||
config.pollIntervalMs
|
||
);
|
||
report.disclosure.openAfterExpansion = afterExpansion.disclosure?.open ?? null;
|
||
|
||
const running = startedDuringRunning
|
||
? await workbenchTraceReadabilityWaitForReadableRunning(
|
||
target.card,
|
||
target.traceId,
|
||
config.runningReadableTimeoutMs,
|
||
config.pollIntervalMs
|
||
)
|
||
: { observed: false, reason: "command-started-after-terminal", snapshot: before, valuesRedacted: true };
|
||
Object.assign(report, { running });
|
||
report.disclosure.autoReadable = openBefore && (startedDuringRunning
|
||
? running.observed === true
|
||
: before.rowCount > 0 && before.sourceAuthorityRowCount > 0 && before.readableSourceRowCount > 0);
|
||
const terminalArrival = await workbenchTraceReadabilityWaitForTerminal(
|
||
target.card,
|
||
target.traceId,
|
||
config.terminalTimeoutMs,
|
||
config.pollIntervalMs
|
||
);
|
||
Object.assign(report, { terminalArrival });
|
||
const terminal = await workbenchTraceReadabilityWaitForStableTerminal(
|
||
target.card,
|
||
target.traceId,
|
||
config.domStableQuietMs,
|
||
config.domStableTimeoutMs,
|
||
config.pollIntervalMs
|
||
);
|
||
Object.assign(report, { terminal });
|
||
report.disclosure.openAfterTerminal = terminal.snapshot?.disclosure?.open ?? null;
|
||
const runningRetention = workbenchTraceReadabilityRetention(running.snapshot, terminal.snapshot);
|
||
const retainedRunningRowCount = runningRetention.retainedRowCount;
|
||
Object.assign(report, { runningRetention, retainedRunningRowCount });
|
||
const screenshot = await captureScreenshot("workbench-trace-readability-" + safeId(command.id));
|
||
const evidence = {
|
||
sessionId,
|
||
traceId: target.traceId,
|
||
scope: target.scope,
|
||
startedDuringRunning,
|
||
forcedExpansion,
|
||
disclosure: {
|
||
openBefore,
|
||
openAfterExpansion: afterExpansion.disclosure?.open ?? null,
|
||
openAfterTerminal: terminal.snapshot?.disclosure?.open ?? null,
|
||
autoReadable: openBefore && (startedDuringRunning
|
||
? running.observed === true
|
||
: before.rowCount > 0 && before.sourceAuthorityRowCount > 0 && before.readableSourceRowCount > 0),
|
||
valuesRedacted: true
|
||
},
|
||
running,
|
||
terminalArrival,
|
||
terminal,
|
||
runningRetention,
|
||
retainedRunningRowCount,
|
||
screenshot: { path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true },
|
||
controlRecovery,
|
||
valuesRedacted: true
|
||
};
|
||
Object.assign(report, evidence);
|
||
const failures = workbenchTraceReadabilityFailures(evidence);
|
||
report.failures = failures;
|
||
report.ok = failures.length === 0;
|
||
report.status = failures.length === 0 ? "passed" : "failed";
|
||
report.completedAt = new Date().toISOString();
|
||
const reportArtifact = await workbenchTraceReadabilityWriteReport(reportDir, report);
|
||
report.reportPath = reportArtifact.path;
|
||
report.reportSha256 = reportArtifact.sha256;
|
||
|
||
if (failures.length > 0) {
|
||
throw workbenchTraceReadabilityEvidenceError(
|
||
"Workbench 产品 Trace 可读性验证失败:" + failures.map((failure) => failure.code).join(","),
|
||
report
|
||
);
|
||
}
|
||
await appendJsonl(files.control, eventRecord("workbench-trace-readability-validated", {
|
||
sessionId,
|
||
traceId: report.traceId,
|
||
disclosure: report.disclosure,
|
||
running: report.running,
|
||
terminal: report.terminal,
|
||
runningRetention: report.runningRetention,
|
||
retainedRunningRowCount,
|
||
reportPath: report.reportPath,
|
||
reportSha256: report.reportSha256,
|
||
screenshot: report.screenshot,
|
||
valuesRedacted: true
|
||
}));
|
||
return {
|
||
ok: true,
|
||
status: "passed",
|
||
sessionId,
|
||
traceId: report.traceId,
|
||
scope: report.scope,
|
||
disclosure: report.disclosure,
|
||
running: report.running,
|
||
terminalArrival: report.terminalArrival,
|
||
terminal: report.terminal,
|
||
runningRetention: report.runningRetention,
|
||
retainedRunningRowCount,
|
||
reportPath: report.reportPath,
|
||
reportSha256: report.reportSha256,
|
||
screenshot: report.screenshot,
|
||
valuesRedacted: true
|
||
};
|
||
} catch (error) {
|
||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||
const partial = wrapped.details && typeof wrapped.details === "object" && !Array.isArray(wrapped.details)
|
||
? wrapped.details
|
||
: {};
|
||
for (const key of ["sessionId", "traceId", "scope", "before", "disclosure", "running", "terminalArrival", "terminal", "runningRetention", "retainedRunningRowCount", "failures"]) {
|
||
if ((report[key] === undefined || report[key] === null) && partial[key] !== undefined && partial[key] !== null) report[key] = partial[key];
|
||
}
|
||
if (!report.completedAt) {
|
||
Object.assign(report, {
|
||
ok: false,
|
||
status: "failed",
|
||
completedAt: new Date().toISOString(),
|
||
failure: errorSummary(error),
|
||
valuesRedacted: true
|
||
});
|
||
}
|
||
if (!Array.isArray(report.failures)) {
|
||
report.failures = [{ code: "probe_exception", message: wrapped.message, valuesRedacted: true }];
|
||
}
|
||
if (!report.screenshot) {
|
||
report.screenshot = await captureScreenshot("workbench-trace-readability-failed-" + safeId(command.id))
|
||
.then((screenshot) => ({ path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true }))
|
||
.catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true }));
|
||
}
|
||
const existingReportPath = wrapped.details?.reportPath || report.reportPath || null;
|
||
const existingReportSha256 = wrapped.details?.reportSha256 || report.reportSha256 || null;
|
||
const reportArtifact = existingReportPath && existingReportSha256
|
||
? null
|
||
: await workbenchTraceReadabilityWriteReport(reportDir, report).catch(() => null);
|
||
wrapped.details = {
|
||
...(wrapped.details || {}),
|
||
sessionId: report.sessionId || wrapped.details?.sessionId || null,
|
||
traceId: report.traceId || wrapped.details?.traceId || null,
|
||
scope: report.scope || wrapped.details?.scope || null,
|
||
before: report.before || wrapped.details?.before || null,
|
||
disclosure: report.disclosure || wrapped.details?.disclosure || null,
|
||
running: report.running || wrapped.details?.running || null,
|
||
terminalArrival: report.terminalArrival || wrapped.details?.terminalArrival || null,
|
||
terminal: report.terminal || wrapped.details?.terminal || null,
|
||
runningRetention: report.runningRetention || wrapped.details?.runningRetention || null,
|
||
retainedRunningRowCount: report.retainedRunningRowCount ?? wrapped.details?.retainedRunningRowCount ?? null,
|
||
failures: report.failures || wrapped.details?.failures || null,
|
||
screenshot: report.screenshot || wrapped.details?.screenshot || null,
|
||
reportPath: existingReportPath || reportArtifact?.path || null,
|
||
reportSha256: existingReportSha256 || reportArtifact?.sha256 || null,
|
||
valuesRedacted: true
|
||
};
|
||
throw wrapped;
|
||
}
|
||
}
|
||
|
||
async function workbenchTraceReadabilityWaitForCurrentProductCard(timeoutMs, pollIntervalMs) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let lastScope = null;
|
||
while (Date.now() <= deadline) {
|
||
const conversation = page.locator("#conversation-list").first();
|
||
const conversationCount = await page.locator("#conversation-list").count();
|
||
const cards = conversation.locator(':scope > .message-card[data-role="agent"][data-trace-id]');
|
||
const productCardCount = conversationCount === 1 ? await cards.count() : 0;
|
||
const debugPanelCountInsideConversation = conversationCount === 1
|
||
? await conversation.locator('[data-testid="workbench-kafka-debug-panel"]').count()
|
||
: 0;
|
||
lastScope = {
|
||
selector: '#conversation-list > .message-card[data-role="agent"][data-trace-id]',
|
||
conversationCount,
|
||
productCardCount,
|
||
debugPanelCountInsideConversation,
|
||
isolatedDebugExcluded: debugPanelCountInsideConversation === 0,
|
||
valuesRedacted: true
|
||
};
|
||
if (conversationCount === 1 && productCardCount > 0) {
|
||
const runningTraceIds = await cards.evaluateAll((elements) => elements
|
||
.filter((element) => {
|
||
const timeline = element.querySelector(":scope > .trace-timeline");
|
||
const status = String(element.getAttribute("data-status") || timeline?.getAttribute("data-status") || "").trim().toLowerCase();
|
||
return status === "running" || status === "pending";
|
||
})
|
||
.map((element) => String(element.getAttribute("data-trace-id") || "").trim())
|
||
.filter((traceId) => /^trc_[A-Za-z0-9_-]+$/u.test(traceId)));
|
||
const traceId = runningTraceIds.at(-1) || null;
|
||
if (traceId) {
|
||
const card = conversation.locator(':scope > .message-card[data-role="agent"][data-trace-id="' + traceId + '"]').first();
|
||
const stableCardCount = await card.count();
|
||
const timelineCount = stableCardCount === 1 ? await card.locator(":scope > .trace-timeline").count() : 0;
|
||
if (stableCardCount === 1 && timelineCount === 1) return { card, traceId, scope: lastScope };
|
||
}
|
||
}
|
||
await sleep(pollIntervalMs);
|
||
}
|
||
const error = new Error("在 owning YAML 指定的 " + timeoutMs + "ms 内未找到当前新运行中的产品 Trace 卡片");
|
||
error.details = { scope: lastScope, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
|
||
async function workbenchTraceReadabilityWaitForDisclosureOpen(card, traceId, timeoutMs, pollIntervalMs) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
while (Date.now() <= deadline) {
|
||
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
if (latest.disclosure.open === true) return latest;
|
||
await sleep(pollIntervalMs);
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
async function workbenchTraceReadabilitySnapshot(card, expectedTraceId) {
|
||
const raw = await card.evaluate((root) => {
|
||
const timeline = root.querySelector(":scope > .trace-timeline");
|
||
const disclosure = timeline?.querySelector(":scope > details.trace-disclosure");
|
||
const rows = disclosure ? Array.from(disclosure.querySelectorAll('[data-testid="trace-render-row"]')) : [];
|
||
const normalized = (value) => String(value || "").replace(/\s+/gu, " ").trim();
|
||
return {
|
||
connected: root.isConnected,
|
||
status: root.getAttribute("data-status") || timeline?.getAttribute("data-status") || null,
|
||
traceId: root.getAttribute("data-trace-id") || timeline?.getAttribute("data-trace-id") || null,
|
||
disclosure: {
|
||
available: Boolean(disclosure),
|
||
open: disclosure instanceof HTMLDetailsElement ? disclosure.open : null,
|
||
eventCount: disclosure?.getAttribute("data-event-count") || null,
|
||
readableRowCount: disclosure?.getAttribute("data-readable-row-count") || null,
|
||
renderedRowCount: disclosure?.getAttribute("data-rendered-row-count") || null,
|
||
rowWindowed: disclosure?.getAttribute("data-row-windowed") || null
|
||
},
|
||
rows: rows.map((row) => ({
|
||
seq: row.getAttribute("data-event-seq"),
|
||
authority: row.getAttribute("data-event-seq-authority"),
|
||
kind: row.getAttribute("data-event-kind"),
|
||
status: row.getAttribute("data-event-status"),
|
||
terminal: row.getAttribute("data-terminal") === "true",
|
||
text: normalized(row.textContent)
|
||
}))
|
||
};
|
||
});
|
||
if (raw.connected !== true || raw.traceId !== expectedTraceId) {
|
||
const error = new Error("产品 Trace 卡片身份在验证期间发生变化");
|
||
error.details = {
|
||
traceId: expectedTraceId,
|
||
observedTraceId: raw.traceId || null,
|
||
connected: raw.connected === true,
|
||
valuesRedacted: true
|
||
};
|
||
throw error;
|
||
}
|
||
const rows = raw.rows.map((row) => ({
|
||
seq: row.seq,
|
||
authority: row.authority,
|
||
kind: row.kind,
|
||
status: row.status,
|
||
terminal: row.terminal,
|
||
identity: [row.authority || "-", row.seq || "-", row.kind || "-"].join(":"),
|
||
textPresent: row.text.length > 0,
|
||
textBytes: Buffer.byteLength(row.text),
|
||
textHash: sha256Text(row.text),
|
||
valuesRedacted: true
|
||
}));
|
||
const sourceRows = rows.filter((row) => row.authority === "source");
|
||
const projectedRows = rows.filter((row) => row.authority === "projected");
|
||
const sourceSeqs = sourceRows
|
||
.map((row) => Number(row.seq))
|
||
.filter((value) => Number.isInteger(value));
|
||
const integerAttribute = (value) => {
|
||
const parsed = Number(value);
|
||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||
};
|
||
return {
|
||
connected: raw.connected,
|
||
status: raw.status,
|
||
traceId: raw.traceId,
|
||
disclosure: {
|
||
...raw.disclosure,
|
||
eventCount: integerAttribute(raw.disclosure.eventCount),
|
||
readableRowCount: integerAttribute(raw.disclosure.readableRowCount),
|
||
renderedRowCount: integerAttribute(raw.disclosure.renderedRowCount),
|
||
rowWindowed: raw.disclosure.rowWindowed === "true"
|
||
? true
|
||
: raw.disclosure.rowWindowed === "false"
|
||
? false
|
||
: null
|
||
},
|
||
rowCount: rows.length,
|
||
readableRowCount: rows.filter((row) => row.textPresent).length,
|
||
sourceAuthorityRowCount: sourceRows.length,
|
||
readableSourceRowCount: sourceRows.filter((row) => row.textPresent).length,
|
||
projectedAuthorityRowCount: projectedRows.length,
|
||
sourceSeqMin: sourceSeqs.length > 0 ? Math.min(...sourceSeqs) : null,
|
||
sourceSeqMax: sourceSeqs.length > 0 ? Math.max(...sourceSeqs) : null,
|
||
rowIdentities: rows.map((row) => row.identity),
|
||
rows: rows.slice(-12),
|
||
rowSetHash: sha256Text(rows.map((row) => row.identity + ":" + row.textHash).join("|")),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
async function workbenchTraceReadabilityWaitForReadableRunning(card, traceId, timeoutMs, pollIntervalMs) {
|
||
const startedAtMs = Date.now();
|
||
const deadline = startedAtMs + timeoutMs;
|
||
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
while (Date.now() <= deadline) {
|
||
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
if (!workbenchTraceReadabilityTerminalStatus(latest.status)
|
||
&& latest.disclosure.open === true
|
||
&& latest.rowCount > 0
|
||
&& latest.sourceAuthorityRowCount > 0
|
||
&& latest.readableSourceRowCount > 0) {
|
||
return { observed: true, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||
}
|
||
if (workbenchTraceReadabilityTerminalStatus(latest.status)) {
|
||
return { observed: false, reason: "terminal-before-readable-running-sample", elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||
}
|
||
await sleep(pollIntervalMs);
|
||
}
|
||
return { observed: false, reason: "readable-running-timeout", elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||
}
|
||
|
||
async function workbenchTraceReadabilityWaitForTerminal(card, traceId, timeoutMs, pollIntervalMs) {
|
||
const startedAtMs = Date.now();
|
||
const deadline = startedAtMs + timeoutMs;
|
||
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
while (Date.now() <= deadline) {
|
||
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
if (workbenchTraceReadabilityTerminalStatus(latest.status)) {
|
||
return { observed: true, status: latest.status, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||
}
|
||
await sleep(pollIntervalMs);
|
||
}
|
||
return { observed: false, status: latest.status, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||
}
|
||
|
||
async function workbenchTraceReadabilityWaitForStableTerminal(card, traceId, quietMs, timeoutMs, pollIntervalMs) {
|
||
const startedAtMs = Date.now();
|
||
const deadline = startedAtMs + timeoutMs;
|
||
let stableSinceMs = null;
|
||
let previousSignature = null;
|
||
let latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
let sampleCount = 0;
|
||
while (Date.now() <= deadline) {
|
||
latest = await workbenchTraceReadabilitySnapshot(card, traceId);
|
||
sampleCount += 1;
|
||
const signature = [latest.status, latest.disclosure.open, latest.rowCount, latest.sourceAuthorityRowCount, latest.rowSetHash].join("|");
|
||
const ready = workbenchTraceReadabilityTerminalStatus(latest.status)
|
||
&& latest.disclosure.open === true
|
||
&& latest.rowCount > 0
|
||
&& latest.sourceAuthorityRowCount > 0
|
||
&& latest.readableSourceRowCount > 0;
|
||
if (ready && signature === previousSignature) {
|
||
stableSinceMs ??= Date.now();
|
||
if (Date.now() - stableSinceMs >= quietMs) {
|
||
return { observed: true, stable: true, sampleCount, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||
}
|
||
} else {
|
||
stableSinceMs = null;
|
||
}
|
||
previousSignature = signature;
|
||
await sleep(pollIntervalMs);
|
||
}
|
||
return { observed: workbenchTraceReadabilityTerminalStatus(latest.status), stable: false, sampleCount, elapsedMs: Date.now() - startedAtMs, snapshot: latest, valuesRedacted: true };
|
||
}
|
||
|
||
function workbenchTraceReadabilityRetention(running, terminal) {
|
||
const runningRowIds = new Set(running?.rowIdentities || []);
|
||
const terminalRowIds = new Set(terminal?.rowIdentities || []);
|
||
const retainedRowCount = [...runningRowIds].filter((identity) => terminalRowIds.has(identity)).length;
|
||
const concreteRunningRowIds = [...runningRowIds].filter((identity) => !identity.endsWith(":trace-noise-summary"));
|
||
const retainedConcreteRowCount = concreteRunningRowIds.filter((identity) => terminalRowIds.has(identity)).length;
|
||
const summaryOnly = runningRowIds.size > 0 && concreteRunningRowIds.length === 0;
|
||
const windowed = terminal?.disclosure?.rowWindowed === true;
|
||
const runningEventCount = running?.disclosure?.eventCount;
|
||
const terminalEventCount = terminal?.disclosure?.eventCount;
|
||
const countsAdvance = Number.isInteger(runningEventCount)
|
||
&& Number.isInteger(terminalEventCount)
|
||
&& terminalEventCount >= runningEventCount;
|
||
const sequenceWindowAdvances = Number.isInteger(running?.sourceSeqMin)
|
||
&& Number.isInteger(running?.sourceSeqMax)
|
||
&& Number.isInteger(terminal?.sourceSeqMin)
|
||
&& Number.isInteger(terminal?.sourceSeqMax)
|
||
&& terminal.sourceSeqMin >= running.sourceSeqMin
|
||
&& terminal.sourceSeqMax >= running.sourceSeqMax;
|
||
if (!windowed && !summaryOnly) {
|
||
return {
|
||
mode: "identity-overlap",
|
||
passed: retainedConcreteRowCount > 0,
|
||
retainedRowCount,
|
||
retainedConcreteRowCount,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
return {
|
||
mode: windowed ? "window-progress" : "summary-progress",
|
||
passed: countsAdvance && sequenceWindowAdvances,
|
||
retainedRowCount,
|
||
retainedConcreteRowCount,
|
||
runningEventCount: runningEventCount ?? null,
|
||
terminalEventCount: terminalEventCount ?? null,
|
||
runningSourceSeqMin: running?.sourceSeqMin ?? null,
|
||
runningSourceSeqMax: running?.sourceSeqMax ?? null,
|
||
terminalSourceSeqMin: terminal?.sourceSeqMin ?? null,
|
||
terminalSourceSeqMax: terminal?.sourceSeqMax ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function workbenchTraceReadabilityFailures(evidence) {
|
||
const failures = [];
|
||
const add = (code, message) => failures.push({ code, message, valuesRedacted: true });
|
||
if (evidence.scope?.conversationCount !== 1 || evidence.scope?.debugPanelCountInsideConversation !== 0) {
|
||
add("product_trace_scope_invalid", "产品 Trace 作用域必须只有一个 #conversation-list,且不得包含隔离调试面板。");
|
||
}
|
||
if (evidence.startedDuringRunning !== true || evidence.running?.observed !== true) {
|
||
add("running_phase_not_observed", "命令未在当前 turn 运行期间观测到可读的产品 Trace。");
|
||
}
|
||
const running = evidence.running?.snapshot;
|
||
if (evidence.running?.observed === true
|
||
&& (running?.sourceAuthorityRowCount !== running?.rowCount || running?.projectedAuthorityRowCount !== 0)) {
|
||
add("running_source_authority_missing", "运行中的产品 Trace 行必须全部使用 source sequence authority。");
|
||
}
|
||
if (evidence.disclosure?.openBefore !== true || evidence.disclosure?.autoReadable !== true) {
|
||
add("trace_not_auto_expanded", "产品 Trace 在命令主动展开前没有默认保持可读。");
|
||
}
|
||
if (evidence.terminalArrival?.observed !== true) add("terminal_not_observed", "当前产品 Trace 未在 YAML 超时内进入终态。");
|
||
const terminal = evidence.terminal?.snapshot;
|
||
if (evidence.terminal?.stable !== true || terminal?.disclosure?.open !== true || terminal?.rowCount <= 0) {
|
||
add("terminal_trace_not_retained", "产品 Trace 行在终态后没有保留在展开的详情中。");
|
||
}
|
||
if (terminal?.sourceAuthorityRowCount <= 0
|
||
|| terminal?.readableSourceRowCount <= 0
|
||
|| terminal?.sourceAuthorityRowCount !== terminal?.rowCount
|
||
|| terminal?.projectedAuthorityRowCount !== 0) {
|
||
add("source_authority_missing", "终态产品 Trace 必须展示可读的 source-authority 行,且不得出现 projected-authority 行。");
|
||
}
|
||
if (evidence.running?.observed === true && evidence.runningRetention?.passed !== true) {
|
||
add("running_rows_not_retained", "运行期 Trace 未通过身份重叠或窗口前进合同证明终态保留。");
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
function workbenchTraceReadabilityEvidenceError(message, evidence) {
|
||
const error = new Error(message);
|
||
error.details = {
|
||
sessionId: evidence?.sessionId || null,
|
||
traceId: evidence?.traceId || null,
|
||
scope: evidence?.scope || null,
|
||
disclosure: evidence?.disclosure || null,
|
||
running: evidence?.running || null,
|
||
terminalArrival: evidence?.terminalArrival || null,
|
||
terminal: evidence?.terminal || null,
|
||
runningRetention: evidence?.runningRetention || null,
|
||
retainedRunningRowCount: evidence?.retainedRunningRowCount ?? null,
|
||
failures: evidence?.failures || null,
|
||
screenshot: evidence?.screenshot || null,
|
||
reportPath: evidence?.reportPath || null,
|
||
reportSha256: evidence?.reportSha256 || null,
|
||
valuesRedacted: true
|
||
};
|
||
return error;
|
||
}
|
||
|
||
async function workbenchTraceReadabilityWriteReport(reportDir, report) {
|
||
await mkdir(reportDir, { recursive: true, mode: 0o700 });
|
||
const file = path.join(reportDir, "report.json");
|
||
await writeFile(file, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 });
|
||
const meta = await fileMeta(file);
|
||
const artifact = { kind: "workbench-trace-readability-report", path: file, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
|
||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact });
|
||
return artifact;
|
||
}
|
||
`;
|
||
}
|