feat: add project-management web-probe observe
This commit is contained in:
@@ -25,6 +25,7 @@ const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERV
|
||||
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
|
||||
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
|
||||
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
|
||||
const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON);
|
||||
const playwrightProxy = proxyConfigFromEnv(baseUrl);
|
||||
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
|
||||
const pageId = "control-" + randomBytes(4).toString("hex");
|
||||
@@ -177,6 +178,7 @@ async function writeManifest(extra = {}) {
|
||||
pageProvenance: compactPageProvenance(currentPageProvenance),
|
||||
sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false },
|
||||
alertThresholds,
|
||||
projectManagement,
|
||||
jsonlRotation,
|
||||
commandDirs: dirs,
|
||||
artifacts: files,
|
||||
@@ -346,6 +348,10 @@ async function processCommand(command) {
|
||||
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
|
||||
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
|
||||
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
|
||||
case "selectProjectSource": return selectProjectSource(command);
|
||||
case "selectMdtodoFile": return selectMdtodoFile(command);
|
||||
case "selectMdtodoTask": return selectMdtodoTask(command);
|
||||
case "launchWorkbenchFromTask": return withObserverSync(await launchWorkbenchFromTask(command), "launchWorkbenchFromTask");
|
||||
case "screenshot": return captureScreenshot(command.reason || "manual", command.imageType || "png");
|
||||
case "mark": return { mark: truncate(command.label || command.text || "mark", 200), currentUrl: currentPageUrl(), pageId };
|
||||
case "stop": stopping = true; return { stopping: true, currentUrl: currentPageUrl(), pageId };
|
||||
@@ -377,7 +383,11 @@ async function syncObserverPageToControlSession(reason, explicitSessionId = null
|
||||
const readiness = await waitForTargetPageReady(observerPage, targetUrl, { timeoutMs: 15000 });
|
||||
if (!readiness.ok) {
|
||||
lastObserverRefreshAtMs = Date.now();
|
||||
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
|
||||
return { ok: false, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, failureKind: readiness.reason || "observer-target-not-ready", valuesRedacted: true };
|
||||
}
|
||||
if (!isWorkbenchPathname(safeUrlPath(targetUrl) || "")) {
|
||||
lastObserverRefreshAtMs = Date.now();
|
||||
return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, pageEpoch: observerPageEpoch, httpStatus: status, statusText, readiness, hydration: null, valuesRedacted: true };
|
||||
}
|
||||
const hydration = await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 15000 });
|
||||
lastObserverRefreshAtMs = Date.now();
|
||||
@@ -877,6 +887,30 @@ function redactErrorMessage(message) {
|
||||
async function waitForTargetPageReady(targetPage, targetUrl, options = {}) {
|
||||
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000;
|
||||
const targetPathname = safeUrlPath(targetUrl) || "";
|
||||
if (isProjectManagementPathname(targetPathname)) {
|
||||
const started = Date.now();
|
||||
const selectors = projectManagement.readinessSelectors;
|
||||
await targetPage.waitForFunction((input) => {
|
||||
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";
|
||||
};
|
||||
return input.selectors.some((selector) => {
|
||||
try { return visible(document.querySelector(selector)); } catch { return false; }
|
||||
});
|
||||
}, { selectors }, { timeout: timeoutMs }).catch(() => null);
|
||||
const snapshot = await projectManagementReadinessSnapshot(targetPage);
|
||||
const ok = snapshot.projectManagementVisible === true || snapshot.mdtodoVisible === true;
|
||||
return {
|
||||
ok,
|
||||
reason: ok ? "project-management-ready" : snapshot.loginVisible ? "login-visible" : "project-management-not-ready",
|
||||
durationMs: Date.now() - started,
|
||||
snapshot,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true };
|
||||
const started = Date.now();
|
||||
await targetPage.waitForFunction(() => {
|
||||
@@ -930,11 +964,49 @@ async function workbenchReadinessSnapshot(targetPage) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function projectManagementReadinessSnapshot(targetPage) {
|
||||
const selectors = projectManagement.readinessSelectors;
|
||||
return targetPage.evaluate((input) => {
|
||||
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 selectorStates = input.selectors.map((selector) => {
|
||||
let matched = false;
|
||||
let visibleMatched = false;
|
||||
try {
|
||||
const element = document.querySelector(selector);
|
||||
matched = Boolean(element);
|
||||
visibleMatched = visible(element);
|
||||
} catch {}
|
||||
return { selector, matched, visible: visibleMatched };
|
||||
});
|
||||
return {
|
||||
url: window.location.href,
|
||||
path: window.location.pathname,
|
||||
readyState: document.readyState,
|
||||
projectManagementVisible: visible(document.querySelector('[data-testid="project-management-root"]')),
|
||||
mdtodoVisible: visible(document.querySelector('[data-testid="project-management-mdtodo"]')),
|
||||
loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")),
|
||||
selectorStates,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}, { selectors }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||||
}
|
||||
|
||||
function isWorkbenchPathname(value) {
|
||||
const pathname = String(value || "");
|
||||
return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/");
|
||||
}
|
||||
|
||||
function isProjectManagementPathname(value) {
|
||||
if (projectManagement.enabled !== true) return false;
|
||||
const pathname = String(value || "");
|
||||
return projectManagement.targetPaths.some((target) => pathname === target || pathname.startsWith(target + "/"));
|
||||
}
|
||||
|
||||
async function createSessionFromUi() {
|
||||
const beforeUrl = currentPageUrl();
|
||||
const before = await workbenchSessionSnapshot();
|
||||
@@ -1428,6 +1500,208 @@ async function clickSession(sessionId) {
|
||||
return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId };
|
||||
}
|
||||
|
||||
function ensureProjectManagementCommand(type) {
|
||||
if (projectManagement.enabled !== true) throw new Error(type + " requires config/hwlab-node-lanes.yaml webProbe.projectManagement.enabled=true for the selected node/lane");
|
||||
if (!projectManagement.commandAllowlist.includes(type)) throw new Error(type + " is not in webProbe.projectManagement.commandAllowlist for the selected node/lane");
|
||||
}
|
||||
|
||||
async function clickProjectItemByAttr({ type, attr, value, fallbackSelector }) {
|
||||
ensureProjectManagementCommand(type);
|
||||
const beforeUrl = currentPageUrl();
|
||||
const beforeProject = await projectManagementCommandSnapshot();
|
||||
const targetValue = typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
const selector = targetValue ? "[" + attr + "=\"" + cssEscape(targetValue) + "\"]" : fallbackSelector;
|
||||
const locator = page.locator(selector).first();
|
||||
await locator.waitFor({ state: "visible", timeout: 15000 });
|
||||
const clickedValue = await locator.evaluate((element, name) => element.getAttribute(name), attr).catch(() => targetValue);
|
||||
await locator.click();
|
||||
await page.waitForTimeout(700);
|
||||
const afterProject = await projectManagementCommandSnapshot();
|
||||
return {
|
||||
beforeUrl,
|
||||
afterUrl: currentPageUrl(),
|
||||
type,
|
||||
attr,
|
||||
selected: opaqueIdSummary(clickedValue || targetValue),
|
||||
beforeProject,
|
||||
afterProject,
|
||||
pageId,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
async function selectProjectSource(command) {
|
||||
return clickProjectItemByAttr({
|
||||
type: "selectProjectSource",
|
||||
attr: "data-source-id",
|
||||
value: command.sourceId || command.value || command.text || "",
|
||||
fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]'
|
||||
});
|
||||
}
|
||||
|
||||
async function selectMdtodoFile(command) {
|
||||
return clickProjectItemByAttr({
|
||||
type: "selectMdtodoFile",
|
||||
attr: "data-file-ref",
|
||||
value: command.fileRef || command.value || command.text || "",
|
||||
fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]'
|
||||
});
|
||||
}
|
||||
|
||||
async function selectMdtodoTask(command) {
|
||||
return clickProjectItemByAttr({
|
||||
type: "selectMdtodoTask",
|
||||
attr: "data-task-ref",
|
||||
value: command.taskRef || command.value || command.text || "",
|
||||
fallbackSelector: '[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]'
|
||||
});
|
||||
}
|
||||
|
||||
async function launchWorkbenchFromTask(command) {
|
||||
ensureProjectManagementCommand("launchWorkbenchFromTask");
|
||||
const beforeUrl = currentPageUrl();
|
||||
const beforeProject = await projectManagementCommandSnapshot({ includeRaw: true });
|
||||
const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null;
|
||||
if (requestedTaskRef && beforeProject.selectedTaskRefRaw !== requestedTaskRef) {
|
||||
await selectMdtodoTask({ ...command, taskRef: requestedTaskRef });
|
||||
}
|
||||
const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true });
|
||||
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first();
|
||||
await button.waitFor({ state: "visible", timeout: 15000 });
|
||||
const buttonState = await button.evaluate((element) => ({
|
||||
disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true",
|
||||
textHash: element.textContent ? null : null,
|
||||
testId: element.getAttribute("data-testid") || null,
|
||||
action: element.getAttribute("data-action") || null,
|
||||
valuesRedacted: true
|
||||
})).catch((error) => ({ disabled: null, error: errorSummary(error), valuesRedacted: true }));
|
||||
if (buttonState.disabled === true) {
|
||||
const error = new Error("launchWorkbenchFromTask button is disabled");
|
||||
error.details = { beforeUrl, project: sanitizeProjectCommandSnapshot(projectBeforeClick), buttonState, valuesRedacted: true };
|
||||
throw error;
|
||||
}
|
||||
const launchPath = projectManagement.launchRoute;
|
||||
const launchResponsePromise = page.waitForResponse((response) => {
|
||||
const request = response.request();
|
||||
if (request.method().toUpperCase() !== "POST") return false;
|
||||
try {
|
||||
return new URL(response.url()).pathname === launchPath;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 45000 }).catch((error) => ({ waitError: errorSummary(error) }));
|
||||
await button.click();
|
||||
const launchResponse = await launchResponsePromise;
|
||||
if (launchResponse?.waitError) {
|
||||
const error = new Error("launchWorkbenchFromTask did not observe POST " + launchPath + " response after button click");
|
||||
error.details = { beforeUrl, afterUrl: currentPageUrl(), launchPath, project: sanitizeProjectCommandSnapshot(projectBeforeClick), waitError: launchResponse.waitError, valuesRedacted: true };
|
||||
throw error;
|
||||
}
|
||||
const launchStatus = launchResponse.status();
|
||||
const headers = launchResponse.headers();
|
||||
const launchTraceHeader = typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null;
|
||||
let payload = null;
|
||||
let responseParseError = null;
|
||||
try {
|
||||
payload = await launchResponse.json();
|
||||
} catch (error) {
|
||||
responseParseError = errorSummary(error);
|
||||
}
|
||||
const sessionId = sessionIdFromAgentSessionPayload(payload);
|
||||
const workbenchUrl = safeUrlPath(payload?.workbenchUrl || payload?.url || "");
|
||||
const contractVersion = typeof payload?.contractVersion === "string" ? payload.contractVersion : null;
|
||||
if (launchStatus < 200 || launchStatus >= 300 || !sessionId) {
|
||||
const error = new Error("launchWorkbenchFromTask did not receive a successful authoritative Workbench session");
|
||||
error.details = { beforeUrl, afterUrl: currentPageUrl(), launchStatus, statusText: launchResponse.statusText(), contractVersion, responseParsed: payload !== null, responseParseError, sessionId, workbenchUrl, otelTraceId: launchTraceHeader, valuesRedacted: true };
|
||||
throw error;
|
||||
}
|
||||
if (workbenchUrl) {
|
||||
await page.waitForFunction((expectedPath) => window.location.pathname === expectedPath, workbenchUrl, { timeout: 20000 }).catch(() => null);
|
||||
}
|
||||
return {
|
||||
beforeUrl,
|
||||
afterUrl: currentPageUrl(),
|
||||
launchPath,
|
||||
launchStatus,
|
||||
statusText: launchResponse.statusText(),
|
||||
contractVersion,
|
||||
sessionId,
|
||||
workbenchUrl,
|
||||
otelTraceId: launchTraceHeader,
|
||||
selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw),
|
||||
projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick),
|
||||
buttonState,
|
||||
responseParsed: payload !== null,
|
||||
responseParseError,
|
||||
pageId,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
async function projectManagementCommandSnapshot(options = {}) {
|
||||
const raw = await page.evaluate(() => {
|
||||
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 text = (element) => String(element?.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240);
|
||||
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 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 launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
|
||||
return {
|
||||
path: window.location.pathname,
|
||||
pageKind: visible(document.querySelector('[data-testid="project-management-mdtodo"]')) ? "project-management-mdtodo" : visible(document.querySelector('[data-testid="project-management-root"]')) ? "project-management-root" : null,
|
||||
sourceCount: Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length,
|
||||
fileCount: Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length,
|
||||
taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length,
|
||||
selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || null,
|
||||
selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || null,
|
||||
selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null,
|
||||
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
|
||||
launchButtonVisible: visible(launch),
|
||||
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
|
||||
launchButtonText: text(launch),
|
||||
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8),
|
||||
workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}).catch((error) => ({ error: errorSummary(error), valuesRedacted: true }));
|
||||
if (options.includeRaw === true) return raw;
|
||||
return sanitizeProjectCommandSnapshot(raw);
|
||||
}
|
||||
|
||||
function sanitizeProjectCommandSnapshot(value) {
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return {
|
||||
...value,
|
||||
selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw),
|
||||
selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw),
|
||||
selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw),
|
||||
selectedSourceIdRaw: undefined,
|
||||
selectedFileRefRaw: undefined,
|
||||
selectedTaskRefRaw: undefined,
|
||||
launchButtonTextHash: value.launchButtonText ? sha256Text(value.launchButtonText) : null,
|
||||
launchButtonTextPreview: value.launchButtonText ? truncate(value.launchButtonText, 80) : null,
|
||||
launchButtonText: undefined,
|
||||
blockerTexts: Array.isArray(value.blockerTexts) ? value.blockerTexts.map((item) => ({ textHash: sha256Text(item), textPreview: truncate(item, 160), textBytes: Buffer.byteLength(String(item || "")) })) : [],
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function opaqueIdSummary(value) {
|
||||
const text = String(value || "");
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
async function preflightSummary() {
|
||||
return { currentUrl: currentPageUrl(), title: await page.title().catch(() => null), pageId, auth: publicAuth(auth) };
|
||||
}
|
||||
@@ -1447,9 +1721,10 @@ async function samplePage(reason, options = {}) {
|
||||
|
||||
async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPageId, pageEpoch }) {
|
||||
sampleSeq += 1;
|
||||
const dom = await targetPage.evaluate(() => {
|
||||
const dom = await 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";
|
||||
@@ -1789,6 +2064,60 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
|
||||
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 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 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);
|
||||
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: sourceItems.length,
|
||||
fileCount: fileItems.length,
|
||||
taskCount: taskItems.length,
|
||||
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
|
||||
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id")),
|
||||
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref")),
|
||||
selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")),
|
||||
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
|
||||
taskStatusCounts: statusCounts,
|
||||
launchButtonVisible: visible(launch),
|
||||
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
|
||||
launchButtonText: trim(launch?.textContent || "", 120),
|
||||
blockerCount: blockers.length,
|
||||
blockers,
|
||||
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]');
|
||||
@@ -1870,6 +2199,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
|
||||
sessionRail,
|
||||
diagnostics,
|
||||
turns,
|
||||
projectManagement: collectProjectManagement(),
|
||||
pageProvenance: {
|
||||
url: location.href,
|
||||
path: location.pathname,
|
||||
@@ -1903,7 +2233,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
|
||||
},
|
||||
performance: performance.getEntriesByType("resource").slice(-80).map(resourceTimingSample),
|
||||
};
|
||||
}).catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
|
||||
}, { projectManagement }).catch((error) => ({ error: errorSummary(error), url: pageUrl(targetPage) }));
|
||||
const sample = {
|
||||
seq: sampleSeq,
|
||||
sampleGroupSeq: groupSeq,
|
||||
@@ -1927,9 +2257,55 @@ function digestDom(dom, pageRole = "control") {
|
||||
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 || "") })) : [];
|
||||
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, pageProvenance: compactPageProvenance(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 };
|
||||
};
|
||||
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),
|
||||
selectedTaskRef: opaque(value.selectedTaskRef),
|
||||
selectedTaskStatus: value.selectedTaskStatus ?? null,
|
||||
taskStatusCounts: value.taskStatusCounts && typeof value.taskStatusCounts === "object" ? value.taskStatusCounts : {},
|
||||
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),
|
||||
})) : [],
|
||||
workbenchLinkCount: Number.isFinite(Number(value.workbenchLinkCount)) ? Number(value.workbenchLinkCount) : 0,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function digestSessionRail(value) {
|
||||
@@ -2003,12 +2379,25 @@ function controlRecord(command, phase, 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,
|
||||
sourceId: opaque(command.sourceId),
|
||||
fileRef: opaque(command.fileRef),
|
||||
taskRef: opaque(command.taskRef),
|
||||
label: command.label ? truncate(command.label, 200) : null,
|
||||
textHash: text === null ? null : sha256Text(text),
|
||||
textBytes: text === null ? null : Buffer.byteLength(text),
|
||||
@@ -2124,6 +2513,54 @@ function parseAlertThresholds(value) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseProjectManagementConfig(value) {
|
||||
if (!value || value === "null") {
|
||||
return {
|
||||
enabled: false,
|
||||
targetPaths: [],
|
||||
readinessSelectors: [],
|
||||
naturalApiPathPrefixes: [],
|
||||
commandAllowlist: [],
|
||||
launchRoute: "",
|
||||
slowApiBudgetMs: 0,
|
||||
source: "yaml-env",
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
const raw = (() => {
|
||||
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
||||
})();
|
||||
const stringList = (key) => {
|
||||
const list = raw?.[key];
|
||||
if (!Array.isArray(list) || list.some((item) => typeof item !== "string" || item.length === 0)) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires string[] " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
|
||||
return list;
|
||||
};
|
||||
const positive = (key) => {
|
||||
const numeric = Number(raw?.[key]);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.projectManagement");
|
||||
return numeric;
|
||||
};
|
||||
if (raw?.enabled !== true && raw?.enabled !== false) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON requires boolean enabled");
|
||||
if (raw.enabled !== true) return { enabled: false, targetPaths: [], readinessSelectors: [], naturalApiPathPrefixes: [], commandAllowlist: [], launchRoute: "", slowApiBudgetMs: 0, source: "yaml-env", valuesRedacted: true };
|
||||
const targetPaths = stringList("targetPaths");
|
||||
const readinessSelectors = stringList("readinessSelectors");
|
||||
const naturalApiPathPrefixes = stringList("naturalApiPathPrefixes");
|
||||
const commandAllowlist = stringList("commandAllowlist");
|
||||
const launchRoute = String(raw.launchRoute || "");
|
||||
if (!launchRoute.startsWith("/")) throw new Error("UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON launchRoute must be an absolute path");
|
||||
return {
|
||||
enabled: true,
|
||||
targetPaths,
|
||||
readinessSelectors,
|
||||
naturalApiPathPrefixes,
|
||||
commandAllowlist,
|
||||
launchRoute,
|
||||
slowApiBudgetMs: positive("slowApiBudgetMs"),
|
||||
source: "yaml-env",
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function sha256Text(value) {
|
||||
return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user