// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel. // Responsibility: Stable wrapper contract for the existing web-probe observe CLI verbs. export type WebObserveWrapperAction = "start" | "status" | "command" | "stop" | "collect" | "analyze"; export interface WebObserveWrapperInput { readonly action: WebObserveWrapperAction; readonly node: string; readonly lane: string; readonly workspace: string; readonly id: string | null; readonly jobId: string | null; readonly stateDir: string | null; readonly url: string | null; readonly targetPath: string | null; readonly commandType: string | null; readonly commandTimeoutSeconds: number | null; readonly collectView: string | null; readonly collectFile: string | null; readonly collectTraceId: string | null; readonly collectSampleSeq: number | null; readonly collectTimestamp: string | null; readonly collectTurn: number | null; readonly collectCommandId: string | null; readonly collectWindowMs: number | null; readonly analyzeArchivePrefix: string | null; } export interface WebObserveWrapperOptionsLike { readonly id: string | null; readonly jobId: string | null; readonly node: string; readonly lane: string; readonly url: string; readonly targetPath: string; readonly stateDir: string | null; readonly commandType: string | null; readonly commandTimeoutSeconds: number; readonly collectView: string; readonly collectFile: string | null; readonly collectTraceId: string | null; readonly collectSampleSeq: number | null; readonly collectTimestamp: string | null; readonly collectTurn: number | null; readonly collectCommandId: string | null; readonly collectWindowMs: number | null; readonly analyzeArchivePrefix: string | null; } export interface WebObserveWrapperOverrides { readonly id?: string | null; readonly jobId?: string | null; readonly stateDir?: string | null; readonly commandType?: string | null; } export const WEB_OBSERVE_WRAPPER_SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel"; const WEB_OBSERVE_ARTIFACT_CONTRACT = [ { path: "manifest.json", producer: "existing-observe-runner", purpose: "observer identity and immutable run settings" }, { path: "heartbeat.json", producer: "existing-observe-runner", purpose: "bounded liveness and current browser state" }, { path: "samples.jsonl", producer: "existing-observe-runner", purpose: "DOM, trace, session, timing, and final response samples" }, { path: "control.jsonl", producer: "existing-observe-runner", purpose: "explicit user/control actions and command results" }, { path: "network.jsonl", producer: "existing-observe-runner", purpose: "request, response, requestfailed, and timing evidence" }, { path: "console.jsonl", producer: "existing-observe-runner", purpose: "browser console/runtime evidence" }, { path: "artifacts.jsonl", producer: "existing-observe-runner", purpose: "screenshots and auxiliary artifact index" }, { path: "performance-events.jsonl", producer: "existing-observe-runner", purpose: "LongTask, LoAF, event-loop gap, and performance capture events" }, { path: "performance/captures/*/{profile.cpuprofile,summary.json}", producer: "observe-command-cli", purpose: "explicit CPU profile captures for frontend hotspot attribution" }, { path: "commands/{pending,processing,done,failed}/*.json", producer: "observe-command-cli", purpose: "durable command queue handoff" }, { path: "analysis/report.json", producer: "existing-observe-analyzer", purpose: "offline machine-readable findings" }, { path: "analysis/report.md", producer: "existing-observe-analyzer", purpose: "offline human-readable report" }, ] as const; const WEB_OBSERVE_CLI_DEFAULT_DISCLOSURE = { targetPath: "/workbench", viewport: "1440x900", sampleIntervalMs: 5000, screenshotIntervalMs: 300000, observerRefreshIntervalMs: 180000, maxSamples: 0, commandTimeoutSeconds: 55, waitMs: 0, tailLines: 5, maxFiles: 80, collectView: "files", source: "current observe CLI parser defaults; P2 must move sentinel policy into YAML configRefs", valuesRedacted: true, } as const; export function buildWebObserveWrapperContract(input: WebObserveWrapperInput): Record { const observerId = input.id ?? input.jobId; const stateDir = input.stateDir; return { specRef: WEB_OBSERVE_WRAPPER_SPEC_REF, adapter: "web-probe-observe-cli-wrapper", adapterVersion: 1, mode: "wrapper-only", action: input.action, node: input.node, lane: input.lane, workspace: input.workspace, id: observerId, jobId: input.jobId ?? observerId, cli: { family: "bun scripts/cli.ts web-probe observe", commandShape: webObserveWrapperCommandShape(input), verb: input.action, valuesRedacted: true, }, effectiveParameters: { url: input.url, targetPath: input.targetPath, commandType: input.commandType, commandTimeoutSeconds: input.commandTimeoutSeconds, collectView: input.collectView, collectFile: input.collectFile, collectTraceId: input.collectTraceId, collectSampleSeq: input.collectSampleSeq, collectTimestamp: input.collectTimestamp, collectTurn: input.collectTurn, collectCommandId: input.collectCommandId, collectWindowMs: input.collectWindowMs, analyzeArchivePrefix: input.analyzeArchivePrefix, valuesRedacted: true, }, cliDefaults: WEB_OBSERVE_CLI_DEFAULT_DISCLOSURE, artifacts: { stateDir, rootKnown: stateDir !== null, manifest: artifactPath(stateDir, "manifest.json"), heartbeat: artifactPath(stateDir, "heartbeat.json"), samples: artifactPath(stateDir, "samples.jsonl"), control: artifactPath(stateDir, "control.jsonl"), network: artifactPath(stateDir, "network.jsonl"), console: artifactPath(stateDir, "console.jsonl"), analysisReportJson: artifactPath(stateDir, "analysis/report.json"), analysisReportMd: artifactPath(stateDir, "analysis/report.md"), contract: WEB_OBSERVE_ARTIFACT_CONTRACT, valuesRedacted: true, }, invariants: { wrapperOnly: true, reusesExistingObserveRunner: true, reusesExistingObserveAnalyzer: true, createsSecondPlaywrightRunner: false, createsSecondAnalyzer: false, createsSecondStateMachine: false, usesPrivateWebProbeApi: false, collectViewsRenderExistingArtifactsOnly: true, analyzeReadsExistingArtifactsOnly: true, valuesRedacted: true, }, nextStageBoundary: { p2: "YAML/configRefs must own sentinel policy and scenario cadence.", p3: "The persistent service may orchestrate this wrapper, not call Playwright or analyzer internals directly.", valuesRedacted: true, }, valuesRedacted: true, }; } export function buildWebObserveWrapperForObserveOptions( action: WebObserveWrapperAction, options: WebObserveWrapperOptionsLike, workspace: string, overrides: WebObserveWrapperOverrides = {}, ): Record { const id = overrides.id ?? options.id ?? options.jobId; return buildWebObserveWrapperContract({ action, node: options.node, lane: options.lane, workspace, id, jobId: overrides.jobId ?? options.jobId ?? id, stateDir: overrides.stateDir ?? options.stateDir, url: options.url, targetPath: options.targetPath, commandType: overrides.commandType ?? options.commandType, commandTimeoutSeconds: options.commandTimeoutSeconds, collectView: options.collectView, collectFile: options.collectFile, collectTraceId: options.collectTraceId, collectSampleSeq: options.collectSampleSeq, collectTimestamp: options.collectTimestamp, collectTurn: options.collectTurn, collectCommandId: options.collectCommandId, collectWindowMs: options.collectWindowMs, analyzeArchivePrefix: options.analyzeArchivePrefix, }); } export function webObserveWrapperStateDirFromStatus(status: Record | null, explicitStateDir: string | null): string | null { const heartbeat = record(status?.heartbeat); const manifest = record(status?.manifest); const heartbeatStateDir = typeof heartbeat.stateDir === "string" ? heartbeat.stateDir : null; const manifestStateDir = typeof manifest.stateDir === "string" ? manifest.stateDir : null; return explicitStateDir ?? heartbeatStateDir ?? manifestStateDir; } function artifactPath(stateDir: string | null, relative: string): string | null { return stateDir === null ? null : `${stateDir}/${relative}`; } function webObserveWrapperCommandShape(input: WebObserveWrapperInput): string { const base = ["bun", "scripts/cli.ts", "web-probe", "observe", input.action]; const observerId = input.id ?? input.jobId; const parts = observerId === null ? [...base, "--node", input.node, "--lane", input.lane] : [...base, observerId]; if (observerId === null && input.stateDir !== null && input.action !== "start") { parts.push("--state-dir", input.stateDir); } if (input.action === "start" && input.targetPath !== null) { parts.push("--target-path", input.targetPath); } if (input.action === "command" && input.commandType !== null) { parts.push("--type", input.commandType); } if (input.action === "collect") { if (input.collectView !== null) parts.push("--view", input.collectView); if (input.collectFile !== null) parts.push("--file", input.collectFile); if (input.collectTraceId !== null) parts.push("--trace-id", input.collectTraceId); if (input.collectSampleSeq !== null) parts.push("--sample-seq", String(input.collectSampleSeq)); if (input.collectTimestamp !== null) parts.push("--timestamp", input.collectTimestamp); if (input.collectTurn !== null) parts.push("--turn", String(input.collectTurn)); if (input.collectCommandId !== null) parts.push("--command-id", input.collectCommandId); if (input.collectWindowMs !== null) parts.push("--window-ms", String(input.collectWindowMs)); } if (input.action === "analyze" && input.analyzeArchivePrefix !== null) { parts.push("--archive-prefix", input.analyzeArchivePrefix); } return parts.map(shellWord).join(" "); } function shellWord(value: string): string { if (/^[A-Za-z0-9_./:=@+-]+$/u.test(value)) return value; return `'${value.replace(/'/gu, "'\\''")}'`; } function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; }