1494 lines
74 KiB
TypeScript
1494 lines
74 KiB
TypeScript
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
|
// Responsibility: Runner Workbench session, provider, and Project/MDTODO command source fragment.
|
|
|
|
export function nodeWebObserveRunnerWorkbenchSource(): string {
|
|
return String.raw`async function selectProvider(provider) {
|
|
const target = String(provider || "").trim();
|
|
if (!target) throw new Error("selectProvider requires provider name");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforePath = safeUrlPath(beforeUrl);
|
|
if (!String(beforePath || "").startsWith("/workbench")) throw new Error("selectProvider requires a Workbench page; currentPath=" + (beforePath || "-") + "; run observe command --type goto --path /workbench or --type newSession first");
|
|
const nativeSelect = await page.evaluate((name) => {
|
|
const normalized = String(name).toLowerCase();
|
|
const visible = (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = window.getComputedStyle(element);
|
|
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
};
|
|
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
|
|
const options = Array.from(select.options || []);
|
|
const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized));
|
|
if (!option) continue;
|
|
select.value = option.value;
|
|
select.dispatchEvent(new Event("input", { bubbles: true }));
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return { kind: "native-select", value: option.value, text: option.textContent || "" };
|
|
}
|
|
return null;
|
|
}, target).catch(() => null);
|
|
if (nativeSelect) {
|
|
await page.waitForTimeout(500);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId };
|
|
}
|
|
const optionVisible = page.getByText(target, { exact: false }).last();
|
|
if (await optionVisible.isVisible().catch(() => false)) {
|
|
await optionVisible.click();
|
|
await page.waitForTimeout(500);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId };
|
|
}
|
|
const control = page.locator([
|
|
'[data-testid*="provider" i]',
|
|
'[data-testid*="model" i]',
|
|
'[aria-label*="provider" i]',
|
|
'[aria-label*="model" i]',
|
|
'[aria-label*="模型"]',
|
|
'[aria-label*="提供"]',
|
|
'[role="combobox"]',
|
|
'[aria-haspopup="listbox"]',
|
|
].join(", "));
|
|
const count = Math.min(await control.count().catch(() => 0), 60);
|
|
const attempts = [];
|
|
for (let index = count - 1; index >= 0; index -= 1) {
|
|
const item = control.nth(index);
|
|
const visible = await item.isVisible().catch(() => false);
|
|
if (!visible) continue;
|
|
const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => "");
|
|
if (isProviderNavigationLabel(label)) continue;
|
|
if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue;
|
|
attempts.push({ index, label });
|
|
await item.click({ timeout: 3000 }).catch(() => null);
|
|
await page.waitForTimeout(300);
|
|
const option = page.getByText(target, { exact: false }).last();
|
|
if (await option.isVisible().catch(() => false)) {
|
|
await option.click();
|
|
await page.waitForTimeout(700);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId };
|
|
}
|
|
await page.keyboard.press("Escape").catch(() => null);
|
|
}
|
|
const providerCandidates = await collectProviderCandidates();
|
|
throw new Error("provider option not found: " + target + "; providerCandidates=" + JSON.stringify(providerCandidates.slice(0, 50)) + "; attempts=" + JSON.stringify(attempts.slice(-10)));
|
|
}
|
|
|
|
async function collectProviderCandidates() {
|
|
return page.evaluate(() => {
|
|
const rows = [];
|
|
const seen = new Set();
|
|
const isNavigationLabel = (value) => {
|
|
const label = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
|
if (!label) return false;
|
|
if (label === "api keys" || label === "kapi keys" || label === "profiles" || label === "rprofiles") return true;
|
|
return /^(?:api keys|profiles)$/iu.test(label.replace(/^[a-z]\s*/iu, ""));
|
|
};
|
|
const visible = (element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
const style = window.getComputedStyle(element);
|
|
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
};
|
|
const push = (kind, element, value, text) => {
|
|
const normalizedValue = String(value || "").trim().slice(0, 160);
|
|
const normalizedText = String(text || "").replace(/\s+/gu, " ").trim().slice(0, 220);
|
|
const label = (normalizedValue + " " + normalizedText).trim();
|
|
if (!label) return;
|
|
const key = (kind + ":" + label).toLowerCase();
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
rows.push({ kind, value: normalizedValue, text: normalizedText, testId: String(element?.getAttribute?.("data-testid") || "").slice(0, 120), ariaLabel: String(element?.getAttribute?.("aria-label") || "").slice(0, 160) });
|
|
};
|
|
for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) {
|
|
for (const option of Array.from(select.options || [])) push("select-option", select, option.value, option.textContent || "");
|
|
}
|
|
const selector = [
|
|
'[role="option"]',
|
|
'[role="menuitem"]',
|
|
'[data-radix-collection-item]',
|
|
'[data-testid*="provider" i]',
|
|
'[data-testid*="profile" i]',
|
|
'[data-testid*="model" i]',
|
|
'[aria-label*="provider" i]',
|
|
'[aria-label*="profile" i]',
|
|
'[aria-label*="model" i]',
|
|
'[aria-label*="模型"]',
|
|
'[aria-label*="提供"]',
|
|
'button'
|
|
].join(", ");
|
|
for (const element of Array.from(document.querySelectorAll(selector)).filter(visible)) {
|
|
const text = String(element.textContent || "").replace(/\s+/gu, " ").trim();
|
|
const ariaLabel = String(element.getAttribute("aria-label") || "");
|
|
const testId = String(element.getAttribute("data-testid") || "");
|
|
const haystack = text + " " + ariaLabel + " " + testId;
|
|
if (isNavigationLabel(haystack)) continue;
|
|
if (!/provider|profile|model|模型|提供|codex|openai|deepseek|gpt|api|flash|spark|claude|gemini|moon/iu.test(haystack)) continue;
|
|
push("visible-control", element, element.getAttribute("value") || "", text || ariaLabel || testId);
|
|
}
|
|
return rows.slice(0, 80);
|
|
}).catch((error) => [{ kind: "candidate-scan-error", value: "", text: String(error?.message || error).slice(0, 240), testId: "", ariaLabel: "" }]);
|
|
}
|
|
|
|
function isProviderNavigationLabel(value) {
|
|
const text = String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
|
if (!text) return false;
|
|
if (text === "api keys" || text === "kapi keys" || text === "profiles" || text === "rprofiles") return true;
|
|
return /^(?:api keys|profiles)$/iu.test(text.replace(/^[a-z]\s*/iu, ""));
|
|
}
|
|
|
|
async function clickSession(sessionId) {
|
|
if (!sessionId) throw new Error("clickSession requires --session-id or --value");
|
|
const beforeUrl = currentPageUrl();
|
|
const escaped = cssEscape(sessionId);
|
|
const cssCandidate = page.locator("[data-session-id=\"" + escaped + "\"], [href*=\"" + escaped + "\"]").first();
|
|
const candidate = await visibleLocator(cssCandidate) ? cssCandidate : page.getByText(sessionId, { exact: true }).first();
|
|
await candidate.waitFor({ state: "visible", timeout: 15000 });
|
|
await candidate.click();
|
|
await page.waitForTimeout(1000);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId };
|
|
}
|
|
|
|
async function refreshCurrentSession(command) {
|
|
const beforeUrl = currentPageUrl();
|
|
const before = await workbenchSessionSnapshot();
|
|
const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
|
|
if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session");
|
|
const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
|
|
const after = await workbenchSessionSnapshot();
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "refreshCurrentSession",
|
|
afterRound: integerOrNull(command.afterRound),
|
|
canarySessionId: sessionId,
|
|
routeSessionId: after?.routeSessionId ?? null,
|
|
activeSessionId: after?.activeSessionId ?? null,
|
|
routeOk: after?.routeSessionId === sessionId,
|
|
activeOk: after?.activeSessionId === sessionId,
|
|
composerReady: after?.composerReady === true,
|
|
navigation,
|
|
pageId,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
async function switchAwayAndBack(command) {
|
|
const beforeUrl = currentPageUrl();
|
|
const before = await workbenchSessionSnapshot();
|
|
const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
|
|
if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session");
|
|
const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create");
|
|
const beforeSessionIds = await visibleWorkbenchSessionIds();
|
|
let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null;
|
|
let alternateSource = alternateSessionId === null ? null : "existing";
|
|
let createResult = null;
|
|
if (alternateSessionId === null && strategy === "existing-or-create") {
|
|
createResult = await createSessionFromUi();
|
|
alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null;
|
|
alternateSource = "created";
|
|
}
|
|
if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy);
|
|
const switchAway = await clickSession(alternateSessionId);
|
|
const awaySettle = await waitForWorkbenchSessionHydrated(page, alternateSessionId, { timeoutMs: 15000 });
|
|
const away = awaySettle.snapshot ?? await workbenchSessionSnapshot();
|
|
let switchBack;
|
|
try {
|
|
switchBack = await clickSession(canarySessionId);
|
|
} catch (error) {
|
|
const fallbackNavigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId));
|
|
switchBack = { ok: fallbackNavigation?.readiness?.ok === true, fallback: "gotoTarget", clickError: errorSummary(error), navigation: fallbackNavigation, valuesRedacted: true };
|
|
}
|
|
let backSettle = await waitForWorkbenchSessionHydrated(page, canarySessionId, { timeoutMs: 15000 });
|
|
let after = backSettle.snapshot ?? await workbenchSessionSnapshot();
|
|
let switchBackRecovery = null;
|
|
let routeOk = after?.routeSessionId === canarySessionId;
|
|
let activeOk = after?.activeSessionId === canarySessionId;
|
|
if (!routeOk || !activeOk) {
|
|
switchBackRecovery = await recoverControlPageSessionHydration(canarySessionId, backSettle);
|
|
if (switchBackRecovery?.ok === true) {
|
|
switchBack = { ...switchBack, recoveryApplied: true, recoveryNavigation: switchBackRecovery.navigation, valuesRedacted: true };
|
|
backSettle = switchBackRecovery.settle;
|
|
after = switchBackRecovery.snapshot ?? after;
|
|
routeOk = after?.routeSessionId === canarySessionId;
|
|
activeOk = after?.activeSessionId === canarySessionId;
|
|
}
|
|
}
|
|
if (awaySettle.ok !== true) {
|
|
const error = new Error("switchAwayAndBack did not settle on the alternate session");
|
|
error.details = { canarySessionId, alternateSessionId, routeSessionId: away?.routeSessionId ?? null, activeSessionId: away?.activeSessionId ?? null, settle: awaySettle, pageId, valuesRedacted: true };
|
|
throw error;
|
|
}
|
|
if (!routeOk || !activeOk) {
|
|
const error = new Error("switchAwayAndBack did not return to the canary session");
|
|
error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, settle: backSettle, recovery: switchBackRecovery, pageId, valuesRedacted: true };
|
|
throw error;
|
|
}
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "switchAwayAndBack",
|
|
afterRound: integerOrNull(command.afterRound),
|
|
canarySessionId,
|
|
alternateSessionId,
|
|
alternateSessionStrategy: strategy,
|
|
alternateSource,
|
|
beforeSessionCount: beforeSessionIds.length,
|
|
createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true },
|
|
switchAway,
|
|
awaySettle,
|
|
away: {
|
|
routeSessionId: away?.routeSessionId ?? null,
|
|
activeSessionId: away?.activeSessionId ?? null,
|
|
messageCount: away?.messageCount ?? null,
|
|
valuesRedacted: true,
|
|
},
|
|
switchBack,
|
|
backSettle,
|
|
switchBackRecovery,
|
|
routeSessionId: after?.routeSessionId ?? null,
|
|
activeSessionId: after?.activeSessionId ?? null,
|
|
routeOk,
|
|
activeOk,
|
|
composerReady: after?.composerReady === true,
|
|
pageId,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
async function recoverControlPageSessionHydration(sessionId, previousSettle) {
|
|
const observerHydration = observerPage && !observerPage.isClosed()
|
|
? await waitForWorkbenchSessionHydrated(observerPage, sessionId, { timeoutMs: 5000 })
|
|
: { ok: false, reason: "observer-page-unavailable", valuesRedacted: true };
|
|
await recreateControlPageForNavigation(observerHydration.ok === true ? "switch-back-hydration-retry" : "switch-back-control-retry-without-observer", 1);
|
|
const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
|
|
const settle = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: 15000 });
|
|
const snapshot = settle.snapshot ?? await workbenchSessionSnapshot();
|
|
const routeOk = snapshot?.routeSessionId === sessionId;
|
|
const activeOk = snapshot?.activeSessionId === sessionId;
|
|
return {
|
|
ok: settle.ok === true && routeOk && activeOk,
|
|
attempted: true,
|
|
reason: settle.ok === true ? "control-page-recreated" : settle.reason || "control-session-hydration-retry-failed",
|
|
previousSettle,
|
|
observerHydrated: observerHydration.ok === true,
|
|
observerHydration,
|
|
navigation,
|
|
settle,
|
|
snapshot: {
|
|
routeSessionId: snapshot?.routeSessionId ?? null,
|
|
activeSessionId: snapshot?.activeSessionId ?? null,
|
|
composerReady: snapshot?.composerReady === true,
|
|
messageCount: snapshot?.messageCount ?? null,
|
|
valuesRedacted: true,
|
|
},
|
|
pageId,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
async function assertSessionInvariant(command) {
|
|
const beforeUrl = currentPageUrl();
|
|
const snapshot = await workbenchSessionSnapshot();
|
|
const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim();
|
|
if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session");
|
|
const routeOk = snapshot?.routeSessionId === canarySessionId;
|
|
const activeOk = snapshot?.activeSessionId === canarySessionId;
|
|
const composerReady = snapshot?.composerReady === true;
|
|
if (!routeOk || !activeOk) {
|
|
const error = new Error("assertSessionInvariant saw route/active session mismatch");
|
|
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true };
|
|
throw error;
|
|
}
|
|
if (command.requireComposerReady === true && !composerReady) {
|
|
const error = new Error("assertSessionInvariant requires composer ready for the next round");
|
|
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true };
|
|
throw error;
|
|
}
|
|
const messageOrder = await visibleMessageOrderSummary();
|
|
await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true })));
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "assertSessionInvariant",
|
|
afterRound: integerOrNull(command.afterRound),
|
|
canarySessionId,
|
|
routeSessionId: snapshot?.routeSessionId ?? null,
|
|
activeSessionId: snapshot?.activeSessionId ?? null,
|
|
routeOk,
|
|
activeOk,
|
|
composerReady,
|
|
expectedSentinelRange: command.expectedSentinelRange || null,
|
|
findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation",
|
|
severity: command.severity || "amber",
|
|
blocking: command.blocking === true,
|
|
messageOrder,
|
|
sampleSeq,
|
|
pageRole: "control",
|
|
pageId,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
async function visibleWorkbenchSessionIds() {
|
|
return 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 sessionIdForElement = (element) => {
|
|
const direct = element.getAttribute("data-session-id");
|
|
if (direct) return direct;
|
|
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
|
|
const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
|
return match ? decodeURIComponent(match[1] || "") : null;
|
|
};
|
|
const ids = [];
|
|
for (const element of 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/']"))) {
|
|
if (!visible(element)) continue;
|
|
const id = sessionIdForElement(element);
|
|
if (id && !ids.includes(id)) ids.push(id);
|
|
}
|
|
return ids.slice(0, 50);
|
|
}).catch(() => []);
|
|
}
|
|
|
|
async function visibleMessageOrderSummary() {
|
|
const rawMessages = await page.evaluate(() => {
|
|
const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").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 stableMessageText = (element) => {
|
|
const selectors = [".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 selectors) {
|
|
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
|
if (!visible(candidate)) continue;
|
|
const text = trim(candidate.textContent || "", 1200);
|
|
if (text && !parts.includes(text)) parts.push(text);
|
|
}
|
|
}
|
|
return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200);
|
|
};
|
|
return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({
|
|
index,
|
|
dataRole: element.getAttribute("data-role") || null,
|
|
role: element.getAttribute("role") || null,
|
|
testId: element.getAttribute("data-testid") || null,
|
|
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,
|
|
text: stableMessageText(element),
|
|
}));
|
|
}).catch(() => []);
|
|
const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => {
|
|
const text = String(item?.text || "");
|
|
const kind = messageKindForOrder(item, text);
|
|
const markers = sentinelMarkers(text);
|
|
return {
|
|
index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index,
|
|
kind,
|
|
role: item?.dataRole || item?.role || null,
|
|
status: item?.status || null,
|
|
sessionId: item?.sessionId || null,
|
|
messageId: item?.messageId || null,
|
|
traceId: item?.traceId || null,
|
|
turnId: item?.turnId || null,
|
|
markers,
|
|
textHash: sha256Text(text),
|
|
textBytes: Buffer.byteLength(text),
|
|
valuesRedacted: true,
|
|
};
|
|
}) : [];
|
|
const clusters = userClusters(entries);
|
|
const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null;
|
|
return {
|
|
messageCount: entries.length,
|
|
sequence: entries.map((item) => ({
|
|
index: item.index,
|
|
kind: item.kind,
|
|
marker: item.markers[0] || null,
|
|
markerCount: item.markers.length,
|
|
traceId: item.traceId,
|
|
status: item.status,
|
|
textHash: item.textHash,
|
|
textBytes: item.textBytes,
|
|
valuesRedacted: true,
|
|
})).slice(-40),
|
|
userClustered: clusters.length > 0,
|
|
consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0,
|
|
sentinelRange: maxCluster?.sentinelRange ?? null,
|
|
traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12),
|
|
clusters,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function messageKindForOrder(item, text) {
|
|
const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" ");
|
|
if (/\b(user|human)\b/u.test(signal)) return "user";
|
|
if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent";
|
|
const body = String(text || "").trim();
|
|
if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user";
|
|
if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent";
|
|
return "unknown";
|
|
}
|
|
|
|
function sentinelMarkers(text) {
|
|
return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase()));
|
|
}
|
|
|
|
function userClusters(entries) {
|
|
const clusters = [];
|
|
let current = [];
|
|
const flush = () => {
|
|
if (current.length >= 2) {
|
|
const markers = current.flatMap((item) => item.markers);
|
|
clusters.push({
|
|
startIndex: current[0].index,
|
|
endIndex: current[current.length - 1].index,
|
|
consecutiveUserMessageCount: current.length,
|
|
sentinelRange: sentinelRange(markers),
|
|
markers: uniqueStrings(markers).slice(0, 12),
|
|
messageTextHashes: current.map((item) => item.textHash).slice(0, 12),
|
|
traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12),
|
|
valuesRedacted: true,
|
|
});
|
|
}
|
|
current = [];
|
|
};
|
|
for (const entry of entries) {
|
|
if (entry.kind === "user") {
|
|
current.push(entry);
|
|
} else {
|
|
flush();
|
|
}
|
|
}
|
|
flush();
|
|
return clusters.slice(0, 20);
|
|
}
|
|
|
|
function sentinelRange(markers) {
|
|
const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b));
|
|
if (unique.length === 0) return null;
|
|
return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1];
|
|
}
|
|
|
|
function sentinelMarkerNumber(marker) {
|
|
const match = String(marker || "").match(/sentinel-(\d+)/u);
|
|
return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean)));
|
|
}
|
|
|
|
function integerOrNull(value) {
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) ? parsed : null;
|
|
}
|
|
|
|
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 gotoProjectMdtodo() {
|
|
ensureProjectManagementCommand("gotoProjectMdtodo");
|
|
return gotoTarget("/projects/mdtodo");
|
|
}
|
|
|
|
function commandValue(command, keys) {
|
|
for (const key of keys) {
|
|
const value = command?.[key];
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function visibleLocator(locator) {
|
|
return await locator.count().catch(() => 0) > 0 && await locator.first().isVisible().catch(() => false);
|
|
}
|
|
|
|
function normalizedProjectText(value) {
|
|
return String(value || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
|
}
|
|
|
|
function projectSnapshotMatchesCommandSelection(type, raw, selected, targetValue) {
|
|
if (!raw || typeof raw !== "object") return false;
|
|
const selectedValue = String(selected?.selectedValue || "");
|
|
const normalizedTarget = normalizedProjectText(targetValue);
|
|
if (type === "selectMdtodoFile") {
|
|
const selectedFileRef = String(raw.selectedFileRefRaw || "");
|
|
const selectedFileLabel = normalizedProjectText(raw.selectedFileLabel);
|
|
return Boolean(
|
|
(selectedValue && selectedFileRef === selectedValue)
|
|
|| (normalizedTarget && selectedFileLabel.includes(normalizedTarget))
|
|
) && Number(raw.fileCount || 0) > 0;
|
|
}
|
|
if (type === "selectMdtodoSource" || type === "selectProjectSource") {
|
|
const selectedSourceId = String(raw.selectedSourceIdRaw || "");
|
|
return Boolean(selectedValue && selectedSourceId === selectedValue) && Number(raw.sourceCount || 0) > 0;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function waitForProjectCommandSelection({ type, selected, targetValue, timeoutMs = 30000 }) {
|
|
const started = Date.now();
|
|
let lastProject = null;
|
|
do {
|
|
await page.waitForTimeout(300);
|
|
lastProject = await projectManagementCommandSnapshot({ includeRaw: true });
|
|
if (projectSnapshotMatchesCommandSelection(type, lastProject, selected, targetValue)) return lastProject;
|
|
} while (Date.now() - started < timeoutMs);
|
|
return lastProject || await projectManagementCommandSnapshot({ includeRaw: true });
|
|
}
|
|
|
|
async function selectHtmlOptionByValueOrLabel(locator, value) {
|
|
const select = locator.first();
|
|
const targetValue = typeof value === "string" && value.trim() ? value.trim() : "";
|
|
if (targetValue) {
|
|
const byValue = await select.selectOption({ value: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
|
if (byValue.ok && byValue.selected.length > 0) return { ok: true, mode: "select-value", selectedValue: byValue.selected[0] || targetValue };
|
|
const byLabel = await select.selectOption({ label: targetValue }).then((selected) => ({ ok: true, selected })).catch(() => ({ ok: false, selected: [] }));
|
|
if (byLabel.ok && byLabel.selected.length > 0) return { ok: true, mode: "select-label", selectedValue: byLabel.selected[0] || targetValue };
|
|
return await select.evaluate((element, target) => {
|
|
const normalize = (raw) => String(raw || "").replace(/\s+/gu, " ").trim().toLowerCase();
|
|
const normalizedTarget = normalize(target);
|
|
const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value).map((option) => ({
|
|
value: option.value,
|
|
label: String(option.textContent || "").replace(/\s+/gu, " ").trim(),
|
|
}));
|
|
const matchers = [
|
|
{ mode: "select-value-normalized", test: (option) => normalize(option.value) === normalizedTarget },
|
|
{ mode: "select-label-normalized", test: (option) => normalize(option.label) === normalizedTarget },
|
|
{ mode: "select-label-contains", test: (option) => normalize(option.label).includes(normalizedTarget) },
|
|
{ mode: "select-value-contains", test: (option) => normalize(option.value).includes(normalizedTarget) },
|
|
];
|
|
for (const matcher of matchers) {
|
|
const chosen = options.find(matcher.test);
|
|
if (!chosen) continue;
|
|
element.value = chosen.value;
|
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return {
|
|
ok: true,
|
|
mode: matcher.mode,
|
|
selectedValue: chosen.value,
|
|
selectedLabel: chosen.label,
|
|
optionCount: options.length,
|
|
optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
return {
|
|
ok: false,
|
|
mode: "select-target-not-found",
|
|
selectedValue: element.value || "",
|
|
optionCount: options.length,
|
|
optionLabelSamples: options.map((option) => option.label).filter(Boolean).slice(0, 8),
|
|
valuesRedacted: true,
|
|
};
|
|
}, targetValue);
|
|
}
|
|
const selectedValue = await select.evaluate((element) => {
|
|
const options = Array.from(element.options || []).filter((option) => !option.disabled && option.value);
|
|
const chosen = options[0] || null;
|
|
if (!chosen) return "";
|
|
element.value = chosen.value;
|
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
return chosen.value;
|
|
});
|
|
return { ok: true, mode: "select-first", selectedValue };
|
|
}
|
|
|
|
async function clickProjectItemByAttr({ type, attr, value, fallbackSelector, selectTestId }) {
|
|
ensureProjectManagementCommand(type);
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const targetValue = typeof value === "string" && value.trim() ? value.trim() : null;
|
|
if (selectTestId) {
|
|
const select = page.locator('[data-testid="' + cssEscape(selectTestId) + '"]');
|
|
if (await visibleLocator(select)) {
|
|
const selected = await selectHtmlOptionByValueOrLabel(select, targetValue || "");
|
|
const afterProjectRaw = await waitForProjectCommandSelection({ type, selected, targetValue: targetValue || "" });
|
|
const selectionMatched = projectSnapshotMatchesCommandSelection(type, afterProjectRaw, selected, targetValue || "");
|
|
const afterProject = sanitizeProjectCommandSnapshot(afterProjectRaw);
|
|
if (targetValue && (selected.ok !== true || !selectionMatched)) {
|
|
const error = new Error(type + " did not select requested target");
|
|
error.details = {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type,
|
|
attr,
|
|
selectTestId,
|
|
mode: selected.mode,
|
|
targetHash: sha256Text(targetValue),
|
|
targetPreview: truncate(targetValue, 120),
|
|
selected,
|
|
beforeProject,
|
|
afterProject,
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
throw error;
|
|
}
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type,
|
|
attr,
|
|
mode: selected.mode,
|
|
selected: opaqueIdSummary(selected.selectedValue || targetValue),
|
|
beforeProject,
|
|
afterProject,
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
}
|
|
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]',
|
|
selectTestId: "mdtodo-source-select"
|
|
});
|
|
}
|
|
|
|
async function selectMdtodoSource(command) {
|
|
return clickProjectItemByAttr({
|
|
type: "selectMdtodoSource",
|
|
attr: "data-source-id",
|
|
value: command.sourceId || command.value || command.text || "",
|
|
fallbackSelector: '[data-testid="mdtodo-source-list"] [data-source-id], [data-source-id]',
|
|
selectTestId: "mdtodo-source-select"
|
|
});
|
|
}
|
|
|
|
async function selectMdtodoFile(command) {
|
|
return clickProjectItemByAttr({
|
|
type: "selectMdtodoFile",
|
|
attr: "data-file-ref",
|
|
value: command.fileRef || command.filename || command.value || command.text || "",
|
|
fallbackSelector: '[data-testid="mdtodo-file-list"] [data-file-ref], [data-file-ref]',
|
|
selectTestId: "mdtodo-file-select"
|
|
});
|
|
}
|
|
|
|
async function mdtodoTaskLocator(command, options = {}) {
|
|
const taskRef = commandValue(command, ["taskRef"]);
|
|
const taskId = commandValue(command, ["taskId", "task", "value", "text"]);
|
|
const selectors = [];
|
|
if (taskRef) selectors.push('[data-task-ref="' + cssEscape(taskRef) + '"]');
|
|
if (taskId) {
|
|
selectors.push('[data-task-id="' + cssEscape(taskId) + '"]');
|
|
selectors.push('[data-rxx-id="' + cssEscape(taskId) + '"]');
|
|
}
|
|
for (const selector of selectors) {
|
|
const locator = page.locator(selector).first();
|
|
if (await visibleLocator(locator)) return { locator, taskRef, taskId, selector };
|
|
}
|
|
if (taskId) {
|
|
const textLocator = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').filter({ hasText: taskId }).first();
|
|
if (await visibleLocator(textLocator)) return { locator: textLocator, taskRef, taskId, selector: "text:" + taskId };
|
|
}
|
|
if ((taskRef || taskId) && options.allowFallback !== true) {
|
|
return { locator: null, taskRef, taskId, selector: "target-task-not-visible", targetMissing: true };
|
|
}
|
|
const fallback = page.locator('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]').first();
|
|
return { locator: fallback, taskRef, taskId, selector: "first-visible-task" };
|
|
}
|
|
|
|
async function waitForMdtodoTaskLocator(command, timeoutMs = 30000) {
|
|
const started = Date.now();
|
|
let lastProject = null;
|
|
let lastTarget = null;
|
|
do {
|
|
lastTarget = await mdtodoTaskLocator(command, { allowFallback: false });
|
|
if (lastTarget.locator && await visibleLocator(lastTarget.locator)) return { target: lastTarget, project: lastProject };
|
|
lastProject = await projectManagementCommandSnapshot();
|
|
await page.waitForTimeout(500);
|
|
} while (Date.now() - started < timeoutMs);
|
|
return { target: lastTarget || await mdtodoTaskLocator(command, { allowFallback: false }), project: lastProject || await projectManagementCommandSnapshot() };
|
|
}
|
|
|
|
async function selectMdtodoTask(command) {
|
|
ensureProjectManagementCommand("selectMdtodoTask");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const hasExplicitTarget = Boolean(commandValue(command, ["taskRef"]) || commandValue(command, ["taskId", "task", "value", "text"]));
|
|
const waited = hasExplicitTarget ? await waitForMdtodoTaskLocator(command) : { target: await mdtodoTaskLocator(command, { allowFallback: true }), project: null };
|
|
const target = waited.target;
|
|
if (!target.locator) {
|
|
const error = new Error("selectMdtodoTask did not find requested task");
|
|
error.details = {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
selector: target.selector,
|
|
requestedTaskRef: target.taskRef ? opaqueIdSummary(target.taskRef) : null,
|
|
requestedTaskId: target.taskId || null,
|
|
beforeProject,
|
|
afterProject: waited.project,
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
throw error;
|
|
}
|
|
await target.locator.waitFor({ state: "visible", timeout: 15000 });
|
|
const clicked = await target.locator.evaluate((element) => ({
|
|
taskRef: element.getAttribute("data-task-ref") || null,
|
|
taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null,
|
|
status: element.getAttribute("data-task-status") || null,
|
|
selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true"
|
|
})).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null }));
|
|
if (!clicked.selected) {
|
|
await target.locator.scrollIntoViewIfNeeded().catch(() => null);
|
|
await target.locator.click();
|
|
await page.waitForTimeout(700);
|
|
}
|
|
const afterProject = await projectManagementCommandSnapshot();
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "selectMdtodoTask",
|
|
selector: target.selector,
|
|
selectedTask: opaqueIdSummary(clicked.taskRef || target.taskRef),
|
|
selectedTaskId: clicked.taskId || target.taskId || null,
|
|
selectedTaskStatus: clicked.status || null,
|
|
alreadySelected: clicked.selected === true,
|
|
beforeProject,
|
|
afterProject,
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function expandMdtodoTask(command) {
|
|
ensureProjectManagementCommand("expandMdtodoTask");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const target = await mdtodoTaskLocator(command);
|
|
await target.locator.waitFor({ state: "visible", timeout: 15000 });
|
|
const toggle = target.locator.locator('[data-testid="mdtodo-task-toggle"], [data-testid="mdtodo-task-expand"], [data-action="toggle-task"], button[aria-expanded]').first();
|
|
const toggleVisible = await visibleLocator(toggle);
|
|
if (toggleVisible) await toggle.click();
|
|
else await target.locator.click();
|
|
await page.waitForTimeout(700);
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "expandMdtodoTask",
|
|
selector: target.selector,
|
|
toggleVisible,
|
|
beforeProject,
|
|
afterProject: await projectManagementCommandSnapshot(),
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function openMdtodoSourceConfig(command) {
|
|
ensureProjectManagementCommand("openMdtodoSourceConfig");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const existingDialog = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first();
|
|
if (await visibleLocator(existingDialog)) {
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "openMdtodoSourceConfig",
|
|
alreadyOpen: true,
|
|
beforeProject,
|
|
afterProject: await projectManagementCommandSnapshot(),
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
const button = page.locator('[data-testid="mdtodo-source-config-open"]').first();
|
|
await button.waitFor({ state: "visible", timeout: 15000 });
|
|
await button.click();
|
|
await page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]').first().waitFor({ state: "visible", timeout: 10000 }).catch(() => null);
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "openMdtodoSourceConfig",
|
|
beforeProject,
|
|
afterProject: await projectManagementCommandSnapshot(),
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function ensureMdtodoSourceConfigOpen() {
|
|
const form = page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"]').first();
|
|
if (await visibleLocator(form)) return { opened: false };
|
|
await openMdtodoSourceConfig({ type: "openMdtodoSourceConfig" });
|
|
return { opened: true };
|
|
}
|
|
|
|
async function closeMdtodoSourceConfig(command) {
|
|
ensureProjectManagementCommand("closeMdtodoSourceConfig");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const close = await closeMdtodoSourceConfigIfOpen({ required: true });
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "closeMdtodoSourceConfig",
|
|
close,
|
|
beforeProject,
|
|
afterProject: await projectManagementCommandSnapshot(),
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function closeMdtodoSourceConfigIfOpen(options = {}) {
|
|
const dialog = page.locator('[data-testid="mdtodo-source-config-dialog"], [role="dialog"]').filter({
|
|
has: page.locator('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-form-node"], [data-testid="mdtodo-source-form-root"], [data-testid="mdtodo-source-reindex-dialog"]')
|
|
}).first();
|
|
if (!await visibleLocator(dialog)) return { wasOpen: false, stillVisible: false };
|
|
const closeButton = dialog.locator('[data-testid="mdtodo-source-config-close"], [aria-label="关闭配置"], [aria-label*="关闭"], [aria-label="Close"], button:has-text("关闭"), button:has-text("Cancel"), button:has-text("取消")').first();
|
|
let closeClick = null;
|
|
try {
|
|
await closeButton.waitFor({ state: "visible", timeout: 5000 });
|
|
await closeButton.click({ timeout: 5000 });
|
|
closeClick = { attempted: true, ok: true };
|
|
} catch (error) {
|
|
closeClick = { attempted: true, ok: false, error: errorSummary(error) };
|
|
await page.keyboard.press("Escape").catch(() => null);
|
|
}
|
|
await page.waitForTimeout(400);
|
|
const stillVisible = await visibleLocator(dialog);
|
|
const result = { wasOpen: true, closeClick, stillVisible, valuesRedacted: true };
|
|
if (options.required && stillVisible) {
|
|
const error = new Error("closeMdtodoSourceConfig dialog remained visible after close attempt");
|
|
error.details = result;
|
|
throw error;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function fillMdtodoField(testId, value) {
|
|
if (typeof value !== "string" || !value.trim()) return { testId, filled: false };
|
|
const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
|
|
await locator.waitFor({ state: "visible", timeout: 10000 });
|
|
await locator.fill(value);
|
|
return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
|
|
}
|
|
|
|
async function clickProjectButtonAndMaybeWait(testId, pathPattern) {
|
|
const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
|
|
await button.waitFor({ state: "visible", timeout: 15000 });
|
|
const responsePromise = pathPattern ? page.waitForResponse((response) => {
|
|
try {
|
|
const pathname = new URL(response.url()).pathname;
|
|
return pathPattern.test(pathname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, { timeout: 15000 }).then((response) => ({ observed: true, status: response.status(), path: new URL(response.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) })) : Promise.resolve(null);
|
|
const buttonState = await button.evaluate((element) => ({ disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true", testId: element.getAttribute("data-testid") || null })).catch((error) => ({ disabled: null, error: errorSummary(error) }));
|
|
if (buttonState.disabled === true) {
|
|
const error = new Error(testId + " button is disabled");
|
|
error.details = { buttonState, valuesRedacted: true };
|
|
throw error;
|
|
}
|
|
await button.click();
|
|
const response = await responsePromise;
|
|
await page.waitForTimeout(900);
|
|
return { buttonState, response, valuesRedacted: true };
|
|
}
|
|
|
|
async function configureMdtodoHwpodSource(command) {
|
|
ensureProjectManagementCommand("configureMdtodoHwpodSource");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const dialog = await ensureMdtodoSourceConfigOpen();
|
|
const fields = [
|
|
await fillMdtodoField("mdtodo-source-form-hwpod", commandValue(command, ["hwpodId", "hwpod", "value"])),
|
|
await fillMdtodoField("mdtodo-source-form-node", commandValue(command, ["nodeId", "node"])),
|
|
await fillMdtodoField("mdtodo-source-form-workspace", commandValue(command, ["workspaceRoot", "workspaceRootRef", "workspace"])),
|
|
await fillMdtodoField("mdtodo-source-form-root", commandValue(command, ["root", "path"])),
|
|
];
|
|
const save = await clickProjectButtonAndMaybeWait("mdtodo-source-save", /^\/v1\/project-management\/mdtodo\/sources/u);
|
|
const close = await closeMdtodoSourceConfigIfOpen();
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
type: "configureMdtodoHwpodSource",
|
|
dialog,
|
|
fields,
|
|
save,
|
|
close,
|
|
beforeProject,
|
|
afterProject: await projectManagementCommandSnapshot(),
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function probeMdtodoSource(command) {
|
|
ensureProjectManagementCommand("probeMdtodoSource");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
await ensureMdtodoSourceConfigOpen();
|
|
const probe = await clickProjectButtonAndMaybeWait("mdtodo-source-probe", /^\/v1\/project-management\/mdtodo\/sources/u);
|
|
const close = await closeMdtodoSourceConfigIfOpen();
|
|
return { beforeUrl, afterUrl: currentPageUrl(), type: "probeMdtodoSource", probe, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
|
}
|
|
|
|
async function reindexMdtodoSource(command) {
|
|
ensureProjectManagementCommand("reindexMdtodoSource");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
await ensureMdtodoSourceConfigOpen();
|
|
const dialogButton = page.locator('[data-testid="mdtodo-source-reindex-dialog"]').first();
|
|
const buttonTestId = await visibleLocator(dialogButton) ? "mdtodo-source-reindex-dialog" : "mdtodo-source-reindex";
|
|
const reindex = await clickProjectButtonAndMaybeWait(buttonTestId, /^\/v1\/project-management\/mdtodo\/sources/u);
|
|
const close = await closeMdtodoSourceConfigIfOpen();
|
|
return { beforeUrl, afterUrl: currentPageUrl(), type: "reindexMdtodoSource", buttonTestId, reindex, close, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
|
}
|
|
|
|
async function selectTaskIfCommandTargetsOne(command) {
|
|
if (commandValue(command, ["taskRef", "taskId", "task"]).length === 0) return null;
|
|
return selectMdtodoTask(command);
|
|
}
|
|
|
|
async function selectMdtodoProviderProfileForLaunch(command) {
|
|
const provider = commandValue(command, ["provider", "providerProfile"]);
|
|
if (!provider) return null;
|
|
const select = page.locator('[data-testid="mdtodo-provider-profile"]').first();
|
|
if (!(await visibleLocator(select))) {
|
|
return { provider, visible: false, selected: null, valuesRedacted: true };
|
|
}
|
|
const selected = await selectHtmlOptionByValueOrLabel(select, provider);
|
|
await page.waitForTimeout(300);
|
|
return { provider, visible: true, ...selected, valuesRedacted: true };
|
|
}
|
|
|
|
async function saveMdtodoTaskWithButton(command, type, testId, fields) {
|
|
ensureProjectManagementCommand(type);
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const selection = await selectTaskIfCommandTargetsOne(command);
|
|
const inlineEditors = [];
|
|
for (const field of fields) {
|
|
if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId));
|
|
if (field.kind === "fill") await fillMdtodoField(field.testId, field.value);
|
|
if (field.kind === "select") {
|
|
const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first();
|
|
await locator.waitFor({ state: "visible", timeout: 10000 });
|
|
await selectHtmlOptionByValueOrLabel(locator, field.value);
|
|
}
|
|
}
|
|
const save = await clickProjectButtonAndMaybeWaitAny(Array.isArray(testId) ? testId : [testId], /^\/v1\/project-management\/mdtodo\/tasks/u);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), type, selection, inlineEditors, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
|
}
|
|
|
|
async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) {
|
|
const candidates = (testIds || []).filter(Boolean);
|
|
for (const testId of candidates) {
|
|
const button = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
|
|
if (await visibleLocator(button)) return clickProjectButtonAndMaybeWait(testId, pathPattern);
|
|
}
|
|
if (candidates.length === 0) throw new Error("clickProjectButtonAndMaybeWaitAny requires at least one data-testid");
|
|
return clickProjectButtonAndMaybeWait(candidates[0], pathPattern);
|
|
}
|
|
|
|
async function ensureMdtodoInlineEditor(editorTestId, readTestId) {
|
|
const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first();
|
|
if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false };
|
|
const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first();
|
|
await read.waitFor({ state: "visible", timeout: 10000 });
|
|
await read.dblclick();
|
|
await editor.waitFor({ state: "visible", timeout: 10000 });
|
|
return { editorTestId, readTestId, opened: true };
|
|
}
|
|
|
|
async function editMdtodoTaskTitle(command) {
|
|
const title = commandValue(command, ["title", "text", "value"]);
|
|
if (!title) throw new Error("editMdtodoTaskTitle requires --title or --text");
|
|
return saveMdtodoTaskWithButton(command, "editMdtodoTaskTitle", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]);
|
|
}
|
|
|
|
async function editMdtodoTaskBody(command) {
|
|
const body = commandValue(command, ["text", "body", "value"]);
|
|
if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin");
|
|
return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
|
|
}
|
|
|
|
async function toggleMdtodoTaskStatus(command) {
|
|
const status = commandValue(command, ["status", "value", "text"]);
|
|
if (!status) throw new Error("toggleMdtodoTaskStatus requires --status");
|
|
return saveMdtodoTaskWithButton(command, "toggleMdtodoTaskStatus", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]);
|
|
}
|
|
|
|
async function editMdtodoTaskInline(command) {
|
|
const field = commandValue(command, ["field", "value"]).toLowerCase();
|
|
if (field === "title") {
|
|
const title = commandValue(command, ["title", "text"]);
|
|
if (!title) throw new Error("editMdtodoTaskInline --field title requires --title or --text");
|
|
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-save", [{ kind: "fill", testId: "mdtodo-edit-title", value: title, openByDblClickTestId: "mdtodo-title-read" }]);
|
|
}
|
|
if (field === "body" || field === "content") {
|
|
const body = commandValue(command, ["body", "text"]);
|
|
if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin");
|
|
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
|
|
}
|
|
if (field === "status") {
|
|
const status = commandValue(command, ["status", "text"]);
|
|
if (!status) throw new Error("editMdtodoTaskInline --field status requires --status or --text");
|
|
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-status-save", "mdtodo-edit-save"], [{ kind: "select", testId: "mdtodo-edit-status", value: status }]);
|
|
}
|
|
throw new Error("editMdtodoTaskInline requires --field title, body, or status");
|
|
}
|
|
|
|
async function openMdtodoReportPreview(command) {
|
|
ensureProjectManagementCommand("openMdtodoReportPreview");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const selection = await selectTaskIfCommandTargetsOne(command);
|
|
const linkText = commandValue(command, ["link", "value", "text"]);
|
|
const links = page.locator('[data-testid="mdtodo-report-link"]');
|
|
await links.first().waitFor({ state: "visible", timeout: 15000 });
|
|
const count = await links.count();
|
|
let index = 0;
|
|
if (linkText) {
|
|
for (let i = 0; i < count; i += 1) {
|
|
const item = links.nth(i);
|
|
const text = await item.textContent().catch(() => "");
|
|
if (String(text || "").includes(linkText)) {
|
|
index = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const link = links.nth(index);
|
|
const linkStateRaw = await link.evaluate((element, selectedIndex) => ({
|
|
index: selectedIndex,
|
|
disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true",
|
|
text: String(element.textContent || "").replace(/\s+/gu, " ").trim().slice(0, 240),
|
|
valuesRedacted: true
|
|
}), index).catch((error) => ({ index, error: errorSummary(error), valuesRedacted: true }));
|
|
const linkState = {
|
|
...linkStateRaw,
|
|
textHash: linkStateRaw.text ? sha256Text(linkStateRaw.text) : null,
|
|
textPreview: linkStateRaw.text ? truncate(linkStateRaw.text, 120) : null,
|
|
text: undefined,
|
|
valuesRedacted: true
|
|
};
|
|
if (linkState.disabled === true) {
|
|
const error = new Error("openMdtodoReportPreview selected report link is disabled");
|
|
error.details = { beforeUrl, linkState, beforeProject, valuesRedacted: true };
|
|
throw error;
|
|
}
|
|
const attempts = [];
|
|
let response = null;
|
|
let afterProject = null;
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
const responsePromise = page.waitForResponse((item) => {
|
|
try {
|
|
const request = item.request();
|
|
return request.method().toUpperCase() === "GET" && new URL(item.url()).pathname === "/v1/project-management/mdtodo/report-preview";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, { timeout: 10000 }).then((item) => ({ observed: true, status: item.status(), path: new URL(item.url()).pathname })).catch((error) => ({ observed: false, waitError: errorSummary(error) }));
|
|
const clickMode = attempt === 1 ? "normal" : "force";
|
|
let click = { ok: true, mode: clickMode, valuesRedacted: true };
|
|
await link.scrollIntoViewIfNeeded().catch(() => null);
|
|
try {
|
|
if (attempt === 1) await link.click();
|
|
else await link.click({ force: true, timeout: 5000 });
|
|
} catch (error) {
|
|
click = { ok: false, mode: clickMode, error: errorSummary(error), valuesRedacted: true };
|
|
await link.evaluate((element) => element.click()).catch(() => null);
|
|
}
|
|
await page.locator('[data-testid="mdtodo-report-preview"], [data-testid="mdtodo-report-error"]').first().waitFor({ state: "visible", timeout: 5000 }).catch(() => null);
|
|
afterProject = await projectManagementCommandSnapshot();
|
|
response = afterProject?.reportPreviewVisible === true
|
|
? { observed: null, skippedAfterPreviewVisible: true, valuesRedacted: true }
|
|
: await responsePromise;
|
|
attempts.push({
|
|
attempt,
|
|
click,
|
|
response,
|
|
afterUrl: currentPageUrl(),
|
|
reportPreviewVisible: afterProject?.reportPreviewVisible === true,
|
|
reportErrorVisible: afterProject?.reportErrorVisible === true,
|
|
reportFullscreenVisible: afterProject?.reportFullscreenVisible === true,
|
|
valuesRedacted: true
|
|
});
|
|
if (afterProject?.reportPreviewVisible === true) break;
|
|
await page.waitForTimeout(700).catch(() => null);
|
|
}
|
|
afterProject = afterProject ?? await projectManagementCommandSnapshot();
|
|
if (afterProject?.reportPreviewVisible !== true) {
|
|
const error = new Error("openMdtodoReportPreview did not show markdown report preview");
|
|
error.details = { beforeUrl, afterUrl: currentPageUrl(), selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true };
|
|
throw error;
|
|
}
|
|
return { beforeUrl, afterUrl: currentPageUrl(), type: "openMdtodoReportPreview", selection, linkState, response, attempts, beforeProject, afterProject, pageId, valuesRedacted: true };
|
|
}
|
|
|
|
async function toggleMdtodoReportFullscreen(command) {
|
|
ensureProjectManagementCommand("toggleMdtodoReportFullscreen");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const desired = commandValue(command, ["value", "text"]).toLowerCase();
|
|
const dialog = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"]').first();
|
|
const initiallyOpen = await visibleLocator(dialog);
|
|
let action = "open";
|
|
if (desired === "close" || desired === "off") action = "close";
|
|
else if (desired === "toggle") action = initiallyOpen ? "close" : "open";
|
|
if (action === "close") {
|
|
const closeButton = page.locator('[data-testid="mdtodo-report-fullscreen-dialog"] [aria-label="关闭报告"], [aria-label="关闭报告"]').first();
|
|
await closeButton.waitFor({ state: "visible", timeout: 10000 });
|
|
await closeButton.click();
|
|
} else if (!initiallyOpen) {
|
|
const button = page.locator('[data-testid="mdtodo-report-fullscreen"]').first();
|
|
await button.waitFor({ state: "visible", timeout: 10000 });
|
|
await button.click();
|
|
}
|
|
await page.waitForTimeout(500);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), type: "toggleMdtodoReportFullscreen", action, initiallyOpen, fullscreenOpen: await visibleLocator(dialog), beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
|
}
|
|
|
|
async function fillNewTaskDraft(command) {
|
|
const title = commandValue(command, ["title", "text", "value"]);
|
|
if (!title) throw new Error(command.type + " requires --title or --text");
|
|
const fields = [await fillMdtodoField("mdtodo-new-title", title)];
|
|
const body = commandValue(command, ["body"]) || (command.title ? commandValue(command, ["text"]) : "");
|
|
if (body) fields.push(await fillMdtodoField("mdtodo-new-body", body));
|
|
return fields;
|
|
}
|
|
|
|
async function addMdtodoTaskWithButton(command, type, testId) {
|
|
ensureProjectManagementCommand(type);
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const selection = type === "addMdtodoRootTask" ? null : await selectTaskIfCommandTargetsOne(command);
|
|
const fields = await fillNewTaskDraft(command);
|
|
const save = await clickProjectButtonAndMaybeWait(testId, /^\/v1\/project-management\/mdtodo\/tasks/u);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), type, selection, fields, save, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
|
}
|
|
|
|
async function addMdtodoRootTask(command) {
|
|
return addMdtodoTaskWithButton(command, "addMdtodoRootTask", "mdtodo-add-root");
|
|
}
|
|
|
|
async function addMdtodoSubTask(command) {
|
|
return addMdtodoTaskWithButton(command, "addMdtodoSubTask", "mdtodo-add-subtask");
|
|
}
|
|
|
|
async function continueMdtodoTask(command) {
|
|
return addMdtodoTaskWithButton(command, "continueMdtodoTask", "mdtodo-continue-task");
|
|
}
|
|
|
|
async function deleteMdtodoTask(command) {
|
|
ensureProjectManagementCommand("deleteMdtodoTask");
|
|
const beforeUrl = currentPageUrl();
|
|
const beforeProject = await projectManagementCommandSnapshot();
|
|
const selection = await selectTaskIfCommandTargetsOne(command);
|
|
const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null);
|
|
let confirmClick = null;
|
|
const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first());
|
|
if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u);
|
|
return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
|
|
}
|
|
|
|
async function launchWorkbenchFromTask(command) {
|
|
const commandType = command.type === "launchWorkbenchFromMdtodo" ? "launchWorkbenchFromMdtodo" : "launchWorkbenchFromTask";
|
|
ensureProjectManagementCommand(commandType);
|
|
const beforeUrl = currentPageUrl();
|
|
const initialProject = await projectManagementCommandSnapshot({ includeRaw: true });
|
|
const requestedSourceId = commandValue(command, ["sourceId", "mdtodoSourceId"]);
|
|
const requestedFileRef = commandValue(command, ["fileRef"]);
|
|
const requestedFilename = commandValue(command, ["filename", "fileName"]);
|
|
const requestedTaskRef = typeof command.taskRef === "string" && command.taskRef.trim() ? command.taskRef.trim() : null;
|
|
const requestedTaskId = typeof command.taskId === "string" && command.taskId.trim() ? command.taskId.trim() : null;
|
|
const needsMdtodoSelection = Boolean(requestedFileRef || requestedFilename || requestedTaskRef || requestedTaskId);
|
|
const sourceSelection = requestedSourceId && initialProject.selectedSourceIdRaw !== requestedSourceId
|
|
? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: requestedSourceId })
|
|
: !requestedSourceId && needsMdtodoSelection && !initialProject.selectedSourceIdRaw
|
|
? await selectMdtodoSource({ ...command, type: "selectMdtodoSource", sourceId: "" })
|
|
: null;
|
|
const fileProject = sourceSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : initialProject;
|
|
const fileSelection = (requestedFileRef && fileProject.selectedFileRefRaw !== requestedFileRef) || requestedFilename
|
|
? await selectMdtodoFile({ ...command, type: "selectMdtodoFile", fileRef: requestedFileRef || command.fileRef, filename: requestedFilename || command.filename })
|
|
: null;
|
|
const taskProject = fileSelection ? await projectManagementCommandSnapshot({ includeRaw: true }) : fileProject;
|
|
const taskSelection = (requestedTaskRef && taskProject.selectedTaskRefRaw !== requestedTaskRef) || requestedTaskId
|
|
? await selectMdtodoTask({ ...command, type: "selectMdtodoTask", taskRef: requestedTaskRef || command.taskRef, taskId: requestedTaskId || command.taskId })
|
|
: null;
|
|
const providerSelection = await selectMdtodoProviderProfileForLaunch(command);
|
|
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) }));
|
|
const chatResponsePromise = page.waitForResponse((response) => {
|
|
const request = response.request();
|
|
if (request.method().toUpperCase() !== "POST") return false;
|
|
try {
|
|
return new URL(response.url()).pathname === "/v1/agent/chat";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, { timeout: 30000 }).then(async (response) => {
|
|
let chatPayload = null;
|
|
let chatPayloadError = null;
|
|
try {
|
|
chatPayload = await response.json();
|
|
} catch (error) {
|
|
chatPayloadError = errorSummary(error);
|
|
}
|
|
const headers = response.headers();
|
|
return {
|
|
observed: true,
|
|
status: response.status(),
|
|
statusText: response.statusText(),
|
|
path: new URL(response.url()).pathname,
|
|
sessionId: sessionIdFromAgentSessionPayload(chatPayload),
|
|
traceId: traceIdFromAgentChatPayload(chatPayload),
|
|
otelTraceId: typeof headers["x-hwlab-otel-trace-id"] === "string" ? headers["x-hwlab-otel-trace-id"] : null,
|
|
responseParsed: chatPayload !== null,
|
|
responseParseError: chatPayloadError,
|
|
valuesRedacted: true
|
|
};
|
|
}).catch((error) => ({ observed: false, waitError: errorSummary(error), valuesRedacted: true }));
|
|
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);
|
|
}
|
|
const chat = await chatResponsePromise;
|
|
await page.waitForTimeout(1500);
|
|
const workbenchSnapshot = await workbenchSessionSnapshot();
|
|
return {
|
|
beforeUrl,
|
|
afterUrl: currentPageUrl(),
|
|
launchPath,
|
|
launchStatus,
|
|
statusText: launchResponse.statusText(),
|
|
contractVersion,
|
|
sessionId,
|
|
workbenchUrl,
|
|
otelTraceId: launchTraceHeader,
|
|
chatObserved: chat?.observed === true,
|
|
chatStatus: chat?.status ?? null,
|
|
chatSessionId: chat?.sessionId ?? null,
|
|
chatTraceId: chat?.traceId ?? null,
|
|
chatOtelTraceId: chat?.otelTraceId ?? null,
|
|
chat,
|
|
workbenchSnapshot,
|
|
selectedTask: opaqueIdSummary(projectBeforeClick.selectedTaskRefRaw),
|
|
selection: {
|
|
source: sourceSelection,
|
|
file: fileSelection,
|
|
task: taskSelection,
|
|
provider: providerSelection,
|
|
valuesRedacted: true
|
|
},
|
|
initialProject: sanitizeProjectCommandSnapshot(initialProject),
|
|
projectBeforeClick: sanitizeProjectCommandSnapshot(projectBeforeClick),
|
|
buttonState,
|
|
responseParsed: payload !== null,
|
|
responseParseError,
|
|
pageId,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function launchWorkbenchFromMdtodo(command) {
|
|
return launchWorkbenchFromTask({ ...command, type: "launchWorkbenchFromMdtodo" });
|
|
}
|
|
|
|
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 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: text(option),
|
|
selected: option.selected === true
|
|
})) : [];
|
|
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
|
|
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 reportError = document.querySelector('[data-testid="mdtodo-report-error"]');
|
|
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
|
|
const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible);
|
|
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: Math.max(Array.from(document.querySelectorAll('[data-source-id]')).filter(visible).length, sourceOptionCount),
|
|
fileCount: Math.max(Array.from(document.querySelectorAll('[data-file-ref]')).filter(visible).length, fileOptionCount),
|
|
taskCount: Array.from(document.querySelectorAll('[data-task-ref]')).filter(visible).length,
|
|
selectedSourceIdRaw: selectedSource?.getAttribute("data-source-id") || sourceSelect?.value || null,
|
|
selectedFileRefRaw: selectedFile?.getAttribute("data-file-ref") || fileSelect?.value || null,
|
|
selectedFileLabel: selectedFile ? text(selectedFile) : selectedFileOption?.label || null,
|
|
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20),
|
|
selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null,
|
|
selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null,
|
|
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) ? text(bodyRendered) : "",
|
|
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
|
|
reportLinkCount: reportLinks.length,
|
|
reportPreviewVisible: visible(reportPreview),
|
|
reportPreviewText: visible(reportPreview) ? text(reportPreview) : "",
|
|
reportErrorVisible: visible(reportError),
|
|
reportErrorText: visible(reportError) ? text(reportError) : "",
|
|
reportFullscreenVisible: visible(reportFullscreen),
|
|
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;
|
|
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 {
|
|
...value,
|
|
selectedSourceId: opaqueIdSummary(value.selectedSourceIdRaw),
|
|
selectedFileRef: opaqueIdSummary(value.selectedFileRefRaw),
|
|
selectedTaskRef: opaqueIdSummary(value.selectedTaskRefRaw),
|
|
selectedSourceIdRaw: undefined,
|
|
selectedFileRefRaw: undefined,
|
|
selectedTaskRefRaw: undefined,
|
|
selectedFileLabel: value.selectedFileLabel ? textDigest(value.selectedFileLabel, 120) : null,
|
|
selectedFileLabelLooksDirect: value.selectedFileLabel ? fileLabelLooksDirect(value.selectedFileLabel) : null,
|
|
fileOptionLabelSamples: fileLabels.slice(0, 8).map((item) => textDigest(item, 120)),
|
|
fileOptionSuspiciousLabelCount: fileLabels.filter(suspiciousFileLabel).length,
|
|
fileOptionLabels: undefined,
|
|
taskBodyText: undefined,
|
|
taskBody: value.taskBodyText ? textDigest(value.taskBodyText, 200) : null,
|
|
reportPreviewText: undefined,
|
|
reportPreview: value.reportPreviewText ? textDigest(value.reportPreviewText, 200) : null,
|
|
reportErrorText: undefined,
|
|
reportError: value.reportErrorText ? textDigest(value.reportErrorText, 200) : null,
|
|
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
|
|
};
|
|
}
|
|
`;
|
|
}
|