195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
|
|
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
|
|
// Responsibility: Source string for the pure-client HWLAB web-probe observer runner.
|
|
import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source";
|
|
import { nodeWebObserveRunnerPerformanceSource } from "./hwlab-node-web-observe-runner-performance-source";
|
|
import { nodeWebObserveRunnerRuntimeSource } from "./hwlab-node-web-observe-runner-runtime-source";
|
|
import { nodeWebObserveRunnerControlSource } from "./hwlab-node-web-observe-runner-control-source";
|
|
import { nodeWebObserveRunnerWorkbenchSource } from "./hwlab-node-web-observe-runner-workbench-source";
|
|
import { nodeWebObserveRunnerSamplingSource } from "./hwlab-node-web-observe-runner-sampling-source";
|
|
import { nodeWebObserveRunnerUtilitySource } from "./hwlab-node-web-observe-runner-utility-source";
|
|
|
|
export function nodeWebObserveRunnerSource(): string {
|
|
return String.raw`#!/usr/bin/env node
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const specRef = "PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer";
|
|
const startedAtMs = Date.now();
|
|
const startedAt = new Date(startedAtMs).toISOString();
|
|
const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL);
|
|
const username = process.env.HWLAB_WEB_USER || "admin";
|
|
const password = process.env.HWLAB_WEB_PASS || "";
|
|
const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || ".state/web-observe/manual");
|
|
const jobId = safeId(process.env.UNIDESK_WEB_OBSERVE_JOB_ID || "webobs-" + Date.now().toString(36) + "-" + randomBytes(3).toString("hex"));
|
|
const targetPath = process.env.UNIDESK_WEB_OBSERVE_TARGET_PATH || "/workbench";
|
|
const sampleIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SAMPLE_INTERVAL_MS, 5000);
|
|
const screenshotIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_INTERVAL_MS, 300000);
|
|
const screenshotCaptureTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_CAPTURE_TIMEOUT_MS, 15000, 1000, 120000);
|
|
const maxSamples = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 0);
|
|
const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000);
|
|
const maxRunMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RUN_MS, 0);
|
|
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
|
|
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
|
|
const webPerformanceRequestBodyMaxBytes = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES, 65536, 1024, 1048576);
|
|
const authLoginMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_ATTEMPTS, 6, 1, 20);
|
|
const authLoginRequestTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_REQUEST_TIMEOUT_MS, 30000, 1000, 120000);
|
|
const authLoginInitialDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_INITIAL_DELAY_MS, 500, 0, 60000);
|
|
const authLoginMaxDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_DELAY_MS, 10000, 0, 120000);
|
|
const navigationMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_NAVIGATION_MAX_ATTEMPTS, 4, 1, 10);
|
|
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
|
|
const browserFreezePolicy = parseBrowserFreezePolicy(process.env.UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON);
|
|
const projectManagement = parseProjectManagementConfig(process.env.UNIDESK_WEB_OBSERVE_PROJECT_MANAGEMENT_JSON);
|
|
const playwrightProxy = proxyConfigFromEnv(baseUrl);
|
|
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
|
|
const pageId = "control-" + randomBytes(4).toString("hex");
|
|
const observerPageId = "observer-" + randomBytes(4).toString("hex");
|
|
const dirs = {
|
|
commandsPending: path.join(stateDir, "commands", "pending"),
|
|
commandsProcessing: path.join(stateDir, "commands", "processing"),
|
|
commandsDone: path.join(stateDir, "commands", "done"),
|
|
commandsFailed: path.join(stateDir, "commands", "failed"),
|
|
screenshots: path.join(stateDir, "screenshots"),
|
|
performance: path.join(stateDir, "performance"),
|
|
performanceCaptures: path.join(stateDir, "performance", "captures"),
|
|
analysis: path.join(stateDir, "analysis"),
|
|
archive: path.join(stateDir, "archive"),
|
|
};
|
|
const files = {
|
|
manifest: path.join(stateDir, "manifest.json"),
|
|
heartbeat: path.join(stateDir, "heartbeat.json"),
|
|
control: path.join(stateDir, "control.jsonl"),
|
|
samples: path.join(stateDir, "samples.jsonl"),
|
|
network: path.join(stateDir, "network.jsonl"),
|
|
console: path.join(stateDir, "console.jsonl"),
|
|
errors: path.join(stateDir, "errors.jsonl"),
|
|
artifacts: path.join(stateDir, "artifacts.jsonl"),
|
|
browserProcess: path.join(stateDir, "browser-process.jsonl"),
|
|
performanceEvents: path.join(stateDir, "performance-events.jsonl"),
|
|
};
|
|
|
|
let browser;
|
|
let context;
|
|
let page;
|
|
let observerPage;
|
|
let lastObserverRefreshAtMs = Date.now();
|
|
let sampleSeq = 0;
|
|
let commandSeq = 0;
|
|
let artifactSeq = 0;
|
|
let activeCommandId = null;
|
|
let activeCommandType = null;
|
|
let stopping = false;
|
|
let terminalStatus = "starting";
|
|
let lastScreenshotAtMs = 0;
|
|
let screenshotCaptureState = null;
|
|
let auth = null;
|
|
let pageLoadSeq = 0;
|
|
let controlPageEpoch = 0;
|
|
let observerPageEpoch = 0;
|
|
let currentPageProvenance = null;
|
|
let heartbeatPulseTimer = null;
|
|
let browserProcessMonitorStop = null;
|
|
let browserProcessMonitorSeq = 0;
|
|
let browserFreezeBlocker = null;
|
|
const browserProcessHistory = [];
|
|
const browserPageRuntimeBaselines = new Map();
|
|
const browserFreezeSignalHistory = [];
|
|
const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] };
|
|
|
|
try {
|
|
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
|
await prepareDirs();
|
|
await rotateExistingJsonlArtifacts();
|
|
await writeManifest({ status: "starting" });
|
|
await writeHeartbeat({ status: "starting" });
|
|
heartbeatPulseTimer = startHeartbeatPulse();
|
|
if (jsonlRotation.files.length > 0) await appendJsonl(files.control, eventRecord("jsonl-rotated", { stamp: jsonlRotation.stamp, archiveDir: path.relative(stateDir, dirs.archive), files: jsonlRotation.files, valuesRedacted: true }));
|
|
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
|
const { chromium } = await launcher.importPlaywright();
|
|
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
|
|
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
|
page = await context.newPage();
|
|
attachPassiveListeners(page, "control", pageId);
|
|
auth = await runControlCommand({ id: "startup-login", type: "login", createdAt: startedAt, source: "startup" }, async () => authenticate(context));
|
|
const startupGoto = await runControlCommand({ id: "startup-goto", type: "goto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => gotoTarget(targetPath));
|
|
if (startupGoto?.degraded === true) {
|
|
const error = new Error("startup target page is not ready: " + (startupGoto.degradedReason || "target-not-ready"));
|
|
error.details = startupGoto;
|
|
error.navigationReadiness = startupGoto.readiness || null;
|
|
throw error;
|
|
}
|
|
observerPage = await context.newPage();
|
|
attachPassiveListeners(observerPage, "observer", observerPageId);
|
|
await runControlCommand({ id: "startup-observer-goto", type: "observerGoto", path: targetPath, createdAt: new Date().toISOString(), source: "startup" }, async () => {
|
|
const result = await syncObserverPageToControlSession("startup");
|
|
if (!result?.ok) {
|
|
await appendJsonl(files.control, eventRecord("observer-startup-degraded", {
|
|
reason: result?.failureKind || result?.reason || "observer-not-ready",
|
|
result: sanitize(result),
|
|
valuesRedacted: true,
|
|
}));
|
|
}
|
|
return result ?? { ok: false, reason: "observer-not-ready", pageRole: "observer", pageId: observerPageId, valuesRedacted: true };
|
|
});
|
|
browserProcessMonitorStop = startBrowserProcessMonitor();
|
|
terminalStatus = "running";
|
|
await writeManifest({ status: "running", auth: publicAuth(auth) });
|
|
await writeHeartbeat({ status: "running" });
|
|
while (!stopping) {
|
|
await drainOneCommand();
|
|
if (browserFreezeBlocker) break;
|
|
await samplePage("interval");
|
|
if (browserFreezeBlocker) break;
|
|
if (maxSamples > 0 && sampleSeq >= maxSamples) {
|
|
await appendJsonl(files.control, controlRecord({ id: "max-samples", type: "stop", source: "sampler" }, "completed", { reason: "max-samples", maxSamples }));
|
|
break;
|
|
}
|
|
if (maxRunMs > 0 && Date.now() - startedAtMs >= maxRunMs) {
|
|
await appendJsonl(files.control, controlRecord({ id: "max-run-ms", type: "stop", source: "sampler" }, "completed", { reason: "max-run-ms", maxRunMs, elapsedMs: Date.now() - startedAtMs }));
|
|
break;
|
|
}
|
|
await sleep(sampleIntervalMs);
|
|
}
|
|
if (browserFreezeBlocker) {
|
|
terminalStatus = "failed";
|
|
await writeHeartbeat({ status: "failed", blocker: browserFreezeBlocker });
|
|
await writeManifest({ status: "failed", completedAt: new Date().toISOString(), blocker: browserFreezeBlocker });
|
|
process.exitCode = browserFreezePolicy.kill.exitCode;
|
|
} else {
|
|
terminalStatus = "completed";
|
|
await writeHeartbeat({ status: "completed" });
|
|
await writeManifest({ status: "completed", completedAt: new Date().toISOString() });
|
|
process.exitCode = 0;
|
|
}
|
|
} catch (error) {
|
|
terminalStatus = "failed";
|
|
await appendJsonl(files.errors, eventRecord("runner-error", { error: errorSummary(error) })).catch(() => {});
|
|
await writeHeartbeat({ status: "failed", error: errorSummary(error) }).catch(() => {});
|
|
await writeManifest({ status: "failed", error: errorSummary(error) }).catch(() => {});
|
|
process.exitCode = 2;
|
|
} finally {
|
|
if (browserProcessMonitorStop) browserProcessMonitorStop();
|
|
if (heartbeatPulseTimer) clearInterval(heartbeatPulseTimer);
|
|
if (browser) await browser.close().catch(() => {});
|
|
}
|
|
|
|
${nodeWebObserveRunnerRuntimeSource()}
|
|
|
|
${nodeWebObserveRunnerPerformanceSource()}
|
|
|
|
${nodeWebObserveRunnerControlSource()}
|
|
|
|
${nodeWebObserveRunnerCommandActionsSource()}
|
|
|
|
${nodeWebObserveRunnerWorkbenchSource()}
|
|
|
|
${nodeWebObserveRunnerSamplingSource()}
|
|
|
|
${nodeWebObserveRunnerUtilitySource()}
|
|
|
|
|
|
`;
|
|
}
|