241a2d511f
Co-authored-by: Codex <codex@noreply.local>
222 lines
9.0 KiB
TypeScript
222 lines
9.0 KiB
TypeScript
// 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 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 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: "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<string, unknown> {
|
|
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,
|
|
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<string, unknown> {
|
|
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,
|
|
analyzeArchivePrefix: options.analyzeArchivePrefix,
|
|
});
|
|
}
|
|
|
|
export function webObserveWrapperStateDirFromStatus(status: Record<string, unknown> | 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.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<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|