feat: 添加既有会话刷新验证命令
This commit is contained in:
@@ -327,6 +327,314 @@ async function validateRealtimeFanout(command) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExistingSessionRefresh(command) {
|
||||
const profileId = String(command.profile || "").trim();
|
||||
const profile = realtimeFanoutProfiles[profileId];
|
||||
if (!profile || typeof profile !== "object") throw new Error("validateExistingSessionRefresh profile is not declared by the owning YAML: " + profileId);
|
||||
const effective = realtimeFanoutEffectiveProfile(profile, command.durationMs);
|
||||
if (effective.expectedProductSse.refreshHandoff !== true) throw new Error("validateExistingSessionRefresh requires a YAML profile with refresh handoff enabled");
|
||||
const reportDir = path.join(stateDir, "artifacts", "existing-session-refresh", safeId(command.id));
|
||||
await mkdir(reportDir, { recursive: true, mode: 0o700 });
|
||||
const network = [];
|
||||
const requestListener = (request) => {
|
||||
let url;
|
||||
try { url = new URL(request.url()); } catch { return; }
|
||||
if (url.origin !== new URL(baseUrl).origin) return;
|
||||
network.push({
|
||||
at: Date.now(),
|
||||
method: request.method(),
|
||||
path: url.pathname,
|
||||
sessionId: url.searchParams.get("sessionId"),
|
||||
traceId: url.searchParams.get("traceId"),
|
||||
afterSeq: url.searchParams.get("afterSeq"),
|
||||
lastEventId: request.headers()["last-event-id"] || null,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
if (network.length > 1000) network.splice(0, network.length - 1000);
|
||||
};
|
||||
const report = {
|
||||
ok: false,
|
||||
contract: "web-probe-existing-session-refresh-v1",
|
||||
commandId: command.id,
|
||||
profile: profileId,
|
||||
origin,
|
||||
startedAt: new Date().toISOString(),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
let phase = "session-selection";
|
||||
let sessionId = null;
|
||||
let refreshReplay = null;
|
||||
let screenshot = null;
|
||||
let requestPage = null;
|
||||
try {
|
||||
const controlRecovery = await ensureControlPageResponsiveForCommand("validateExistingSessionRefresh");
|
||||
const initial = await workbenchSessionSnapshot();
|
||||
sessionId = String(command.sessionId || initial?.activeSessionId || initial?.routeSessionId || "").trim();
|
||||
if (!sessionId) throw new Error("validateExistingSessionRefresh requires --session-id or an active Workbench session");
|
||||
if (!/^ses_[A-Za-z0-9_-]+$/u.test(sessionId)) throw new Error("validateExistingSessionRefresh session id is invalid");
|
||||
Object.assign(report, { sessionId, controlRecovery });
|
||||
if (initial?.activeSessionId !== sessionId || initial?.routeSessionId !== sessionId) {
|
||||
await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
|
||||
const selected = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs });
|
||||
if (selected.ok !== true) throw new Error("existing session did not hydrate before refresh validation");
|
||||
}
|
||||
const before = await realtimeWorkbenchDomIdentity();
|
||||
if (before.messageCount < 1) throw new Error("existing session has no rendered messages to validate across refresh");
|
||||
report.before = before;
|
||||
|
||||
phase = "refresh-navigation";
|
||||
requestPage = page;
|
||||
requestPage.on("request", requestListener);
|
||||
await realtimeArmExistingSessionRefreshProbe(requestPage, effective);
|
||||
await requestPage.reload({ waitUntil: "domcontentloaded", timeout: effective.barrierTimeoutMs });
|
||||
const settled = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs });
|
||||
if (settled.ok !== true) throw new Error("existing session did not hydrate after refresh");
|
||||
phase = "retention-barrier";
|
||||
const connected = await realtimePoll(async () => page.evaluate(() => window.__unideskExistingSessionRefresh?.connected?.at(-1) || null), effective.barrierTimeoutMs, "existing session refresh connected evidence");
|
||||
realtimeAssertConnectedContract(connected, sessionId, "existing-session-refresh", effective, { requireRetainedEvents: true });
|
||||
refreshReplay = realtimeConnectedSummary(connected).refreshReplay;
|
||||
phase = "live-handoff";
|
||||
await realtimeWaitForSameWorkbenchDom(before, effective.barrierTimeoutMs);
|
||||
await sleep(effective.reconnectQuietMs);
|
||||
const after = await realtimeWorkbenchDomIdentity();
|
||||
realtimeAssertSameWorkbenchDom(before, after);
|
||||
const browserEvidence = await page.evaluate(() => JSON.parse(JSON.stringify(window.__unideskExistingSessionRefresh || {})));
|
||||
if (browserEvidence.sources.length !== 1) throw new Error("main Workbench product EventSource count is " + browserEvidence.sources.length + ", expected 1");
|
||||
if (browserEvidence.sources[0]?.opened !== 1) throw new Error("main Workbench product EventSource open count is not 1");
|
||||
if (browserEvidence.connected.length !== 1) throw new Error("main Workbench connected event count is " + browserEvidence.connected.length + ", expected 1");
|
||||
if (browserEvidence.errors.length !== 0) throw new Error("main Workbench product EventSource observed an error");
|
||||
const mainStreams = network.filter((item) => item.path === effective.productEventsPath && item.sessionId === sessionId);
|
||||
const forbidden = network.filter((item) => realtimeForbiddenRequest(item, effective.forbiddenRequestPaths));
|
||||
if (mainStreams.length !== 1) throw new Error("main Workbench session SSE request count is " + mainStreams.length + ", expected 1");
|
||||
if (mainStreams[0].traceId !== null || mainStreams[0].afterSeq !== null || mainStreams[0].lastEventId !== null) throw new Error("main Workbench product SSE used a trace or cursor scope");
|
||||
if (forbidden.length > 0) throw new Error("forbidden recovery or polling requests observed: " + forbidden.map((item) => item.path).join(","));
|
||||
phase = "evidence-write";
|
||||
const screenshotFile = path.join(reportDir, "existing-session-refresh.png");
|
||||
await page.screenshot({ path: screenshotFile, fullPage: true, animations: "disabled", timeout: screenshotCaptureTimeoutMs });
|
||||
const screenshotMeta = await fileMeta(screenshotFile);
|
||||
screenshot = { kind: "existing-session-refresh-screenshot", path: screenshotFile, ...screenshotMeta, commandId: activeCommandId, valuesRedacted: true };
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...screenshot });
|
||||
const productSse = {
|
||||
requestCount: mainStreams.length,
|
||||
browserSourceCount: browserEvidence.sources.length,
|
||||
openCount: browserEvidence.sources[0]?.opened || 0,
|
||||
connectedEventCount: browserEvidence.connected.length,
|
||||
businessEventCount: browserEvidence.businessEventCount,
|
||||
livePhase: refreshReplay?.phase === "live",
|
||||
valuesRedacted: true,
|
||||
};
|
||||
Object.assign(report, {
|
||||
ok: true,
|
||||
status: "passed",
|
||||
phase: "live",
|
||||
code: "refresh_live_handoff_validated",
|
||||
completedAt: new Date().toISOString(),
|
||||
after,
|
||||
connected: realtimeConnectedSummary(connected),
|
||||
refreshReplay,
|
||||
productSse,
|
||||
forbiddenRequests: { count: 0, rows: [], valuesRedacted: true },
|
||||
screenshot,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh");
|
||||
report.artifacts = artifacts;
|
||||
const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report");
|
||||
const observer = await syncObserverPageToControlSession("validateExistingSessionRefresh", sessionId);
|
||||
await appendJsonl(files.control, eventRecord("existing-session-refresh-validated", {
|
||||
profile: profileId,
|
||||
sessionId,
|
||||
traceIds: after.traceIds,
|
||||
phase: "live",
|
||||
code: "refresh_live_handoff_validated",
|
||||
reportPath: reportArtifact.path,
|
||||
reportSha256: reportArtifact.sha256,
|
||||
screenshotSha256: screenshot.sha256,
|
||||
valuesRedacted: true,
|
||||
}));
|
||||
return {
|
||||
ok: true,
|
||||
status: "passed",
|
||||
profile: profileId,
|
||||
sessionId,
|
||||
traceIds: after.traceIds,
|
||||
phase: "live",
|
||||
code: "refresh_live_handoff_validated",
|
||||
before: realtimeWorkbenchDomSummary(before),
|
||||
after: realtimeWorkbenchDomSummary(after),
|
||||
refreshReplay,
|
||||
productSse,
|
||||
forbiddenRequestCount: 0,
|
||||
reportPath: reportArtifact.path,
|
||||
reportSha256: reportArtifact.sha256,
|
||||
screenshot,
|
||||
observer,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const failure = realtimeExistingRefreshFailure(phase, error);
|
||||
if (!screenshot) {
|
||||
screenshot = await captureScreenshot("existing-session-refresh-failed-" + safeId(command.id))
|
||||
.then((artifact) => ({ path: artifact.path, sha256: artifact.sha256, valuesRedacted: true }))
|
||||
.catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true }));
|
||||
}
|
||||
Object.assign(report, {
|
||||
ok: false,
|
||||
status: "failed",
|
||||
completedAt: new Date().toISOString(),
|
||||
phase: failure.phase,
|
||||
code: failure.code,
|
||||
refreshReplay,
|
||||
failure,
|
||||
screenshot,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh").catch(() => null);
|
||||
if (artifacts) report.artifacts = artifacts;
|
||||
const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report").catch(() => null);
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.details = {
|
||||
...(wrapped.details || {}),
|
||||
profile: profileId,
|
||||
sessionId,
|
||||
traceIds: report.after?.traceIds || report.before?.traceIds || [],
|
||||
phase: failure.phase,
|
||||
code: failure.code,
|
||||
before: report.before ? realtimeWorkbenchDomSummary(report.before) : null,
|
||||
after: report.after ? realtimeWorkbenchDomSummary(report.after) : null,
|
||||
refreshReplay,
|
||||
screenshot,
|
||||
reportPath: reportArtifact?.path || null,
|
||||
reportSha256: reportArtifact?.sha256 || null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
throw wrapped;
|
||||
} finally {
|
||||
if (requestPage) requestPage.off("request", requestListener);
|
||||
await page.evaluate(() => window.sessionStorage.removeItem("__unideskExistingSessionRefreshCapture")).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function realtimeExistingRefreshFailure(phase, error) {
|
||||
const codes = {
|
||||
"session-selection": "session_selection_failed",
|
||||
"refresh-navigation": "session_hydration_failed",
|
||||
"retention-barrier": "retention_barrier_failed",
|
||||
"live-handoff": "live_handoff_failed",
|
||||
"evidence-write": "evidence_write_failed",
|
||||
};
|
||||
return {
|
||||
phase,
|
||||
code: codes[phase] || "existing_session_refresh_failed",
|
||||
error: errorSummary(error),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) {
|
||||
await targetPage.addInitScript(() => {
|
||||
const markerKey = "__unideskExistingSessionRefreshCapture";
|
||||
const raw = window.sessionStorage.getItem(markerKey);
|
||||
if (!raw) return;
|
||||
window.sessionStorage.removeItem(markerKey);
|
||||
let config;
|
||||
try { config = JSON.parse(raw); } catch { return; }
|
||||
const NativeEventSource = window.EventSource;
|
||||
const state = { sources: [], connected: [], businessEventCount: 0, errors: [] };
|
||||
window.__unideskExistingSessionRefresh = state;
|
||||
if (typeof NativeEventSource !== "function") {
|
||||
state.errors.push({ kind: "eventsource-unavailable" });
|
||||
return;
|
||||
}
|
||||
class ObservedEventSource extends NativeEventSource {
|
||||
constructor(url, options) {
|
||||
super(url, options);
|
||||
let pathname = "";
|
||||
try { pathname = new URL(String(url), window.location.href).pathname; } catch {}
|
||||
if (pathname !== config.productEventsPath) return;
|
||||
state.sources.push({ url: String(url), opened: 0 });
|
||||
const source = state.sources[state.sources.length - 1];
|
||||
this.addEventListener("open", () => { source.opened += 1; });
|
||||
this.addEventListener(config.productReadyEvent, (event) => {
|
||||
try { state.connected.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "connected-parse" }); }
|
||||
});
|
||||
this.addEventListener(config.businessEvent, () => { state.businessEventCount += 1; });
|
||||
this.addEventListener("error", () => { state.errors.push({ kind: "eventsource-error" }); });
|
||||
}
|
||||
}
|
||||
Object.defineProperty(ObservedEventSource, "CONNECTING", { value: NativeEventSource.CONNECTING });
|
||||
Object.defineProperty(ObservedEventSource, "OPEN", { value: NativeEventSource.OPEN });
|
||||
Object.defineProperty(ObservedEventSource, "CLOSED", { value: NativeEventSource.CLOSED });
|
||||
window.EventSource = ObservedEventSource;
|
||||
});
|
||||
await targetPage.evaluate((config) => {
|
||||
window.sessionStorage.setItem("__unideskExistingSessionRefreshCapture", JSON.stringify(config));
|
||||
}, {
|
||||
productEventsPath: profile.productEventsPath,
|
||||
productReadyEvent: profile.productReadyEvent,
|
||||
businessEvent: profile.businessEvent,
|
||||
});
|
||||
}
|
||||
|
||||
async function realtimeWorkbenchDomIdentity() {
|
||||
return page.evaluate(() => {
|
||||
const cards = Array.from(document.querySelectorAll("#conversation-list > .message-card"));
|
||||
const rows = cards.map((element, index) => ({
|
||||
index,
|
||||
role: element.getAttribute("data-role") || null,
|
||||
messageId: element.getAttribute("data-message-id") || null,
|
||||
traceId: element.getAttribute("data-trace-id") || null,
|
||||
status: element.getAttribute("data-status") || null,
|
||||
}));
|
||||
const identity = (row) => [row.role || "", row.messageId || "", row.traceId || ""].join(":");
|
||||
const identities = rows.map(identity);
|
||||
return {
|
||||
messageCount: rows.length,
|
||||
identities,
|
||||
missingIdentityRows: rows.filter((row) => !row.role || (!row.messageId && !row.traceId)).map((row) => row.index),
|
||||
duplicateIdentities: [...new Set(identities.filter((value, index, all) => all.indexOf(value) !== index))],
|
||||
traceIds: [...new Set(rows.map((row) => row.traceId).filter(Boolean))],
|
||||
rows,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function realtimeAssertSameWorkbenchDom(before, after) {
|
||||
if (before.missingIdentityRows.length > 0 || after.missingIdentityRows.length > 0) throw new Error("Workbench DOM contains rows without a stable message identity");
|
||||
if (before.duplicateIdentities.length > 0 || after.duplicateIdentities.length > 0) throw new Error("Workbench DOM contains duplicate message identities");
|
||||
if (before.messageCount !== after.messageCount) throw new Error("Workbench message count changed across refresh: " + before.messageCount + " -> " + after.messageCount);
|
||||
if (JSON.stringify(before.identities) !== JSON.stringify(after.identities)) throw new Error("Workbench message identity or order changed across refresh");
|
||||
}
|
||||
|
||||
async function realtimeWaitForSameWorkbenchDom(before, timeoutMs) {
|
||||
let latest = null;
|
||||
return realtimePoll(async () => {
|
||||
latest = await realtimeWorkbenchDomIdentity();
|
||||
try {
|
||||
realtimeAssertSameWorkbenchDom(before, latest);
|
||||
return latest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, timeoutMs, "existing session Workbench DOM identity restoration").catch((error) => {
|
||||
const wrapped = error instanceof Error ? error : new Error(String(error));
|
||||
wrapped.details = { ...(wrapped.details || {}), before: realtimeWorkbenchDomSummary(before), after: realtimeWorkbenchDomSummary(latest), valuesRedacted: true };
|
||||
throw wrapped;
|
||||
});
|
||||
}
|
||||
|
||||
function realtimeWorkbenchDomSummary(value) {
|
||||
return value ? {
|
||||
messageCount: value.messageCount,
|
||||
identities: Array.isArray(value.identities) ? value.identities.slice(0, 80) : [],
|
||||
missingIdentityRows: Array.isArray(value.missingIdentityRows) ? value.missingIdentityRows.slice(0, 20) : [],
|
||||
duplicateIdentities: Array.isArray(value.duplicateIdentities) ? value.duplicateIdentities.slice(0, 20) : [],
|
||||
traceIds: Array.isArray(value.traceIds) ? value.traceIds.slice(0, 20) : [],
|
||||
valuesRedacted: true,
|
||||
} : null;
|
||||
}
|
||||
|
||||
function realtimeFanoutEffectiveProfile(profile, durationMs) {
|
||||
const debugStreams = Array.isArray(profile.debugStreams) ? profile.debugStreams.map(String) : [];
|
||||
if (Number(profile.subscriberCount) !== 2) throw new Error("realtime fanout profile subscriberCount must be 2");
|
||||
@@ -1041,7 +1349,7 @@ function realtimeDebugSnapshotSummary(snapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots) {
|
||||
async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots, artifactPrefix = "realtime-fanout") {
|
||||
const requestsFile = path.join(reportDir, "request-ledger.jsonl");
|
||||
const eventsFile = path.join(reportDir, "events.jsonl");
|
||||
await writeFile(requestsFile, network.map((item) => JSON.stringify(item)).join("\n") + "\n", { mode: 0o600 });
|
||||
@@ -1059,16 +1367,16 @@ async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots
|
||||
events: { path: eventsFile, ...eventMeta, count: eventRows.length, valuesRedacted: true },
|
||||
valuesRedacted: true,
|
||||
};
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger });
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-events", commandId: activeCommandId, ...artifacts.events });
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger });
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-events", commandId: activeCommandId, ...artifacts.events });
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
async function realtimeWriteReport(reportDir, report) {
|
||||
async function realtimeWriteReport(reportDir, report, artifactKind = "realtime-fanout-report") {
|
||||
const reportFile = path.join(reportDir, "report.json");
|
||||
await writeFile(reportFile, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 });
|
||||
const meta = await fileMeta(reportFile);
|
||||
const artifact = { kind: "realtime-fanout-report", path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
|
||||
const artifact = { kind: artifactKind, path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
|
||||
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact });
|
||||
return artifact;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user