Files
pikasTech-unidesk/scripts/src/hwlab-node-web-observe-render.ts
T
2026-06-25 03:38:05 +08:00

466 lines
21 KiB
TypeScript

// SPEC: PJ2026-01040111 long-running Workbench observation.
// Responsibility: CLI text rendering for hwlab nodes web-probe observe status/command/collect.
import type { RenderedCliResult } from "./output";
export function withWebObserveStatusRendered(value: Record<string, unknown>): RenderedCliResult {
return {
ok: value.ok !== false,
command: typeof value.command === "string" ? value.command : "hwlab nodes web-probe observe status",
contentType: "text/plain",
renderedText: renderWebObserveStatusTable(value),
};
}
export function withWebObserveCommandRendered(value: Record<string, unknown>): RenderedCliResult {
return {
ok: value.ok !== false,
command: typeof value.command === "string" ? value.command : "hwlab nodes web-probe observe command",
contentType: "text/plain",
renderedText: renderWebObserveCommandTable(value),
};
}
export function withWebObserveCollectRendered(value: Record<string, unknown>): RenderedCliResult {
return {
ok: value.ok !== false,
command: typeof value.command === "string" ? value.command : "hwlab nodes web-probe observe collect",
contentType: "text/plain",
renderedText: renderWebObserveCollectTable(value),
};
}
function renderWebObserveStatusTable(value: Record<string, unknown>): string {
const observer = record(value.observer);
const manifest = record(observer?.manifest);
const heartbeat = record(observer?.heartbeat);
const tails = record(observer?.tails);
const result = record(value.result);
const samples = Array.isArray(tails?.samples) ? tails.samples.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-5) : [];
const controls = Array.isArray(tails?.control) ? tails.control.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-5) : [];
const completedControlIds = new Set(controls
.filter((item) => item.phase === "completed" || item.phase === "failed")
.map((item) => webObserveText(item.commandId))
.filter((item) => item !== "-"));
const activeControl = controls.slice().reverse().find((item) => item.phase === "started" && !completedControlIds.has(webObserveText(item.commandId))) ?? null;
const activeControlAgeSeconds = activeControl === null ? null : Math.max(0, Math.round((Date.now() - Date.parse(webObserveText(activeControl.ts))) / 1000));
const network = Array.isArray(tails?.network)
? tails.network.map(record).filter((item): item is Record<string, unknown> => item !== null).filter((item) => {
const status = typeof item.status === "number" ? item.status : null;
return status === null || status >= 400 || item.failure !== null && item.failure !== undefined;
}).slice(-5)
: [];
const next = record(value.next);
const heartbeatError = record(heartbeat?.error) ?? record(manifest?.error);
const heartbeatAuth = record(heartbeat?.auth) ?? record(heartbeatError?.auth);
const manifestNetwork = record(manifest?.network);
const manifestBrowser = record(manifestNetwork?.browser);
const manifestProxy = record(manifestNetwork?.proxy);
const lines = [
`hwlab nodes web-probe observe status (${webObserveText(value.status)})`,
"",
webObserveTable(["ID", "NODE", "LANE", "ALIVE", "PID", "SAMPLE", "UPDATED", "TARGET"], [[
value.id,
value.node,
value.lane,
observer?.processAlive,
observer?.pid,
heartbeat?.sampleSeq,
webObserveShort(webObserveText(heartbeat?.updatedAt ?? heartbeat?.lastSampleAt), 24),
webObserveShort(`${webObserveText(manifest?.baseUrl)}${webObserveText(manifest?.targetPath) === "-" ? "" : webObserveText(manifest?.targetPath)}`, 52),
]]),
"",
...(value.ok === false ? [
"Blocked detail:",
webObserveTable(["REASON", "EXIT", "TIMEOUT", "STDOUT", "STDERR"], [[
webObserveShort(webObserveText(value.degradedReason), 48),
result.exitCode,
result.timedOut,
webObserveShort(webObserveText(result.stdoutTail), 120),
webObserveShort(webObserveText(result.stderr), 120),
]]),
"",
] : []),
...(heartbeatError !== null ? [
"Observer error:",
webObserveTable(["STATUS", "RETRY", "EXHAUSTED", "BROWSER_PROXY", "MESSAGE"], [[
heartbeat?.status ?? manifest?.status,
heartbeatAuth?.lastRetryLabel,
heartbeatAuth?.retryExhausted,
webObserveShort(webObserveText(manifestBrowser?.proxyMode ?? manifestProxy?.enabled), 32),
webObserveShort(webObserveText(heartbeatError.message ?? heartbeatAuth?.lastError), 120),
]]),
"",
] : []),
...(heartbeatAuth !== null ? [
"Auth progress:",
webObserveTable(["PHASE", "RETRY", "DELAY_MS", "STATUS", "RETRYABLE", "COOKIE", "EXHAUSTED", "LAST_ERROR"], [[
heartbeatAuth.phase,
heartbeatAuth.lastRetryLabel,
heartbeatAuth.retryDelayMs,
heartbeatAuth.lastStatusText === undefined ? heartbeatAuth.lastStatus : `${webObserveText(heartbeatAuth.lastStatus)} ${webObserveText(heartbeatAuth.lastStatusText)}`,
heartbeatAuth.retryable,
heartbeatAuth.cookiePresent,
heartbeatAuth.retryExhausted,
webObserveShort(webObserveText(heartbeatAuth.lastError), 80),
]]),
"",
] : []),
...(activeControl !== null ? [
"Active command:",
webObserveTable(["TYPE", "COMMAND", "AGE_S", "STARTED_AT", "DETAIL"], [[
activeControl.type,
webObserveShort(webObserveText(activeControl.commandId), 28),
activeControlAgeSeconds,
webObserveShort(webObserveText(activeControl.ts), 24),
webObserveShort(webObserveText(activeControl.detail), 80),
]]),
"",
] : []),
"Recent samples:",
webObserveTable(["SEQ", "TS", "PATH", "ROUTE_SESSION", "ACTIVE_SESSION", "MSG", "TRACE"], samples.length > 0 ? samples.map((sample) => [
sample.seq,
webObserveShort(webObserveText(sample.ts), 24),
webObserveShort(webObserveText(sample.path), 24),
webObserveShort(webObserveText(sample.routeSessionId), 20),
webObserveShort(webObserveText(sample.activeSessionId), 20),
sample.messageCount,
sample.traceRowCount,
]) : [["-", "-", "-", "-", "-", "-", "-"]]),
"",
"Recent control:",
webObserveTable(["SEQ", "TS", "PHASE", "TYPE", "COMMAND", "RETRY", "DETAIL"], controls.length > 0 ? controls.map((control) => [
control.seq,
webObserveShort(webObserveText(control.ts), 24),
control.phase,
control.type,
webObserveShort(webObserveText(control.commandId), 28),
control.retry,
webObserveShort(webObserveText(control.detail), 80),
]) : [["-", "-", "-", "-", "-", "-", "-"]]),
"",
"Network alerts:",
webObserveTable(["TS", "METHOD", "STATUS", "TYPE", "URL_OR_FAILURE"], network.length > 0 ? network.map((item) => [
webObserveShort(webObserveText(item.ts), 24),
item.method,
item.status,
item.type,
webObserveShort(webObserveText(item.failure ?? item.url), 80),
]) : [["-", "-", "-", "-", "-"]]),
"",
"Next:",
];
for (const key of ["status", "command", "stop", "analyze"] as const) {
const command = webObserveText(next?.[key]);
if (command !== "-") lines.push(` ${command}`);
}
lines.push("", "Disclosure:");
lines.push(" default view is a bounded table; use observe collect/analyze for artifact-level drill-down.");
return lines.join("\n");
}
function renderWebObserveCommandTable(value: Record<string, unknown>): string {
const observerCommand = record(value.observerCommand);
const observer = record(value.observer);
const result = record(observer?.result);
const id = webObserveText(value.id);
const full = value.full === true;
const lines = [
`hwlab nodes web-probe observe command (${webObserveText(value.status)})`,
"",
webObserveTable(["OBSERVER", "COMMAND", "TYPE", "STATUS", "TEXT_BYTES", "TEXT_HASH", "DETAIL"], [[
id,
value.commandId,
observerCommand?.type,
observer?.ok === false ? "failed" : observer?.completedAt !== undefined ? "completed" : value.status,
observerCommand?.textBytes,
webObserveShort(webObserveText(observerCommand?.textHash), 24),
webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80),
]]),
...(full ? [
"",
"Full command result:",
"```json",
JSON.stringify(observer ?? {}, null, 2),
"```",
] : []),
"",
"Next:",
` bun scripts/cli.ts hwlab nodes web-probe observe status ${id}`,
` bun scripts/cli.ts hwlab nodes web-probe observe analyze ${id}`,
"",
"Disclosure:",
" default view is a bounded command result; pass --full to expose the complete command result JSON.",
" use observe status/analyze for sampled state.",
];
return lines.join("\n");
}
function renderWebObserveCollectTable(value: Record<string, unknown>): string {
const collect = nullableRecord(value.collect);
if (typeof collect?.renderedText === "string") {
return [
`hwlab nodes web-probe observe collect (${webObserveText(value.status)})`,
"",
collect.renderedText,
"",
"Source:",
webObserveTable(["ID", "NODE", "LANE", "VIEW", "STATE_DIR"], [[
value.id,
value.node,
value.lane,
collect.view ?? value.view,
webObserveShort(webObserveText(collect.stateDir), 88),
]]),
"",
"Disclosure:",
" collect views are rendered from existing samples/control/analysis artifacts; they do not create a new sampling source.",
].join("\n");
}
const result = record(value.result);
const file = record(collect?.file);
const files = Array.isArray(collect?.files) ? collect.files.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 20) : [];
const jsonlTail = Array.isArray(file.jsonlTail) ? file.jsonlTail.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8) : [];
const jsonSummary = record(file.jsonSummary);
const grepSummary = record(file.grep);
const grepLines = Array.isArray(grepSummary?.lines) ? grepSummary.lines.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 40) : [];
const jsonContentPreview = file.jsonContent === undefined || file.jsonContent === null ? "" : JSON.stringify(file.jsonContent, null, 2);
const jsonRunnerErrors = Array.isArray(jsonSummary.runnerErrors) ? jsonSummary.runnerErrors.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8) : [];
const jsonFindings = Array.isArray(jsonSummary.findings) ? jsonSummary.findings.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8) : [];
const jsonFindingDetail = record(jsonSummary.findingDetail);
const jsonFindingSamples = Array.isArray(jsonFindingDetail?.samples) ? jsonFindingDetail.samples.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 12) : [];
const lines = [
`hwlab nodes web-probe observe collect (${webObserveText(value.status)})`,
"",
webObserveTable(["ID", "NODE", "LANE", "MODE", "STATE_DIR"], [[
value.id,
value.node,
value.lane,
collect?.mode ?? "files",
webObserveShort(webObserveText(collect?.stateDir), 88),
]]),
"",
];
if (collect === null) {
lines.push(
"Collect command:",
webObserveTable(["REQUESTED", "REASON", "EXIT", "TIMED_OUT", "STDOUT_BYTES", "STDOUT_TAIL", "STDERR"], [[
webObserveShort(webObserveText(value.requestedFile), 64),
webObserveShort(webObserveText(value.degradedReason), 40),
result.exitCode,
result.timedOut,
result.stdoutBytes,
webObserveShort(webObserveText(result.stdoutTail), 160),
webObserveShort(webObserveText(result.stderr), 160),
]]),
"",
"Disclosure:",
" collect stdout was not valid JSON; fix the collect command or rerun with a narrower --file after the root cause is visible.",
);
return lines.join("\n");
}
if (collect.mode === "file") {
lines.push(
"File:",
webObserveTable(["RELATIVE", "BYTES", "TRUNC", "SHA256"], [[
webObserveShort(webObserveText(file.relative), 64),
file.byteCount,
file.truncated,
webObserveShort(webObserveText(file.sha256), 28),
]]),
"",
);
if (Object.keys(jsonSummary).length > 0) {
lines.push(
"JSON summary:",
webObserveTable(["KEYS", "COUNTS", "PARSE_ERROR"], [[
webObserveShort(webObserveText(jsonSummary.topLevelKeys), 80),
webObserveShort(webObserveText(jsonSummary.counts), 80),
webObserveShort(webObserveText(jsonSummary.parseError), 80),
]]),
"",
);
}
if (grepSummary !== null) {
lines.push(
"Grep matches:",
webObserveTable(["PATTERN_SHA", "MATCHES", "RETURNED", "TRUNC"], [[
webObserveShort(webObserveText(grepSummary.patternSha256), 24),
grepSummary.matchCount,
grepSummary.returnedLineCount,
grepSummary.truncated,
]]),
webObserveTable(["LINE", "MATCH", "TEXT"], grepLines.length > 0 ? grepLines.map((item) => [
item.lineNo,
item.match === true,
webObserveShort(webObserveText(item.text), 360),
]) : [["-", "-", "-"]]),
"Grep context:",
grepLines.length > 0
? grepLines.map((item) => "- " + webObserveText(item.lineNo) + (item.match === true ? " * " : " ") + webObserveShort(webObserveText(item.text), 500)).join("\n")
: "-",
"",
);
}
if (jsonContentPreview.length > 0) {
lines.push("JSON content:", webObserveShort(jsonContentPreview, 2400), "");
}
if (jsonRunnerErrors.length > 0) {
lines.push(
"Runner errors:",
webObserveTable(["TS", "TYPE", "ATTEMPTS", "READY", "MESSAGE"], jsonRunnerErrors.map((item) => [
webObserveShort(webObserveText(item.ts), 24),
webObserveShort(webObserveText(item.type), 20),
item.attemptCount,
webObserveShort(webObserveText(item.lastReadinessReason), 24),
webObserveShort(webObserveText(item.message ?? item.lastError), 96),
])),
"",
);
}
if (jsonFindings.length > 0) {
lines.push(
"Findings:",
webObserveTable(["KIND", "SEVERITY", "COUNT", "SUMMARY"], jsonFindings.map((item) => [
webObserveShort(webObserveText(item.kind ?? item.id ?? item.code), 48),
item.severity ?? item.level,
item.count ?? item.sampleCount,
webObserveShort(webObserveText(item.summary ?? item.message), 96),
])),
"",
);
}
if (jsonFindingDetail !== null) {
lines.push(
"Finding detail:",
webObserveTable(["ID", "SEVERITY", "COUNT", "SAMPLE_SOURCE", "SUMMARY"], [[
webObserveShort(webObserveText(jsonFindingDetail.id), 48),
jsonFindingDetail.severity,
jsonFindingDetail.count,
jsonFindingDetail.sampleSource,
webObserveShort(webObserveText(jsonFindingDetail.summary), 96),
]]),
"",
"Finding samples:",
webObserveTable(["SEQ", "TS", "KIND", "FROM", "TO", "DELTA", "SAMPLE_S", "ALLOWED", "EXCESS", "SESSION/PATH", "TRACE", "DETAIL"], jsonFindingSamples.length > 0 ? jsonFindingSamples.map((item) => [
item.seq ?? item.sampleSeq ?? item.fromSeq,
webObserveShort(webObserveText(item.ts ?? item.sampleTs ?? item.fromTs), 24),
webObserveShort(webObserveText(item.diffKind ?? item.kind ?? item.type ?? item.metric ?? item.anomaly), 28),
webObserveShort(webObserveText(item.fromValue ?? item.from ?? item.control ?? item.controlValue ?? item.beforeValue), 28),
webObserveShort(webObserveText(item.toValue ?? item.to ?? item.observer ?? item.observerValue ?? item.afterValue), 28),
item.delta,
item.sampleDeltaSeconds,
item.allowedIncreaseSeconds,
item.excessiveIncreaseSeconds,
webObserveShort(webObserveText(item.sessionId ?? item.routeSessionId ?? item.activeSessionId ?? item.path ?? item.urlPath ?? item.url), 48),
webObserveShort(webObserveText(item.trace ?? item.traceId ?? item.controlTraceId ?? item.observerTraceId), 48),
webObserveShort(webObserveText(item.detail ?? item.summary ?? item.message ?? item.preview), 96),
]) : [["-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"]]),
"",
);
}
if (jsonlTail.length > 0) {
lines.push(
"JSONL tail:",
webObserveTable(["TS", "ROLE", "TYPE", "COMMAND", "MSG", "TRACE", "TRACE_IDS", "MSG_STATUS", "LOAD", "ATTEMPTS", "READY", "DOM", "PATH", "MESSAGE"], jsonlTail.map((item) => {
const error = record(item.error);
const attempts = Array.isArray(error.attempts) ? error.attempts : [];
const lastAttempt = attempts.length > 0 ? record(attempts[attempts.length - 1]) : {};
const rawReadiness = record(lastAttempt.readiness ?? error.navigationReadiness);
const readiness = record(rawReadiness.snapshot ?? rawReadiness);
const domBits = [
`shell=${readiness.workbenchShellVisible === true ? "Y" : "n"}`,
`create=${readiness.sessionCreateVisible === true ? "Y" : "n"}`,
`input=${readiness.commandInputPresent === true ? "Y" : "n"}`,
`tab=${readiness.activeTabPresent === true ? "Y" : "n"}`,
`login=${readiness.loginVisible === true ? "Y" : "n"}`,
].join(" ");
return [
webObserveShort(webObserveText(item.ts), 24),
webObserveShort(webObserveText(item.pageRole ?? item.role), 12),
webObserveShort(webObserveText(item.type), 20),
webObserveShort(webObserveText(item.commandId), 28),
item.messageCount,
item.traceRowCount ?? item.traceEventCount,
webObserveShort(webObserveText(item.traceIds), 48),
webObserveShort(webObserveText(item.messageStatuses), 48),
item.loadingCount,
attempts.length,
webObserveShort(webObserveText(readiness.reason), 24),
webObserveShort(domBits, 48),
webObserveShort(webObserveText(item.path ?? item.urlPath ?? item.url ?? item.currentUrl ?? item.status), 60),
webObserveShort(webObserveText(error.message ?? item.message ?? item.text ?? item.preview), 96),
];
})),
"",
);
} else if (typeof file.content === "string" && file.content.length > 0) {
lines.push("Content preview:", webObserveShort(file.content, 4000), "");
}
} else {
lines.push(
"Files:",
webObserveTable(["RELATIVE", "BYTES", "SHA256"], files.length > 0 ? files.map((item) => [
webObserveShort(webObserveText(item.relative), 72),
item.byteCount,
webObserveShort(webObserveText(item.sha256), 28),
]) : [["-", "-", "-"]]),
"",
);
}
if (value.ok === false) {
lines.push(
"Blocked detail:",
webObserveTable(["REASON", "EXIT", "TIMEOUT", "STDERR"], [[
webObserveShort(webObserveText(collect.reason ?? value.degradedReason), 48),
result.exitCode,
result.timedOut,
webObserveShort(webObserveText(result.stderr), 120),
]]),
"",
);
}
lines.push("Disclosure:", " collect is bounded; use --file <relative> plus --finding <id> for id-specific drill-down; exact --grep <finding-id> also expands finding samples.");
return lines.join("\n");
}
function webObserveTable(headers: string[], rows: unknown[][]): string {
const stringRows = rows.map((row) => headers.map((_, index) => webObserveCell(row[index])));
const widths = headers.map((header, index) => Math.max(header.length, ...stringRows.map((row) => row[index]?.length ?? 0)));
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
return [renderRow(headers), ...stringRows.map(renderRow)].join("\n");
}
function webObserveCell(value: unknown, maxLength = 96): string {
if (value === null || value === undefined) return "-";
const text = typeof value === "string" ? value : String(value);
const compact = text.replace(/\s+/gu, " ").trim();
if (compact.length === 0) return "-";
if (compact.length <= maxLength) return compact;
return `${compact.slice(0, Math.max(1, maxLength - 1))}...`;
}
function webObserveText(value: unknown): string {
if (value === null || value === undefined || value === "") return "-";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function webObserveShort(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
if (maxLength <= 1) return value.slice(0, maxLength);
return `${value.slice(0, maxLength - 1)}~`;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function nullableRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}