fix: 固化 Workbench Trace 可读性探针
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
// 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 windowed = terminal?.disclosure?.rowWindowed === true;
|
||||
if (!windowed) {
|
||||
return {
|
||||
mode: "identity-overlap",
|
||||
passed: retainedRowCount > 0,
|
||||
retainedRowCount,
|
||||
valuesRedacted: 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;
|
||||
return {
|
||||
mode: "window-progress",
|
||||
passed: countsAdvance && sequenceWindowAdvances,
|
||||
retainedRowCount,
|
||||
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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user