// 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"; import { nodeWebObserveRunnerRealtimeSource } from "./hwlab-node-web-observe-runner-realtime-source"; import { nodeWebObserveRunnerKafkaDebugReplaySource } from "./hwlab-node-web-observe-runner-kafka-debug-replay-source"; import { nodeWebObserveRunnerTraceReadabilitySource } from "./hwlab-node-web-observe-runner-trace-readability-source"; export function nodeWebObserveRunnerSource(): string { return String.raw`#!/usr/bin/env node import { createHash, randomBytes } from "node:crypto"; import { createReadStream } from "node:fs"; 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 = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, "UNIDESK_WEB_OBSERVE_MAX_SAMPLES", 1, 10000000); const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000); const maxRunMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RUN_MS, "UNIDESK_WEB_OBSERVE_MAX_RUN_MS", 1000, 86400000); const maxScreenshots = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS, "UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS", 1, 10000); const maxPerformanceEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES", 1, 100000); const maxBrowserProcessHistoryEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_BROWSER_PROCESS_HISTORY_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_BROWSER_PROCESS_HISTORY_ENTRIES", 1, 100000); const maxBrowserFreezeSignalHistoryEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_BROWSER_FREEZE_SIGNAL_HISTORY_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_BROWSER_FREEZE_SIGNAL_HISTORY_ENTRIES", 1, 100000); const maxRealtimeEventEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_ENTRIES", 1, 100000); const maxRealtimeEventBodyBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES", 1, 16777216); const maxDomEvidenceRows = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_DOM_EVIDENCE_ROWS, "UNIDESK_WEB_OBSERVE_MAX_DOM_EVIDENCE_ROWS", 1, 100000); const maxProcessScanEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PROCESS_SCAN_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_PROCESS_SCAN_ENTRIES", 1, 1000000); const maxPerformanceCaptureMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_CAPTURE_SECONDS, "UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_CAPTURE_SECONDS", 1, 600) * 1000; const responseBodyMaxBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_RESPONSE_BODY_MAX_BYTES, "UNIDESK_WEB_OBSERVE_RESPONSE_BODY_MAX_BYTES", 1024, 16777216); const maxJsonlQueueEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_ENTRIES", 1, 100000); const maxJsonlQueueBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_BYTES, "UNIDESK_WEB_OBSERVE_MAX_JSONL_QUEUE_BYTES", 1024, 67108864); const maxJsonlLineBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_JSONL_LINE_BYTES, "UNIDESK_WEB_OBSERVE_MAX_JSONL_LINE_BYTES", 1024, 16777216); const jsonlFlushTimeoutMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_JSONL_FLUSH_TIMEOUT_MS, "UNIDESK_WEB_OBSERVE_JSONL_FLUSH_TIMEOUT_MS", 100, 60000); const maxResponseBodyInFlight = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RESPONSE_BODY_IN_FLIGHT, "UNIDESK_WEB_OBSERVE_MAX_RESPONSE_BODY_IN_FLIGHT", 1, 64); const maxPassiveListenerTasks = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PASSIVE_LISTENER_TASKS, "UNIDESK_WEB_OBSERVE_MAX_PASSIVE_LISTENER_TASKS", 1, 10000); const passiveTaskFlushTimeoutMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_PASSIVE_TASK_FLUSH_TIMEOUT_MS, "UNIDESK_WEB_OBSERVE_PASSIVE_TASK_FLUSH_TIMEOUT_MS", 100, 60000); const closeStepTimeoutMs = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_CLOSE_STEP_TIMEOUT_MS, "UNIDESK_WEB_OBSERVE_CLOSE_STEP_TIMEOUT_MS", 100, 60000); const maxProcessReadConcurrency = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_PROCESS_READ_CONCURRENCY, "UNIDESK_WEB_OBSERVE_MAX_PROCESS_READ_CONCURRENCY", 1, 256); const maxRealtimeTotalEntries = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_ENTRIES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_ENTRIES", 1, 100000); const maxRealtimeTotalBodyBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_BODY_BYTES, "UNIDESK_WEB_OBSERVE_MAX_REALTIME_TOTAL_BODY_BYTES", 1024, 67108864); const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900"); const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto"); const origin = { name: process.env.UNIDESK_WEB_OBSERVE_ORIGIN_NAME || "custom", mode: process.env.UNIDESK_WEB_OBSERVE_ORIGIN_MODE || "custom-url", configPath: process.env.UNIDESK_WEB_OBSERVE_ORIGIN_CONFIG_PATH || null, browserProxyMode, browserProxyModeSource: process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE_SOURCE || "default", valuesRedacted: true, }; const webPerformanceRequestBodyMaxBytes = requiredBoundedInteger(process.env.UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES, "UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES", 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 realtimeFanoutProfiles = parseRealtimeFanoutProfiles(process.env.UNIDESK_WEB_OBSERVE_REALTIME_FANOUT_PROFILES_JSON); const workbenchKafkaDebugReplay = parseWorkbenchKafkaDebugReplayConfig(process.env.UNIDESK_WEB_OBSERVE_WORKBENCH_KAFKA_DEBUG_REPLAY_JSON); const workbenchTraceReadability = parseWorkbenchTraceReadabilityConfig(process.env.UNIDESK_WEB_OBSERVE_WORKBENCH_TRACE_READABILITY_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 screenshotCount = 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; let shutdownSignal = null; let browserResourcesClosePromise = null; let browserLaunchAttempted = false; const browserProcessHistory = []; const browserPageRuntimeBaselines = new Map(); const browserFreezeSignalHistory = []; let trackedBrowserProcessIdentities = []; const jsonlRotation = { stamp: compactFileTimestamp(startedAt), files: [] }; const jsonlWriterState = { queue: [], queuedBytes: 0, inFlight: 0, pump: null, acceptedEntries: 0, acceptedBytes: 0, writtenEntries: 0, writtenBytes: 0, droppedEntries: 0, droppedBytes: 0, failedEntries: 0, dropsByFile: {} }; const responseBodyState = { inFlight: 0, accepted: 0, skippedSaturated: 0, completed: 0, failed: 0 }; const passiveTaskState = { tasks: new Set(), accepted: 0, dropped: 0, completed: 0, failed: 0, drainTimedOut: false }; const runnerSleepWakes = new Set(); let lastProcessScan = { ok: false, complete: false, totalPidEntries: 0, scannedPidEntries: 0, failedPidEntries: 0, truncated: false, readConcurrency: maxProcessReadConcurrency, error: "not-scanned", valuesRedacted: true }; let realtimeAggregateLast = null; for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { process.once(signal, () => { shutdownSignal = signal; stopping = true; terminalStatus = "stopping"; interruptRunnerSleep(); void closeBrowserResources("signal:" + signal); }); } 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(); browserLaunchAttempted = true; 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) { 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; } if (maxSamples > 0 && sampleSeq >= maxSamples) { await appendJsonl(files.control, controlRecord({ id: "max-samples", type: "stop", source: "sampler" }, "completed", { reason: "max-samples", maxSamples })); break; } await drainOneCommand(); if (browserFreezeBlocker) break; await samplePage("interval"); if (browserFreezeBlocker) break; await interruptibleRunnerSleep(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); interruptRunnerSleep(); const closeResult = await closeBrowserResources(shutdownSignal === null ? "finally" : "signal:" + shutdownSignal); const passiveTaskDrain = await drainPassiveTasks(); const terminalFlush = await flushJsonlQueue(); await writeTerminalResourceStats({ closeResult, passiveTaskDrain, terminalFlush }).catch(() => {}); } async function closeBrowserResources(reason) { if (browserResourcesClosePromise) return browserResourcesClosePromise; browserResourcesClosePromise = (async () => { stopping = true; const closingObserverPage = observerPage; const closingPage = page; const closingContext = context; const closingBrowser = browser; let preCloseIdentityDiscovery; try { const summary = await collectChromiumProcessSummary(null); preCloseIdentityDiscovery = { ok: true, processScan: summary.processScan, chromiumProcessCount: summary.chromiumProcessCount, valuesRedacted: true }; } catch (error) { preCloseIdentityDiscovery = { ok: false, processScan: lastProcessScan, error: errorSummary(error), valuesRedacted: true }; } observerPage = null; page = null; context = null; browser = null; const steps = []; const closeStep = async (label, operation) => { try { await withHardTimeout(Promise.resolve().then(operation), closeStepTimeoutMs, label + " close exceeded " + closeStepTimeoutMs + "ms"); steps.push({ label, ok: true, valuesRedacted: true }); } catch (error) { steps.push({ label, ok: false, error: errorSummary(error), valuesRedacted: true }); } }; if (closingObserverPage && !closingObserverPage.isClosed()) await closeStep("observer-page", () => closingObserverPage.close()); if (closingPage && !closingPage.isClosed()) await closeStep("control-page", () => closingPage.close()); if (closingContext) await closeStep("browser-context", () => closingContext.close()); if (closingBrowser) await closeStep("browser", () => closingBrowser.close()); const browserCleanup = await terminateTrackedBrowserProcesses("browser-close-residual", preCloseIdentityDiscovery); return { ok: steps.every((step) => step.ok === true) && browserCleanup.ok === true, reason, closeStepTimeoutMs, steps, browserCleanup, valuesRedacted: true }; })(); return browserResourcesClosePromise; } ${nodeWebObserveRunnerRuntimeSource()} ${nodeWebObserveRunnerPerformanceSource()} ${nodeWebObserveRunnerControlSource()} ${nodeWebObserveRunnerCommandActionsSource()} ${nodeWebObserveRunnerWorkbenchSource()} ${nodeWebObserveRunnerRealtimeSource()} ${nodeWebObserveRunnerKafkaDebugReplaySource()} ${nodeWebObserveRunnerTraceReadabilitySource()} ${nodeWebObserveRunnerSamplingSource()} ${nodeWebObserveRunnerUtilitySource()} `; }