1123 lines
64 KiB
TypeScript
1123 lines
64 KiB
TypeScript
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
||
// Responsibility: Runner page sampling, screenshots, DOM digest, response body summaries, and command record source fragment.
|
||
|
||
export function nodeWebObserveRunnerSamplingSource(): string {
|
||
return String.raw`async function preflightSummary() {
|
||
return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) };
|
||
}
|
||
|
||
async function samplePage(reason, options = {}) {
|
||
if (options?.refreshObserver !== false) await maybeRefreshObserverPage(reason);
|
||
const groupSeq = sampleSeq + 1;
|
||
if (page && !page.isClosed()) {
|
||
await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("control-sample-error", { pageRole: "control", pageId, pageEpoch: controlPageEpoch, error: errorSummary(error) })));
|
||
await drainPagePerformanceEvents(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId, pageEpoch: controlPageEpoch })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("control-performance-drain-error", { pageRole: "control", pageId, pageEpoch: controlPageEpoch, error: errorSummary(error) })));
|
||
}
|
||
if (observerPage && !observerPage.isClosed()) {
|
||
await sampleOnePage(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch }).catch((error) => appendJsonl(files.errors, eventRecord("observer-sample-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) })));
|
||
await drainPagePerformanceEvents(observerPage, { reason, groupSeq, pageRole: "observer", targetPageId: observerPageId, pageEpoch: observerPageEpoch })
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("observer-performance-drain-error", { pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, error: errorSummary(error) })));
|
||
}
|
||
if (options?.screenshot !== false && screenshotIntervalMs > 0 && Date.now() - lastScreenshotAtMs >= screenshotIntervalMs) {
|
||
await captureScreenshot("checkpoint", "jpeg")
|
||
.catch((error) => appendJsonl(files.errors, eventRecord("screenshot-error", { pageRole: "control", pageId, error: errorSummary(error) })));
|
||
}
|
||
await writeHeartbeat({ status: terminalStatus });
|
||
}
|
||
|
||
async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) {
|
||
sampleSeq += 1;
|
||
const evaluateTimeoutMs = Math.max(3000, Math.min(8000, Number(sampleIntervalMs) || 5000));
|
||
const dom = await withHardTimeout(targetPage.evaluate((input) => {
|
||
const trim = (value, limit = 500) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit);
|
||
const visible = (element) => {
|
||
if (!element) return false;
|
||
const rect = element.getBoundingClientRect();
|
||
const style = window.getComputedStyle(element);
|
||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||
};
|
||
const textHashInput = (element) => trim(element.textContent || "", 800);
|
||
const loadingTextPattern = /加载中|\bLoading\b/iu;
|
||
const loadingUiPattern = /loading-state|loading-spinner|spinner|progress|skeleton|busy|pending/iu;
|
||
const codeLikeSelector = "pre,code,.trace-row-body,.trace-row-markdown,.markdown-body,.message-text,[class*='trace-row' i],[class*='terminal' i],[class*='log' i],[class*='output' i]";
|
||
const elementTextForLoading = (element) => [
|
||
element.textContent || "",
|
||
element.getAttribute("aria-label") || "",
|
||
element.getAttribute("title") || "",
|
||
element.getAttribute("data-testid") || ""
|
||
].map((value) => trim(value, 240)).filter(Boolean).join(" ");
|
||
const elementLooksLikeLoadingUi = (element) => {
|
||
const signal = [
|
||
element.getAttribute("class") || "",
|
||
element.getAttribute("data-testid") || "",
|
||
element.getAttribute("role") || "",
|
||
element.getAttribute("aria-busy") || "",
|
||
element.getAttribute("aria-label") || "",
|
||
element.getAttribute("title") || ""
|
||
].join(" ");
|
||
return element.getAttribute("aria-busy") === "true" || element.getAttribute("role") === "status" || loadingUiPattern.test(signal);
|
||
};
|
||
const elementIsCodeLike = (element) => Boolean(element.closest(codeLikeSelector)) && !elementLooksLikeLoadingUi(element);
|
||
const hasLoadingText = (element) => {
|
||
const text = elementTextForLoading(element);
|
||
if (!loadingTextPattern.test(text)) return false;
|
||
if (elementIsCodeLike(element)) return false;
|
||
return true;
|
||
};
|
||
const elementDescriptor = (element) => {
|
||
if (!element) return null;
|
||
const className = String(element.className || "").replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" ");
|
||
const identityDescendant = element.querySelector("[data-trace-id], [data-message-id], [data-session-id]");
|
||
return {
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid") || null,
|
||
role: element.getAttribute("role") || null,
|
||
id: element.getAttribute("id") || null,
|
||
className: className || null,
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || identityDescendant?.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || identityDescendant?.getAttribute("data-message-id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || identityDescendant?.getAttribute("data-trace-id") || null,
|
||
ariaLabel: element.getAttribute("aria-label") || null
|
||
};
|
||
};
|
||
const ownerKindFor = (element) => {
|
||
const value = [
|
||
element.getAttribute("data-testid") || "",
|
||
element.getAttribute("class") || "",
|
||
element.getAttribute("role") || "",
|
||
element.tagName || ""
|
||
].join(" ").toLowerCase();
|
||
if (/message|turn|agent|assistant/.test(value)) return "turn";
|
||
if (/session|rail|sidebar|nav/.test(value)) return "session-nav";
|
||
if (/composer|prompt|input|textarea/.test(value)) return "composer";
|
||
if (/diagnostic|alert|error|warning/.test(value)) return "diagnostic";
|
||
if (/trace|event|terminal|log/.test(value)) return "trace";
|
||
if (/performance|metric|chart|table/.test(value)) return "performance";
|
||
if (/main|workspace|workbench|root/.test(value)) return "workbench";
|
||
return "unknown";
|
||
};
|
||
const ownerLabelFor = (element) => {
|
||
const heading = element.querySelector("h1,h2,h3,h4,[data-testid*='title' i],[class*='title' i],[class*='header' i]");
|
||
return trim(
|
||
element.getAttribute("aria-label")
|
||
|| element.getAttribute("data-testid")
|
||
|| (heading ? heading.textContent || "" : "")
|
||
|| element.getAttribute("class")
|
||
|| element.tagName,
|
||
160
|
||
);
|
||
};
|
||
const ownerKeyFor = (element) => {
|
||
const descriptor = elementDescriptor(element) || {};
|
||
return [
|
||
ownerKindFor(element),
|
||
descriptor.testId || descriptor.id || descriptor.role || descriptor.className || descriptor.tag || "unknown",
|
||
descriptor.sessionId || descriptor.messageId || descriptor.traceId || ""
|
||
].filter(Boolean).join(":").slice(0, 240);
|
||
};
|
||
const collectLoadingNodes = () => {
|
||
const candidates = Array.from(document.querySelectorAll("body *"))
|
||
.filter(visible)
|
||
.filter(hasLoadingText)
|
||
.filter((element) => !Array.from(element.children).some((child) => visible(child) && hasLoadingText(child)))
|
||
.slice(-80);
|
||
return candidates.map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const identityOwner = element.closest('[data-message-id], [data-trace-id], [data-session-id]');
|
||
const structuralOwner = element.closest('article.message-card, .message-card, [data-testid*="message" i], [data-testid*="turn" i], [data-testid*="composer" i], [data-testid*="session" i], [class*="composer" i], [class*="session" i], [class*="trace" i], [class*="diagnostic" i], article, section, aside, main, form, [role="status"], [role="alert"], [role="article"], [role="navigation"]');
|
||
const owner = identityOwner || structuralOwner || element;
|
||
const ownerDescriptor = elementDescriptor(owner);
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
className: String(element.className || "").slice(0, 180),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
text: trim(elementTextForLoading(element), 240),
|
||
ownerKind: ownerKindFor(owner),
|
||
ownerKey: ownerKeyFor(owner),
|
||
ownerLabel: ownerLabelFor(owner),
|
||
owner: ownerDescriptor,
|
||
ownerText: trim(owner.textContent || "", 300),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
};
|
||
const sessionIdForElement = (element) => {
|
||
const direct = element.getAttribute("data-session-id");
|
||
if (direct) return direct;
|
||
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
|
||
if (!href) return null;
|
||
try {
|
||
const parsed = new URL(href, location.href);
|
||
const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
} catch {
|
||
const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||
return match ? decodeURIComponent(match[1] || "") : null;
|
||
}
|
||
};
|
||
const sessionTitleTextForElement = (element) => {
|
||
const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]");
|
||
return trim(
|
||
(titleNode ? titleNode.textContent || "" : "")
|
||
|| element.getAttribute("aria-label")
|
||
|| element.getAttribute("title")
|
||
|| element.textContent
|
||
|| "",
|
||
240
|
||
);
|
||
};
|
||
const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu;
|
||
const looksLikeSessionTitleFallback = (title, sessionId) => {
|
||
const text = trim(title, 240);
|
||
const id = String(sessionId || "").trim();
|
||
if (!text) return true;
|
||
if (sessionTitleFallbackPattern.test(text)) return true;
|
||
if (!id) return false;
|
||
return text === id || text.startsWith(id) || text.startsWith("Session " + id);
|
||
};
|
||
const collectSessionRailTitles = () => {
|
||
const candidates = Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))
|
||
.filter(visible);
|
||
const seen = new Set();
|
||
const items = [];
|
||
for (const element of candidates) {
|
||
const sessionId = sessionIdForElement(element);
|
||
const titleText = sessionTitleTextForElement(element);
|
||
const key = (sessionId || "") + "|" + titleText;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
const rect = element.getBoundingClientRect();
|
||
const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId);
|
||
const dataStatus = element.getAttribute("data-status") || element.getAttribute("data-state") || null;
|
||
const dataRunning = element.getAttribute("data-running") || element.getAttribute("data-busy") || null;
|
||
const ariaBusy = element.getAttribute("aria-busy") || null;
|
||
items.push({
|
||
index: items.length,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
|
||
status: dataStatus || ariaBusy || null,
|
||
dataStatus,
|
||
running: dataRunning === "true" || ariaBusy === "true" || element.classList.contains("is-running") || element.classList.contains("running"),
|
||
dataRunning,
|
||
ariaBusy,
|
||
sessionId,
|
||
sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null,
|
||
titleText,
|
||
fallbackTitle,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
});
|
||
}
|
||
const visibleItems = items.slice(0, 120);
|
||
const fallbackItems = visibleItems.filter((item) => item.fallbackTitle);
|
||
const visibleCount = visibleItems.length;
|
||
const fallbackTitleCount = fallbackItems.length;
|
||
return {
|
||
visibleCount,
|
||
fallbackTitleCount,
|
||
fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0,
|
||
activeItem: visibleItems.find((item) => item.active) || null,
|
||
items: visibleItems.slice(0, 60),
|
||
fallbackItems: fallbackItems.slice(0, 12),
|
||
};
|
||
};
|
||
const diagnosticSummaryText = (element) => {
|
||
const summarySelectors = [
|
||
".api-error-diagnostic-summary-text p",
|
||
".api-error-diagnostic-summary-text",
|
||
"[class*='diagnostic-summary-text' i] p",
|
||
"[class*='diagnostic-summary-text' i]",
|
||
"[data-testid*='diagnostic-summary' i]",
|
||
"[data-testid*='error-summary' i]",
|
||
"[role='alert'] p",
|
||
"[role='alert']"
|
||
];
|
||
const parts = [];
|
||
for (const selector of summarySelectors) {
|
||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||
if (!visible(candidate)) continue;
|
||
const text = trim(candidate.textContent || "", 800);
|
||
if (text && !parts.includes(text)) parts.push(text);
|
||
}
|
||
}
|
||
const ownText = textHashInput(element);
|
||
const text = parts.length > 0 ? parts.join(" ") : ownText;
|
||
return text.replace(/\s+(?:!|i诊断|诊断详情)$/u, "").trim();
|
||
};
|
||
const diagnosticToggleOnly = (element, text) => {
|
||
const compact = String(text || "").trim();
|
||
if (!/^(?:!|i诊断|诊断|诊断详情)$/u.test(compact)) return false;
|
||
const tag = element.tagName.toLowerCase();
|
||
const role = element.getAttribute("role");
|
||
const aria = element.getAttribute("aria-label") || "";
|
||
const title = element.getAttribute("title") || "";
|
||
return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title);
|
||
};
|
||
const messageBodyTextDetail = (element) => {
|
||
const bodySelectors = [
|
||
".message-markdown.message-text",
|
||
".message-text",
|
||
"[data-message-body]",
|
||
"[data-testid='message-body']",
|
||
"[data-testid*='message-text' i]",
|
||
"[data-testid*='final-response' i]"
|
||
];
|
||
const parts = [];
|
||
for (const selector of bodySelectors) {
|
||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||
if (!visible(candidate)) continue;
|
||
const text = trim(candidate.textContent || "", 1200);
|
||
if (text && !parts.some((part) => part.text === text)) parts.push({ text, selector });
|
||
}
|
||
}
|
||
if (parts.length > 0) {
|
||
return {
|
||
text: parts.map((part) => part.text).join(" "),
|
||
source: parts[0]?.selector || "body-selector",
|
||
candidateCount: parts.length
|
||
};
|
||
}
|
||
return { text: "", source: null, candidateCount: 0 };
|
||
};
|
||
const stableMessageText = (element) => {
|
||
const body = messageBodyTextDetail(element);
|
||
if (body.text) return body.text;
|
||
const clone = element.cloneNode(true);
|
||
for (const selector of [
|
||
".message-duration-meta",
|
||
".message-activity-meta",
|
||
".api-error-diagnostic",
|
||
"[class*='diagnostic' i]",
|
||
"[class*='trace' i]",
|
||
"[data-trace-id]",
|
||
"[data-testid*='trace' i]",
|
||
"[data-testid*='event' i]",
|
||
"[role='status']",
|
||
"[role='alert']",
|
||
"button"
|
||
]) {
|
||
for (const child of Array.from(clone.querySelectorAll(selector))) child.remove();
|
||
}
|
||
return trim(clone.textContent || "", 1200);
|
||
};
|
||
const numericAttr = (element, names) => {
|
||
for (const name of names) {
|
||
const raw = element.getAttribute(name);
|
||
const value = Number(raw);
|
||
if (Number.isFinite(value)) return value;
|
||
}
|
||
return null;
|
||
};
|
||
const textAttr = (element, names) => {
|
||
for (const name of names) {
|
||
const raw = element.getAttribute(name);
|
||
if (raw && String(raw).trim()) return String(raw).trim();
|
||
}
|
||
return null;
|
||
};
|
||
const summarizeElements = (elements, limit) => elements.filter(visible).slice(-limit).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const owner = element.closest('article.message-card, .message-card[data-message-id], article[data-message-id], [data-trace-id]');
|
||
const timeElement = element.matches("time,[datetime]") ? element : element.querySelector("time,[datetime]");
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || owner?.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || owner?.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || owner?.getAttribute("data-trace-id") || null,
|
||
turnId: element.getAttribute("data-turn-id") || owner?.getAttribute("data-turn-id") || null,
|
||
projectedSeq: numericAttr(element, ["data-projected-seq", "data-projectedseq", "data-seq", "data-sequence", "aria-posinset"]),
|
||
sourceSeq: numericAttr(element, ["data-source-seq", "data-sourceseq", "data-source-event-seq"]),
|
||
eventSeq: numericAttr(element, ["data-event-seq", "data-eventseq"]),
|
||
eventTimestamp: textAttr(element, ["data-event-ts", "data-event-time", "data-timestamp", "datetime"]) || (timeElement ? textAttr(timeElement, ["datetime", "data-event-ts", "data-event-time", "data-timestamp"]) : null),
|
||
eventTimeText: timeElement ? trim(timeElement.textContent || "", 80) : null,
|
||
eventKind: textAttr(element, ["data-event-kind", "data-kind", "data-label", "data-status"]) || element.getAttribute("aria-label") || null,
|
||
text: textHashInput(element),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
const summarize = (selector, limit) => summarizeElements(Array.from(document.querySelectorAll(selector)), limit);
|
||
const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const body = messageBodyTextDetail(element);
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
dataRole: element.getAttribute("data-role"),
|
||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||
sessionId: element.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: element.getAttribute("data-trace-id") || null,
|
||
turnId: element.getAttribute("data-turn-id") || null,
|
||
durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120),
|
||
activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120),
|
||
bodyText: body.text,
|
||
bodyTextSource: body.source,
|
||
bodyTextCandidateCount: body.candidateCount,
|
||
text: stableMessageText(element),
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
});
|
||
const resourceTimingSample = (entry) => ({
|
||
name: entry.name.split(/[?#]/u)[0].slice(0, 240),
|
||
initiatorType: entry.initiatorType,
|
||
startTime: Math.round(entry.startTime),
|
||
duration: Math.round(entry.duration),
|
||
workerStart: Math.round(entry.workerStart || 0),
|
||
redirectStart: Math.round(entry.redirectStart || 0),
|
||
redirectEnd: Math.round(entry.redirectEnd || 0),
|
||
fetchStart: Math.round(entry.fetchStart || 0),
|
||
domainLookupStart: Math.round(entry.domainLookupStart || 0),
|
||
domainLookupEnd: Math.round(entry.domainLookupEnd || 0),
|
||
connectStart: Math.round(entry.connectStart || 0),
|
||
connectEnd: Math.round(entry.connectEnd || 0),
|
||
secureConnectionStart: Math.round(entry.secureConnectionStart || 0),
|
||
requestStart: Math.round(entry.requestStart || 0),
|
||
responseStart: Math.round(entry.responseStart || 0),
|
||
responseEnd: Math.round(entry.responseEnd || 0),
|
||
transferSize: Number.isFinite(Number(entry.transferSize)) ? Number(entry.transferSize) : null,
|
||
encodedBodySize: Number.isFinite(Number(entry.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
|
||
decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
|
||
nextHopProtocol: entry.nextHopProtocol || null,
|
||
responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null,
|
||
serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({
|
||
name: String(item.name || "").slice(0, 80),
|
||
duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null,
|
||
description: String(item.description || "").slice(0, 120)
|
||
})),
|
||
});
|
||
const opaqueDomId = (value) => String(value || "").trim();
|
||
const collectProjectManagement = () => {
|
||
const config = input?.projectManagement || {};
|
||
const targetPaths = Array.isArray(config.targetPaths) ? config.targetPaths : [];
|
||
const path = location.pathname;
|
||
const configuredPath = targetPaths.some((target) => path === target || path.startsWith(String(target) + "/"));
|
||
const root = document.querySelector('[data-testid="project-management-root"]');
|
||
const mdtodoRoot = document.querySelector('[data-testid="project-management-mdtodo"]');
|
||
const rootVisible = visible(root);
|
||
const mdtodoVisible = visible(mdtodoRoot);
|
||
if (!configuredPath && !rootVisible && !mdtodoVisible) return null;
|
||
const sourceItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]')).filter(visible);
|
||
const fileItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]')).filter(visible);
|
||
const sourceSelect = document.querySelector('[data-testid="mdtodo-source-select"]');
|
||
const fileSelect = document.querySelector('[data-testid="mdtodo-file-select"]');
|
||
const sourceOptionCount = sourceSelect ? Array.from(sourceSelect.options || []).filter((option) => option.value).length : 0;
|
||
const fileOptionCount = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).length : 0;
|
||
const fileOptions = fileSelect ? Array.from(fileSelect.options || []).filter((option) => option.value).map((option) => ({
|
||
value: option.value,
|
||
label: trim(option.textContent || option.label || "", 180),
|
||
selected: option.selected === true,
|
||
})) : [];
|
||
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
|
||
const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible);
|
||
const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible);
|
||
const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected');
|
||
const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected');
|
||
const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected');
|
||
const statusCounts = {};
|
||
for (const task of taskItems) {
|
||
const status = task.getAttribute("data-task-status") || "unknown";
|
||
statusCounts[status] = (statusCounts[status] || 0) + 1;
|
||
}
|
||
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
|
||
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
|
||
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
|
||
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
|
||
const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible);
|
||
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
|
||
index,
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
text: trim(element.textContent || "", 260),
|
||
})).filter((item) => item.text);
|
||
const workbenchLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible);
|
||
const measurePaneGap = (name, paneSelector, contentSelector) => {
|
||
const pane = document.querySelector(paneSelector);
|
||
if (!visible(pane)) return { name, visible: false };
|
||
const rect = pane.getBoundingClientRect();
|
||
const contentNodes = Array.from(pane.querySelectorAll(contentSelector)).filter(visible);
|
||
const contentBottom = Math.max(rect.top, ...contentNodes.map((element) => element.getBoundingClientRect().bottom));
|
||
const bottomGapPx = Math.max(0, Math.round(rect.bottom - contentBottom));
|
||
const heightPx = Math.max(0, Math.round(rect.height));
|
||
return {
|
||
name,
|
||
visible: true,
|
||
widthPx: Math.max(0, Math.round(rect.width)),
|
||
heightPx,
|
||
bottomGapPx,
|
||
bottomGapRatio: heightPx > 0 ? Number((bottomGapPx / heightPx).toFixed(3)) : 0,
|
||
contentNodeCount: contentNodes.length,
|
||
};
|
||
};
|
||
const paneGaps = [
|
||
measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', '[data-task-ref], [role="treeitem"], [role="listitem"], li, button, input, select, .task-row-shell, .task-tools'),
|
||
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'),
|
||
measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'),
|
||
];
|
||
return {
|
||
pageKind: mdtodoVisible || path.startsWith("/projects/mdtodo") ? "project-management-mdtodo" : rootVisible || path === "/projects" || path.startsWith("/projects/") ? "project-management-root" : "project-management-unknown",
|
||
configuredPath,
|
||
rootVisible,
|
||
mdtodoVisible,
|
||
sourceCount: Math.max(sourceItems.length, sourceOptionCount),
|
||
fileCount: Math.max(fileItems.length, fileOptionCount),
|
||
taskCount: taskItems.length,
|
||
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
|
||
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value),
|
||
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value),
|
||
selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null,
|
||
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24),
|
||
selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")),
|
||
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
|
||
sourceSelectVisible: visible(sourceSelect),
|
||
fileSelectVisible: visible(fileSelect),
|
||
sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')),
|
||
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')),
|
||
taskBodyVisible: visible(bodyRendered),
|
||
taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "",
|
||
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
|
||
taskStatusCounts: statusCounts,
|
||
reportLinkCount: reportLinks.length,
|
||
reportPreviewVisible: visible(reportPreview),
|
||
reportPreviewText: visible(reportPreview) ? trim(reportPreview.textContent || "", 500) : "",
|
||
reportFullscreenVisible: visible(reportFullscreen),
|
||
launchButtonVisible: visible(launch),
|
||
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
|
||
launchButtonText: trim(launch?.textContent || "", 120),
|
||
blockerCount: blockers.length,
|
||
blockers,
|
||
paneGaps,
|
||
workbenchLinkCount: workbenchLinks.length,
|
||
valuesRedacted: true,
|
||
};
|
||
};
|
||
const url = location.href;
|
||
const routeSessionMatch = url.match(/\/workbench\/sessions\/([^/?#]+)/u);
|
||
const activeSession = document.querySelector('[data-active="true"][data-session-id], [aria-selected="true"][data-session-id], .active[data-session-id]');
|
||
const activeSessionId = activeSession ? activeSession.getAttribute("data-session-id") : null;
|
||
const commandInput = document.querySelector("#command-input");
|
||
const commandSubmit = document.querySelector('#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]');
|
||
const composerWarning = document.querySelector(".composer-warning");
|
||
const messageSelector = 'article.message-card, .message-card[data-message-id], article[data-message-id]';
|
||
const stableTraceSelector = 'li.trace-render-row[data-row-id], li.trace-render-row[data-testid="trace-render-row"], [data-testid="trace-render-row"][data-row-id]';
|
||
const fallbackTraceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]';
|
||
const diagnosticSelector = '.api-error-diagnostic, [class*="api-error-diagnostic" i], [class*="message-diagnostic" i], [class*="projection-diagnostic" i], [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [data-testid*="diagnostic" i], [role="alert"], [aria-live="assertive"]';
|
||
const messages = summarizeMessages(messageSelector, 80);
|
||
const stableTraceElements = Array.from(document.querySelectorAll(stableTraceSelector));
|
||
const fallbackTraceElements = Array.from(document.querySelectorAll(fallbackTraceSelector)).filter((element) => element.matches('li,[role="listitem"],[data-testid*="trace-row" i],[data-testid*="event-row" i]'));
|
||
const traceRows = summarizeElements(stableTraceElements.length > 0 ? stableTraceElements : fallbackTraceElements, 30);
|
||
const loadings = collectLoadingNodes();
|
||
const sessionRail = collectSessionRailTitles();
|
||
const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const text = diagnosticSummaryText(element);
|
||
if (!text || diagnosticToggleOnly(element, text)) return null;
|
||
const traceMatch = text.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu);
|
||
const httpStatusMatch = text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu);
|
||
const idleMatch = text.match(/\bidle\s+(\d+)s\b/iu);
|
||
const waitingForMatch = text.match(/\bwaitingFor=([^\s;;,,)]+)/iu);
|
||
const lastEventLabelMatch = text.match(/\blastEventLabel=([^\s;;,,)]+)/iu);
|
||
const diagnosticCode = httpStatusMatch ? "http-" + httpStatusMatch[1] : /turn\s*超过|无新活动/iu.test(text) ? "turn-idle-no-activity" : /Failed to fetch/iu.test(text) ? "failed-to-fetch" : "diagnostic";
|
||
return {
|
||
index,
|
||
tag: element.tagName.toLowerCase(),
|
||
className: String(element.className || "").slice(0, 240),
|
||
testId: element.getAttribute("data-testid"),
|
||
role: element.getAttribute("role"),
|
||
compact: element.getAttribute("data-compact"),
|
||
expanded: element.getAttribute("data-expanded") || element.getAttribute("aria-expanded"),
|
||
title: element.getAttribute("title"),
|
||
diagnosticCode,
|
||
traceId: traceMatch?.[1] || null,
|
||
httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null,
|
||
idleSeconds: idleMatch ? Number(idleMatch[1]) : null,
|
||
waitingFor: waitingForMatch?.[1] || null,
|
||
lastEventLabel: lastEventLabelMatch?.[1] || null,
|
||
text,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
}).filter(Boolean);
|
||
const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => {
|
||
const rect = element.getBoundingClientRect();
|
||
const text = textHashInput(element);
|
||
const finalResponse = messageBodyTextDetail(element);
|
||
const traceElement = element.matches("[data-trace-id]") ? element : element.querySelector("[data-trace-id]");
|
||
const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||
const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120);
|
||
const activityText = trim(element.querySelector(".message-activity-meta")?.textContent || "", 120);
|
||
const directTraceId = element.getAttribute("data-trace-id") || traceElement?.getAttribute("data-trace-id") || traceMatch?.[0] || null;
|
||
return {
|
||
index,
|
||
role: element.getAttribute("data-role") || "agent",
|
||
status: element.getAttribute("data-status") || null,
|
||
sessionId: element.getAttribute("data-session-id") || null,
|
||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||
traceId: directTraceId,
|
||
turnId: element.getAttribute("data-turn-id") || directTraceId,
|
||
durationText,
|
||
activityText,
|
||
finalResponseText: finalResponse.text,
|
||
finalResponseTextSource: finalResponse.source,
|
||
finalResponseCandidateCount: finalResponse.candidateCount,
|
||
finalResponsePresent: Boolean(finalResponse.text && finalResponse.text.trim()),
|
||
text,
|
||
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||
};
|
||
}).slice(-80);
|
||
const active = document.activeElement;
|
||
return {
|
||
url,
|
||
path: location.pathname,
|
||
routeSessionId: routeSessionMatch ? decodeURIComponent(routeSessionMatch[1]) : null,
|
||
activeSessionId,
|
||
title: document.title,
|
||
focus: active ? { tag: active.tagName.toLowerCase(), testId: active.getAttribute("data-testid"), role: active.getAttribute("role") } : null,
|
||
viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio },
|
||
scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY), height: Math.round(document.documentElement.scrollHeight), width: Math.round(document.documentElement.scrollWidth) },
|
||
messages,
|
||
traceRows,
|
||
loadings,
|
||
sessionRail,
|
||
diagnostics,
|
||
turns,
|
||
composer: {
|
||
inputPresent: visible(commandInput),
|
||
inputDisabled: Boolean(commandInput?.disabled) || commandInput?.getAttribute("aria-disabled") === "true",
|
||
warningPresent: visible(composerWarning),
|
||
warningText: trim(composerWarning?.textContent || "", 160),
|
||
submitPresent: visible(commandSubmit),
|
||
submitDisabled: Boolean(commandSubmit?.disabled) || commandSubmit?.getAttribute("aria-disabled") === "true",
|
||
submitAction: commandSubmit?.getAttribute("data-action") || null,
|
||
submitText: trim(commandSubmit?.textContent || "", 80),
|
||
submitTestId: commandSubmit?.getAttribute("data-testid") || null,
|
||
activeStatus: activeSession?.getAttribute("data-status") || null,
|
||
valuesRedacted: true
|
||
},
|
||
projectManagement: collectProjectManagement(),
|
||
pageProvenance: {
|
||
url: location.href,
|
||
path: location.pathname,
|
||
title: document.title,
|
||
readyState: document.readyState,
|
||
timeOrigin: Math.round(performance.timeOrigin || 0),
|
||
navigationStartTime: (performance.getEntriesByType("navigation")[0] || null)?.startTime ?? null,
|
||
scripts: Array.from(document.scripts).map((element) => {
|
||
if (!element.src) return null;
|
||
try {
|
||
const url = new URL(element.src, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).filter(Boolean).sort(),
|
||
stylesheets: Array.from(document.querySelectorAll('link[rel~="stylesheet"][href]')).map((element) => {
|
||
try {
|
||
const url = new URL(element.href, location.href);
|
||
const keys = Array.from(url.searchParams.keys()).sort();
|
||
return url.pathname + (keys.length > 0 ? "?keys=" + keys.join(",") : "");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}).filter(Boolean).sort(),
|
||
meta: Array.from(document.querySelectorAll("meta[name], meta[property]")).map((element) => ({
|
||
key: String(element.getAttribute("name") || element.getAttribute("property") || "").slice(0, 120),
|
||
content: String(element.getAttribute("content") || "").slice(0, 200),
|
||
})).filter((item) => item.key).sort((a, b) => a.key.localeCompare(b.key)),
|
||
},
|
||
performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample),
|
||
};
|
||
}, { projectManagement }), evaluateTimeoutMs, "sampleOnePage DOM evaluate exceeded " + evaluateTimeoutMs + "ms").catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
|
||
const sample = {
|
||
seq: sampleSeq,
|
||
sampleGroupSeq: groupSeq,
|
||
ts: new Date().toISOString(),
|
||
reason,
|
||
pageRole,
|
||
pageId: targetPageId,
|
||
pageEpoch: Number.isFinite(Number(pageEpoch)) ? Number(pageEpoch) : 0,
|
||
commandId: activeCommandId,
|
||
observerInitiated: false,
|
||
...digestDom(dom, pageRole),
|
||
};
|
||
await appendJsonl(files.samples, sample);
|
||
}
|
||
|
||
function digestDom(dom, pageRole = "control") {
|
||
if (dom && dom.error) return dom;
|
||
const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), bodyTextHash: sha256Text(item.bodyText || ""), bodyTextPreview: truncate(item.bodyText || "", 160), bodyTextBytes: Buffer.byteLength(item.bodyText || ""), bodyTextPresent: Boolean(String(item.bodyText || "").trim()) })) : [];
|
||
const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : [];
|
||
const sessionRail = digestSessionRail(dom.sessionRail);
|
||
const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : [];
|
||
const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || ""), finalResponseTextHash: sha256Text(item.finalResponseText || ""), finalResponseTextPreview: truncate(item.finalResponseText || "", 200), finalResponseTextBytes: Buffer.byteLength(item.finalResponseText || ""), finalResponsePresent: Boolean(String(item.finalResponseText || "").trim()) })) : [];
|
||
const projectManagementSample = digestProjectManagement(dom.projectManagement);
|
||
const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq });
|
||
if (pageRole === "control") currentPageProvenance = pageProvenance;
|
||
return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, projectManagement: projectManagementSample, pageProvenance: compactPageProvenance(pageProvenance) };
|
||
}
|
||
|
||
function digestProjectManagement(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const opaque = (raw) => {
|
||
const text = String(raw || "");
|
||
if (!text) return null;
|
||
return {
|
||
hash: sha256Text(text),
|
||
preview: text.length <= 18 ? text : text.slice(0, 10) + "..." + text.slice(-5),
|
||
bytes: Buffer.byteLength(text),
|
||
valuesRedacted: true
|
||
};
|
||
};
|
||
const textDigest = (raw, limit = 160) => {
|
||
const text = String(raw || "");
|
||
return { textHash: sha256Text(text), textPreview: truncate(text, limit), textBytes: Buffer.byteLength(text), valuesRedacted: true };
|
||
};
|
||
const fileLabelLooksDirect = (label) => /^[^/\\]+\.md$/iu.test(String(label || "").trim());
|
||
const suspiciousFileLabel = (label) => {
|
||
const text = String(label || "").trim();
|
||
return Boolean(text && (!fileLabelLooksDirect(text) || /(?:details\/|_Task_Report|_log_|\/)/iu.test(text)));
|
||
};
|
||
const fileLabels = Array.isArray(value.fileOptionLabels) ? value.fileOptionLabels.map((item) => String(item || "")).filter(Boolean) : [];
|
||
return {
|
||
pageKind: value.pageKind ?? null,
|
||
configuredPath: value.configuredPath === true,
|
||
rootVisible: value.rootVisible === true,
|
||
mdtodoVisible: value.mdtodoVisible === true,
|
||
sourceCount: Number.isFinite(Number(value.sourceCount)) ? Number(value.sourceCount) : 0,
|
||
fileCount: Number.isFinite(Number(value.fileCount)) ? Number(value.fileCount) : 0,
|
||
taskCount: Number.isFinite(Number(value.taskCount)) ? Number(value.taskCount) : 0,
|
||
taskRefMissingCount: Number.isFinite(Number(value.taskRefMissingCount)) ? Number(value.taskRefMissingCount) : 0,
|
||
selectedSourceId: opaque(value.selectedSourceId),
|
||
selectedFileRef: opaque(value.selectedFileRef),
|
||
selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null,
|
||
selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null,
|
||
fileOptionLabelSamples: fileLabels.slice(0, 10).map((item) => textDigest(item, 120)),
|
||
fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length,
|
||
selectedTaskRef: opaque(value.selectedTaskRef),
|
||
selectedTaskStatus: value.selectedTaskStatus ?? null,
|
||
sourceSelectVisible: value.sourceSelectVisible === true,
|
||
fileSelectVisible: value.fileSelectVisible === true,
|
||
sourceConfigVisible: value.sourceConfigVisible === true,
|
||
taskEditorVisible: value.taskEditorVisible === true,
|
||
taskBodyVisible: value.taskBodyVisible === true,
|
||
taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null,
|
||
newTaskDraftVisible: value.newTaskDraftVisible === true,
|
||
taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {},
|
||
reportLinkCount: Number.isFinite(Number(value.reportLinkCount)) ? Number(value.reportLinkCount) : 0,
|
||
reportPreviewVisible: value.reportPreviewVisible === true,
|
||
reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null,
|
||
reportFullscreenVisible: value.reportFullscreenVisible === true,
|
||
launchButtonVisible: value.launchButtonVisible === true,
|
||
launchButtonEnabled: value.launchButtonEnabled === true,
|
||
launchButtonText: value.launchButtonText ? textDigest(value.launchButtonText, 120) : null,
|
||
blockerCount: Number.isFinite(Number(value.blockerCount)) ? Number(value.blockerCount) : 0,
|
||
blockers: Array.isArray(value.blockers) ? value.blockers.slice(0, 12).map((item) => ({
|
||
index: item?.index ?? null,
|
||
testId: item?.testId ?? null,
|
||
role: item?.role ?? null,
|
||
...textDigest(item?.text || "", 160),
|
||
})) : [],
|
||
paneGaps: Array.isArray(value.paneGaps) ? value.paneGaps.slice(0, 8).map((item) => ({
|
||
name: item?.name ?? null,
|
||
visible: item?.visible === true,
|
||
widthPx: Number.isFinite(Number(item?.widthPx)) ? Number(item.widthPx) : null,
|
||
heightPx: Number.isFinite(Number(item?.heightPx)) ? Number(item.heightPx) : null,
|
||
bottomGapPx: Number.isFinite(Number(item?.bottomGapPx)) ? Number(item.bottomGapPx) : null,
|
||
bottomGapRatio: Number.isFinite(Number(item?.bottomGapRatio)) ? Number(item.bottomGapRatio) : null,
|
||
contentNodeCount: Number.isFinite(Number(item?.contentNodeCount)) ? Number(item.contentNodeCount) : null,
|
||
valuesRedacted: true,
|
||
})) : [],
|
||
workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function digestSessionRail(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const items = Array.isArray(value.items) ? value.items.map((item) => {
|
||
const titleText = String(item?.titleText || item?.titlePreview || "");
|
||
return {
|
||
index: item?.index ?? null,
|
||
tag: item?.tag ?? null,
|
||
testId: item?.testId ?? null,
|
||
role: item?.role ?? null,
|
||
active: item?.active === true,
|
||
status: item?.status ?? null,
|
||
dataStatus: item?.dataStatus ?? null,
|
||
running: item?.running === true,
|
||
dataRunning: item?.dataRunning ?? null,
|
||
ariaBusy: item?.ariaBusy ?? null,
|
||
sessionId: item?.sessionId ?? null,
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
|
||
fallbackTitle: item?.fallbackTitle === true,
|
||
titleHash: sha256Text(titleText),
|
||
titlePreview: truncate(titleText, 160),
|
||
titleBytes: Buffer.byteLength(titleText),
|
||
rect: item?.rect ?? null,
|
||
};
|
||
}) : [];
|
||
const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12);
|
||
const visibleCount = Number(value.visibleCount ?? items.length);
|
||
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
|
||
return {
|
||
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
|
||
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
|
||
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
|
||
activeItem: items.find((item) => item.active) || null,
|
||
items,
|
||
fallbackItems,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
|
||
async function captureScreenshot(reason, imageType = "png") {
|
||
if (!page || page.isClosed()) throw new Error("page is not available for screenshot");
|
||
if (screenshotCaptureState && screenshotCaptureState.settled !== true) {
|
||
const ageMs = Date.now() - Number(screenshotCaptureState.startedAtMs || Date.now());
|
||
const error = new Error("screenshot capture already in progress");
|
||
error.details = {
|
||
reason,
|
||
currentUrl: currentPageUrl(),
|
||
pageId,
|
||
activeReason: screenshotCaptureState.reason,
|
||
activeStartedAt: screenshotCaptureState.startedAt,
|
||
activeAgeMs: ageMs,
|
||
activeTimedOut: screenshotCaptureState.timedOut === true,
|
||
timeoutMs: screenshotCaptureTimeoutMs,
|
||
valuesRedacted: true,
|
||
};
|
||
lastScreenshotAtMs = Date.now();
|
||
throw error;
|
||
}
|
||
artifactSeq += 1;
|
||
const safeReason = safeId(String(reason || "manual")).slice(0, 40) || "manual";
|
||
const type = imageType === "jpeg" || imageType === "jpg" ? "jpeg" : "png";
|
||
const ext = type === "jpeg" ? "jpg" : "png";
|
||
const file = path.join(dirs.screenshots, String(sampleSeq).padStart(6, "0") + "_" + String(artifactSeq).padStart(4, "0") + "_" + safeReason + "." + ext);
|
||
const timeoutMs = screenshotCaptureTimeoutMs;
|
||
const options = type === "jpeg"
|
||
? { path: file, type: "jpeg", quality: 70, fullPage: false, animations: "disabled", timeout: timeoutMs }
|
||
: { path: file, type: "png", fullPage: false, animations: "disabled", timeout: timeoutMs };
|
||
const state = { reason, startedAtMs: Date.now(), startedAt: new Date().toISOString(), timeoutMs, settled: false, timedOut: false };
|
||
screenshotCaptureState = state;
|
||
const screenshotPromise = page.screenshot(options)
|
||
.then((value) => {
|
||
state.settled = true;
|
||
return value;
|
||
})
|
||
.catch((error) => {
|
||
state.settled = true;
|
||
throw error;
|
||
})
|
||
.finally(() => {
|
||
if (screenshotCaptureState === state) screenshotCaptureState = null;
|
||
});
|
||
try {
|
||
await withHardTimeout(screenshotPromise, timeoutMs + 1000, "captureScreenshot " + safeReason + " exceeded " + timeoutMs + "ms");
|
||
const meta = await fileMeta(file);
|
||
const artifact = { seq: artifactSeq, sampleSeq, ts: new Date().toISOString(), kind: "screenshot", reason, path: file, type, byteCount: meta.byteCount, sha256: meta.sha256, pageId, currentUrl: currentPageUrl(), timeoutMs };
|
||
await appendJsonl(files.artifacts, artifact);
|
||
lastScreenshotAtMs = Date.now();
|
||
return artifact;
|
||
} catch (error) {
|
||
if (String(error?.message || "").includes("exceeded " + timeoutMs + "ms")) state.timedOut = true;
|
||
lastScreenshotAtMs = Date.now();
|
||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||
wrapped.details = {
|
||
...(wrapped.details || {}),
|
||
reason,
|
||
currentUrl: currentPageUrl(),
|
||
pageId,
|
||
timeoutMs,
|
||
file,
|
||
valuesRedacted: true,
|
||
};
|
||
throw wrapped;
|
||
}
|
||
}
|
||
|
||
async function captureCommandScreenshot(command) {
|
||
const shouldWaitProject = command.waitProjectManagementReady === true;
|
||
const readiness = shouldWaitProject ? await waitForProjectManagementCommandReady({ timeoutMs: 15000 }) : null;
|
||
if (readiness && readiness.ok !== true) {
|
||
const error = new Error("screenshot project-management readiness wait failed: " + (readiness.reason || "not-ready"));
|
||
error.details = { readiness, currentUrl: currentPageUrl(), pageId, valuesRedacted: true };
|
||
throw error;
|
||
}
|
||
const artifact = await captureScreenshot(command.reason || command.label || "manual", command.imageType || "png");
|
||
return { ...artifact, readiness, valuesRedacted: true };
|
||
}
|
||
|
||
function eventRecord(type, data) {
|
||
const clean = sanitize(data) || {};
|
||
return { ts: new Date().toISOString(), type, jobId, pageId: clean.pageId ?? pageId, pageRole: clean.pageRole ?? "control", sampleSeq, commandId: activeCommandId, ...clean };
|
||
}
|
||
|
||
async function summarizeWorkbenchResponseBody(response, request) {
|
||
const method = String(request.method() || "GET").toUpperCase();
|
||
const path = safeUrlPath(response.url()) || "";
|
||
const resourceType = String(request.resourceType() || "");
|
||
const status = Number(response.status());
|
||
if (!shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status })) return { bodyRead: false };
|
||
const headers = response.headers();
|
||
const contentType = String(headers["content-type"] || headers["Content-Type"] || "");
|
||
if (!/json/iu.test(contentType)) return { bodyRead: false, bodyReadSkipped: "non-json", valuesRedacted: true };
|
||
const contentLength = Number(headers["content-length"] || headers["Content-Length"]);
|
||
const maxBytes = 512 * 1024;
|
||
if (Number.isFinite(contentLength) && contentLength > maxBytes) return { bodyRead: false, bodyReadSkipped: "content-length-too-large", bodyByteCount: contentLength, valuesRedacted: true };
|
||
const text = await response.text();
|
||
const byteCount = Buffer.byteLength(text);
|
||
if (byteCount > maxBytes) return { bodyRead: true, bodyReadSkipped: "body-too-large", bodyByteCount: byteCount, bodyHash: sha256Text(text), valuesRedacted: true };
|
||
let parsed = null;
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
} catch (error) {
|
||
return { bodyRead: true, bodyReadSkipped: "json-parse-error", bodyByteCount: byteCount, bodyHash: sha256Text(text), bodyParseError: errorSummary(error), valuesRedacted: true };
|
||
}
|
||
return {
|
||
bodyRead: true,
|
||
bodyByteCount: byteCount,
|
||
bodyHash: sha256Text(text),
|
||
bodySummary: summarizeWorkbenchJsonBody(parsed, path),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function shouldSummarizeWorkbenchResponseBody({ method, path, resourceType, status }) {
|
||
if (method !== "GET" && method !== "POST") return false;
|
||
if (!Number.isFinite(status) || status < 200 || status >= 300) return false;
|
||
if (resourceType === "eventsource" || path === "/v1/workbench/events") return false;
|
||
return path === "/v1/agent/chat"
|
||
|| path === "/v1/agent/chat/steer"
|
||
|| path === "/v1/workbench/sessions"
|
||
|| /^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)
|
||
|| /^\/v1\/workbench\/turns\/[^/]+$/u.test(path)
|
||
|| /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path);
|
||
}
|
||
|
||
function summarizeWorkbenchJsonBody(value, path) {
|
||
const traceIds = new Set();
|
||
const sessionIds = new Set();
|
||
const terminalTraceIds = new Set();
|
||
const statusCounts = {};
|
||
const counters = {
|
||
objectCount: 0,
|
||
arrayCount: 0,
|
||
traceEventLikeCount: 0,
|
||
messageLikeCount: 0,
|
||
turnLikeCount: 0,
|
||
terminalStatusCount: 0,
|
||
runningStatusCount: 0,
|
||
terminalTextCount: 0,
|
||
finalTextFieldCount: 0,
|
||
finalTextByteCount: 0
|
||
};
|
||
|
||
const visit = (node, key = "", parent = null, depth = 0) => {
|
||
if (depth > 32 || node === null || node === undefined) return;
|
||
if (typeof node === "string") {
|
||
collectWorkbenchIdsFromText(node, traceIds, sessionIds);
|
||
const normalizedStatus = normalizeWorkbenchStatus(key, node);
|
||
if (normalizedStatus) {
|
||
statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1;
|
||
if (isWorkbenchTerminalStatus(normalizedStatus)) counters.terminalStatusCount += 1;
|
||
if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1;
|
||
}
|
||
if (isWorkbenchTerminalText(node)) {
|
||
counters.terminalTextCount += 1;
|
||
for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId);
|
||
}
|
||
if (isLikelyWorkbenchFinalTextField(key, parent, node)) {
|
||
counters.finalTextFieldCount += 1;
|
||
counters.finalTextByteCount += Buffer.byteLength(node);
|
||
for (const traceId of workbenchTraceIdsFromRecord(parent)) terminalTraceIds.add(traceId);
|
||
}
|
||
return;
|
||
}
|
||
if (typeof node !== "object") return;
|
||
if (Array.isArray(node)) {
|
||
counters.arrayCount += 1;
|
||
for (const item of node) visit(item, key, parent, depth + 1);
|
||
return;
|
||
}
|
||
counters.objectCount += 1;
|
||
const record = node;
|
||
const recordTraceIds = workbenchTraceIdsFromRecord(record);
|
||
for (const raw of [record.traceId, record.trace_id, record.id, record.turnId, record.messageId]) {
|
||
if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds);
|
||
}
|
||
for (const raw of [record.sessionId, record.session_id]) {
|
||
if (typeof raw === "string") collectWorkbenchIdsFromText(raw, traceIds, sessionIds);
|
||
}
|
||
const statusValue = record.status ?? record.state ?? record.phase ?? record.result ?? record.lifecycle;
|
||
if (typeof statusValue === "string") {
|
||
const normalizedStatus = normalizeWorkbenchStatus("status", statusValue);
|
||
if (normalizedStatus) {
|
||
statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] || 0) + 1;
|
||
if (isWorkbenchTerminalStatus(normalizedStatus)) {
|
||
counters.terminalStatusCount += 1;
|
||
for (const traceId of recordTraceIds) terminalTraceIds.add(traceId);
|
||
}
|
||
if (isWorkbenchRunningStatus(normalizedStatus)) counters.runningStatusCount += 1;
|
||
}
|
||
}
|
||
if (record.traceId || record.trace_id || record.turnId || record.turn_id) counters.turnLikeCount += 1;
|
||
if (record.messageId || record.message_id || record.role || record.author || record.content || record.text || record.finalResponse) counters.messageLikeCount += 1;
|
||
if (record.projectedSeq !== undefined || record.sourceSeq !== undefined || record.eventSeq !== undefined || record.eventKind !== undefined || record.eventTimestamp !== undefined) counters.traceEventLikeCount += 1;
|
||
for (const [childKey, childValue] of Object.entries(record)) visit(childValue, childKey, record, depth + 1);
|
||
};
|
||
visit(value);
|
||
return {
|
||
pathKind: workbenchBodyPathKind(path),
|
||
traceIds: Array.from(traceIds).sort().slice(0, 12),
|
||
terminalTraceIds: Array.from(terminalTraceIds).sort().slice(0, 12),
|
||
sessionIds: Array.from(sessionIds).sort().slice(0, 12),
|
||
statusCounts,
|
||
...counters,
|
||
terminalEvidenceCount: counters.terminalStatusCount + counters.terminalTextCount,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function collectWorkbenchIdsFromText(value, traceIds, sessionIds) {
|
||
const text = String(value || "");
|
||
for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) traceIds.add(match[0]);
|
||
for (const match of text.matchAll(/\bses_[A-Za-z0-9_-]+\b/gu)) sessionIds.add(match[0]);
|
||
}
|
||
|
||
function workbenchTraceIdsFromRecord(record) {
|
||
if (!record || typeof record !== "object") return [];
|
||
const values = [record.traceId, record.trace_id, record.turnId, record.turn_id, record.messageId, record.message_id, record.id];
|
||
const ids = new Set();
|
||
for (const raw of values) {
|
||
if (typeof raw !== "string") continue;
|
||
for (const match of raw.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]);
|
||
}
|
||
return Array.from(ids).sort();
|
||
}
|
||
|
||
function normalizeWorkbenchStatus(key, value) {
|
||
if (!/status|state|phase|result|lifecycle/iu.test(String(key || ""))) return null;
|
||
const text = String(value || "").toLowerCase().replace(/[^a-z0-9_-]+/gu, "-").replace(/^-+|-+$/gu, "");
|
||
if (!text) return null;
|
||
if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(text)) return "completed";
|
||
if (/^(failed|failure|error|errored)$/u.test(text)) return "failed";
|
||
if (/^(canceled|cancelled|aborted|cancel)$/u.test(text)) return "canceled";
|
||
if (/^(running|active|in-progress|in_progress|processing|streaming|executing)$/u.test(text)) return "running";
|
||
if (/^(queued|pending|admitted|created|waiting)$/u.test(text)) return "pending";
|
||
return text.slice(0, 80);
|
||
}
|
||
|
||
function isWorkbenchTerminalStatus(value) {
|
||
return value === "completed" || value === "failed" || value === "canceled";
|
||
}
|
||
|
||
function isWorkbenchRunningStatus(value) {
|
||
return value === "running" || value === "pending";
|
||
}
|
||
|
||
function isWorkbenchTerminalText(value) {
|
||
return /轮次完成|轮次失败|轮次取消|已记录|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(value || ""));
|
||
}
|
||
|
||
function isLikelyWorkbenchFinalTextField(key, parent, value) {
|
||
const text = String(value || "").trim();
|
||
if (!text) return false;
|
||
const field = String(key || "");
|
||
if (!/final|assistant|response|content|markdown|text|message|output|result/iu.test(field)) return false;
|
||
const parentStatus = normalizeWorkbenchStatus("status", parent?.status ?? parent?.state ?? parent?.phase ?? parent?.result ?? "");
|
||
const parentRole = String(parent?.role ?? parent?.author ?? parent?.dataRole ?? "").toLowerCase();
|
||
return isWorkbenchTerminalStatus(parentStatus) || /assistant|agent|code/iu.test(parentRole) || /final|response|result/iu.test(field);
|
||
}
|
||
|
||
function workbenchBodyPathKind(path) {
|
||
if (path === "/v1/agent/chat" || path === "/v1/agent/chat/steer") return "agent-chat-submit";
|
||
if (path === "/v1/workbench/sessions") return "workbench-sessions";
|
||
if (/^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)) return "workbench-session-messages";
|
||
if (/^\/v1\/workbench\/turns\/[^/]+$/u.test(path)) return "workbench-turn";
|
||
if (/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path)) return "workbench-trace-events";
|
||
return "workbench";
|
||
}
|
||
|
||
function controlRecord(command, phase, detail) {
|
||
return {
|
||
ts: new Date().toISOString(),
|
||
seq: commandSeq,
|
||
phase,
|
||
commandId: command.id,
|
||
type: command.type,
|
||
source: command.source || "file",
|
||
input: commandInputSummary(command),
|
||
beforeUrl: command.beforeUrl || null,
|
||
afterUrl: currentPageUrl(),
|
||
pageId,
|
||
detail: sanitize(detail),
|
||
};
|
||
}
|
||
|
||
function commandInputSummary(command) {
|
||
const text = typeof command.text === "string" ? command.text : null;
|
||
const opaque = (value) => {
|
||
const raw = typeof value === "string" ? value : null;
|
||
if (!raw) return null;
|
||
return {
|
||
hash: sha256Text(raw),
|
||
preview: raw.length <= 18 ? raw : raw.slice(0, 10) + "..." + raw.slice(-5),
|
||
bytes: Buffer.byteLength(raw),
|
||
valuesRedacted: true
|
||
};
|
||
};
|
||
return {
|
||
type: command.type,
|
||
path: command.path || null,
|
||
url: command.url ? safeUrl(command.url) : null,
|
||
sessionId: command.sessionId || command.value || null,
|
||
provider: command.provider || null,
|
||
afterRound: Number.isInteger(Number(command.afterRound)) ? Number(command.afterRound) : null,
|
||
severity: command.severity || null,
|
||
alternateSessionStrategy: command.alternateSessionStrategy || null,
|
||
expectedSentinelRange: command.expectedSentinelRange || null,
|
||
expectedActionWaitMs: command.expectedActionWaitMs === null || command.expectedActionWaitMs === undefined || command.expectedActionWaitMs === "" ? null : Number(command.expectedActionWaitMs),
|
||
durationMs: command.durationMs === null || command.durationMs === undefined || command.durationMs === "" ? null : Number(command.durationMs),
|
||
requireComposerReady: command.requireComposerReady === true,
|
||
waitProjectManagementReady: command.waitProjectManagementReady === true,
|
||
findingId: command.findingId || null,
|
||
blocking: command.blocking === true ? true : command.blocking === false ? false : null,
|
||
sourceId: opaque(command.sourceId),
|
||
fileRef: opaque(command.fileRef),
|
||
filename: command.filename ? truncate(command.filename, 200) : null,
|
||
taskRef: opaque(command.taskRef),
|
||
taskId: command.taskId || null,
|
||
field: command.field || null,
|
||
link: command.link ? truncate(command.link, 200) : null,
|
||
titleHash: command.title ? sha256Text(command.title) : null,
|
||
titleBytes: command.title ? Buffer.byteLength(command.title) : null,
|
||
bodyHash: command.body ? sha256Text(command.body) : null,
|
||
bodyBytes: command.body ? Buffer.byteLength(command.body) : null,
|
||
status: command.status || null,
|
||
hwpodId: opaque(command.hwpodId),
|
||
nodeId: opaque(command.nodeId),
|
||
workspaceRoot: opaque(command.workspaceRoot),
|
||
root: opaque(command.root),
|
||
label: command.label ? truncate(command.label, 200) : null,
|
||
textHash: text === null ? null : sha256Text(text),
|
||
textBytes: text === null ? null : Buffer.byteLength(text),
|
||
textPreview: null,
|
||
valuesRedacted: true,
|
||
};
|
||
}
|
||
`;
|
||
}
|