feat: add web probe performance hotspot analysis
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
||||
// Responsibility: Analyzer Workbench session invariant and controlled-navigation root-cause source fragment.
|
||||
|
||||
export function nodeWebObserveAnalyzerSessionSource(): string {
|
||||
return String.raw`function buildSessionInvariantFindings(control, manifest = {}) {
|
||||
const findings = [];
|
||||
for (const row of control || []) {
|
||||
if (row?.type !== "assertSessionInvariant" || row?.phase !== "completed") continue;
|
||||
const detail = objectValue(row.detail);
|
||||
const messageOrder = objectValue(detail.messageOrder);
|
||||
if (messageOrder.userClustered !== true) continue;
|
||||
const afterRound = numberOrNull(detail.afterRound ?? row.input?.afterRound);
|
||||
const consecutiveUserMessageCount = numberOrNull(messageOrder.consecutiveUserMessageCount) ?? 0;
|
||||
const sentinelRange = stringOrNull(messageOrder.sentinelRange) ?? stringOrNull(detail.expectedSentinelRange);
|
||||
const traceIds = arrayStrings(messageOrder.traceIds).slice(0, 12);
|
||||
const findingId = stringOrNull(detail.findingId) ?? "workbench-message-order-user-clustered-after-navigation";
|
||||
const severity = stringOrNull(detail.severity) ?? "amber";
|
||||
const rootCause = "session_message_role_clustered";
|
||||
const rootCauseStatus = "confirmed-from-controlled-refresh-dom;check-otel-session_messages_read-role-sequence";
|
||||
const rootCauseConfidence = "medium";
|
||||
const nextAction = "Use OTel session_messages_read/session detail for the same canarySessionId and traceIds. Compare roleSequencePrefix and adjacentSameRoleCount; if the read model is already clustered, fix Workbench projection/read-model timeline ordering before changing renderer code.";
|
||||
findings.push({
|
||||
id: findingId,
|
||||
severity,
|
||||
summary: "message-order root cause visible: controlled refresh/switch-back afterRound=" + (afterRound ?? "-") + " left consecutive user message cards without interleaved assistant/code-agent terminal cards" + (sentinelRange ? " (" + sentinelRange + ")" : ""),
|
||||
rootCause,
|
||||
rootCauseStatus,
|
||||
rootCauseConfidence,
|
||||
nextAction,
|
||||
count: Math.max(1, consecutiveUserMessageCount),
|
||||
blocking: detail.blocking === true ? true : false,
|
||||
afterRound,
|
||||
canarySessionId: stringOrNull(detail.canarySessionId),
|
||||
routeSessionId: stringOrNull(detail.routeSessionId),
|
||||
activeSessionId: stringOrNull(detail.activeSessionId),
|
||||
consecutiveUserMessageCount,
|
||||
sentinelRange,
|
||||
sampleSeq: numberOrNull(detail.sampleSeq),
|
||||
traceIds,
|
||||
pageRole: stringOrNull(detail.pageRole) ?? "control",
|
||||
pageId: stringOrNull(detail.pageId),
|
||||
observerId: stringOrNull(manifest.jobId),
|
||||
stateDir: stringOrNull(manifest.stateDir),
|
||||
commandId: stringOrNull(row.commandId),
|
||||
commandTs: stringOrNull(row.ts),
|
||||
evidence: {
|
||||
afterRound,
|
||||
consecutiveUserMessageCount,
|
||||
sentinelRange,
|
||||
sampleSeq: numberOrNull(detail.sampleSeq),
|
||||
traceIds,
|
||||
canarySessionId: stringOrNull(detail.canarySessionId),
|
||||
routeSessionId: stringOrNull(detail.routeSessionId),
|
||||
activeSessionId: stringOrNull(detail.activeSessionId),
|
||||
valuesRedacted: true,
|
||||
},
|
||||
messageOrder: {
|
||||
sequence: Array.isArray(messageOrder.sequence) ? messageOrder.sequence.slice(-20) : [],
|
||||
clusters: Array.isArray(messageOrder.clusters) ? messageOrder.clusters.slice(0, 8) : [],
|
||||
valuesRedacted: true,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function buildControlledNavigationRootCauseFindings(control, manifest = {}) {
|
||||
const commands = [];
|
||||
for (const row of control || []) {
|
||||
if (row?.phase !== "completed") continue;
|
||||
if (row?.type !== "refreshCurrentSession" && row?.type !== "switchAwayAndBack") continue;
|
||||
const detail = objectValue(row.detail);
|
||||
const navigation = objectValue(detail.navigation);
|
||||
const readiness = objectValue(navigation.readiness);
|
||||
const snapshot = objectValue(readiness.snapshot);
|
||||
const pageProvenance = objectValue(navigation.pageProvenance);
|
||||
const blankShell = snapshot.workbenchShellVisible === false
|
||||
&& snapshot.sessionRailPresent === false
|
||||
&& snapshot.commandInputPresent === false
|
||||
&& snapshot.bodyTextHash === "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
const shellOrComposerMissing = snapshot.workbenchShellVisible === false
|
||||
|| snapshot.sessionRailPresent === false
|
||||
|| snapshot.commandInputPresent === false;
|
||||
const degraded = navigation.degraded === true
|
||||
|| readiness.ok === false
|
||||
|| detail.routeOk === false
|
||||
|| blankShell
|
||||
|| (detail.activeOk === false && shellOrComposerMissing)
|
||||
|| (detail.composerReady === false && snapshot.commandInputPresent === false);
|
||||
if (!degraded) continue;
|
||||
const rootCause = stringOrNull(navigation.degradedReason)
|
||||
?? (readiness.ok === false ? stringOrNull(readiness.reason) : null)
|
||||
?? (detail.routeOk === false ? "route-session-not-hydrated" : null)
|
||||
?? (blankShell ? "workbench-blank-shell-after-navigation" : null)
|
||||
?? (detail.activeOk === false && shellOrComposerMissing ? "active-session-not-hydrated" : null)
|
||||
?? (detail.composerReady === false && snapshot.commandInputPresent === false ? "composer-not-ready" : null)
|
||||
?? "controlled-navigation-degraded";
|
||||
commands.push({
|
||||
commandId: stringOrNull(row.commandId),
|
||||
type: stringOrNull(row.type),
|
||||
commandTs: stringOrNull(row.ts),
|
||||
afterRound: numberOrNull(detail.afterRound ?? row.input?.afterRound),
|
||||
rootCause,
|
||||
blocking: true,
|
||||
canarySessionId: stringOrNull(detail.canarySessionId),
|
||||
routeSessionId: stringOrNull(detail.routeSessionId),
|
||||
activeSessionId: stringOrNull(detail.activeSessionId),
|
||||
routeOk: detail.routeOk === true,
|
||||
activeOk: detail.activeOk === true,
|
||||
composerReady: detail.composerReady === true,
|
||||
navigation: {
|
||||
httpStatus: numberOrNull(navigation.httpStatus),
|
||||
degraded: navigation.degraded === true,
|
||||
degradedReason: stringOrNull(navigation.degradedReason),
|
||||
beforePath: urlPath(navigation.beforeUrl),
|
||||
afterPath: urlPath(navigation.afterUrl),
|
||||
valuesRedacted: true,
|
||||
},
|
||||
readiness: {
|
||||
ok: readiness.ok === true,
|
||||
reason: stringOrNull(readiness.reason),
|
||||
durationMs: numberOrNull(readiness.durationMs),
|
||||
path: stringOrNull(snapshot.path),
|
||||
readyState: stringOrNull(snapshot.readyState),
|
||||
workbenchShellVisible: snapshot.workbenchShellVisible === true,
|
||||
sessionCreatePresent: snapshot.sessionCreatePresent === true,
|
||||
sessionRailPresent: snapshot.sessionRailPresent === true,
|
||||
commandInputPresent: snapshot.commandInputPresent === true,
|
||||
activeTabPresent: snapshot.activeTabPresent === true,
|
||||
loginVisible: snapshot.loginVisible === true,
|
||||
blankShell,
|
||||
bodyTextHash: stringOrNull(snapshot.bodyTextHash),
|
||||
valuesRedacted: true,
|
||||
},
|
||||
pageProvenance: {
|
||||
pageLoadSeq: numberOrNull(pageProvenance.pageLoadSeq),
|
||||
reason: stringOrNull(pageProvenance.reason),
|
||||
observedAt: stringOrNull(pageProvenance.observedAt),
|
||||
urlPath: stringOrNull(pageProvenance.urlPath),
|
||||
documentReadyState: stringOrNull(pageProvenance.documentReadyState),
|
||||
timeOrigin: numberOrNull(pageProvenance.timeOrigin),
|
||||
httpStatus: numberOrNull(pageProvenance.httpStatus),
|
||||
assetFingerprint: stringOrNull(pageProvenance.assetFingerprint),
|
||||
scriptCount: numberOrNull(pageProvenance.scriptCount),
|
||||
stylesheetCount: numberOrNull(pageProvenance.stylesheetCount),
|
||||
scripts: arrayStrings(pageProvenance.scripts).slice(0, 8),
|
||||
stylesheets: arrayStrings(pageProvenance.stylesheets).slice(0, 8),
|
||||
valuesRedacted: true,
|
||||
},
|
||||
observer: {
|
||||
ok: detail.observer?.ok === true,
|
||||
pageRole: stringOrNull(detail.observer?.pageRole),
|
||||
pageId: stringOrNull(detail.observer?.pageId),
|
||||
changed: detail.observer?.changed === true,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
observerId: stringOrNull(manifest.jobId),
|
||||
stateDir: stringOrNull(manifest.stateDir),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
if (commands.length === 0) return [];
|
||||
return [{
|
||||
id: "workbench-controlled-navigation-degraded-root-cause",
|
||||
severity: "red",
|
||||
summary: "controlled Workbench refresh/switch completed degraded; route may be correct but app shell, active session, or composer was not ready, so later Code Agent turns cannot continue",
|
||||
count: commands.length,
|
||||
blocking: true,
|
||||
rootCauses: Array.from(new Set(commands.map((item) => item.rootCause))).slice(0, 12),
|
||||
commands: commands.slice(0, 20),
|
||||
next: "Investigate the first degraded command, then correlate browser requestfailed/static asset failures and Workbench hydration state before changing Code Agent/provider logic.",
|
||||
valuesRedacted: true,
|
||||
}];
|
||||
}
|
||||
|
||||
function sessionInvariantNavigationWindows(control) {
|
||||
const started = new Map();
|
||||
const windows = [];
|
||||
for (const row of control || []) {
|
||||
if (row?.type !== "switchAwayAndBack" && row?.type !== "refreshCurrentSession") continue;
|
||||
const commandId = stringOrNull(row.commandId) ?? String(row.seq ?? "");
|
||||
if (row.phase === "started") {
|
||||
started.set(commandId, row);
|
||||
continue;
|
||||
}
|
||||
if (row.phase !== "completed") continue;
|
||||
const detail = objectValue(row.detail);
|
||||
const canarySessionId = stringOrNull(detail.canarySessionId);
|
||||
const alternateSessionId = stringOrNull(detail.alternateSessionId);
|
||||
const startRow = started.get(commandId);
|
||||
const startMs = timestampMs(startRow?.ts ?? row.ts);
|
||||
const endMs = timestampMs(row.ts);
|
||||
if (!canarySessionId || !Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
||||
windows.push({
|
||||
commandId,
|
||||
afterRound: numberOrNull(detail.afterRound ?? row.input?.afterRound),
|
||||
startMs: Math.max(0, startMs - 1000),
|
||||
endMs: endMs + 5000,
|
||||
startAt: new Date(startMs).toISOString(),
|
||||
endAt: new Date(endMs).toISOString(),
|
||||
canarySessionId,
|
||||
alternateSessionId,
|
||||
routeOk: detail.routeOk === true,
|
||||
activeOk: detail.activeOk === true,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function sessionInvariantCanarySessionIds(control) {
|
||||
const ids = new Set();
|
||||
for (const row of control || []) {
|
||||
const detail = objectValue(row?.detail);
|
||||
if (row?.type === "newSession" && row?.phase === "completed") {
|
||||
const sessionId = stringOrNull(detail.sessionId)
|
||||
?? stringOrNull(detail.result?.sessionId)
|
||||
?? stringOrNull(detail.createSession?.createdSessionId);
|
||||
if (sessionId) ids.add(sessionId);
|
||||
}
|
||||
const canarySessionId = stringOrNull(detail.canarySessionId);
|
||||
if (canarySessionId) ids.add(canarySessionId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function sessionChangeSamplesOutsideControlledNavigation(samples, key, windows) {
|
||||
const canaryIds = new Set((windows || []).map((item) => item.canarySessionId).filter(Boolean));
|
||||
if (canaryIds.size === 0) return samples.filter((item) => item?.[key]);
|
||||
return (samples || []).filter((sample) => {
|
||||
const value = stringOrNull(sample?.[key]);
|
||||
if (!value) return false;
|
||||
if (canaryIds.has(value)) return false;
|
||||
return !sampleInControlledNavigationWindow(sample, windows);
|
||||
});
|
||||
}
|
||||
|
||||
function sampleInControlledNavigationWindow(sample, windows) {
|
||||
const ms = timestampMs(sample?.ts);
|
||||
if (!Number.isFinite(ms)) return false;
|
||||
return (windows || []).some((window) => ms >= window.startMs && ms <= window.endMs);
|
||||
}
|
||||
|
||||
function sampleRefInControlledNavigationSessionWindow(sample, windows) {
|
||||
const ms = timestampMs(sample?.ts);
|
||||
if (!Number.isFinite(ms)) return false;
|
||||
if (!sampleRefMatchesControlledNavigationSession(sample, windows)) return false;
|
||||
return (windows || []).some((window) => ms >= window.startMs && ms <= window.endMs);
|
||||
}
|
||||
|
||||
function sampleRefMatchesControlledNavigationSession(sample, windows) {
|
||||
const routeSessionId = stringOrNull(sample?.routeSessionId);
|
||||
const activeSessionId = stringOrNull(sample?.activeSessionId);
|
||||
return (windows || []).some((window) => {
|
||||
const expected = [window.canarySessionId, window.alternateSessionId].filter(Boolean);
|
||||
return expected.some((sessionId) => sessionId === routeSessionId || sessionId === activeSessionId);
|
||||
});
|
||||
}
|
||||
|
||||
function isBlankHydrationProjectionSample(sample) {
|
||||
if (!sample) return false;
|
||||
const messageCount = Array.isArray(sample.messages) ? sample.messages.length : Number(sample.messageCount ?? 0);
|
||||
const traceRowCount = Array.isArray(sample.traceRows) ? sample.traceRows.length : Number(sample.traceRowCount ?? 0);
|
||||
return !stringOrNull(sample.activeSessionId)
|
||||
&& Number(messageCount) === 0
|
||||
&& Number(traceRowCount) === 0;
|
||||
}
|
||||
|
||||
function controlledNavigationHydrationCrossPageDiff(row, windows, sampleBySeq) {
|
||||
if (row?.diffKind !== "projection") return false;
|
||||
if (!sampleRefMatchesControlledNavigationSession(row.control, windows) || !sampleRefMatchesControlledNavigationSession(row.observer, windows)) return false;
|
||||
const control = sampleBySeq.get(Number(row?.control?.seq));
|
||||
const observer = sampleBySeq.get(Number(row?.observer?.seq));
|
||||
return isBlankHydrationProjectionSample(control) || isBlankHydrationProjectionSample(observer);
|
||||
}
|
||||
|
||||
function crossPageDiffHasWorkbenchAppShellNotReady(row, sampleBySeq) {
|
||||
const control = sampleBySeq.get(Number(row?.control?.seq));
|
||||
const observer = sampleBySeq.get(Number(row?.observer?.seq));
|
||||
return workbenchSampleAppShellNotReady(control) || workbenchSampleAppShellNotReady(observer);
|
||||
}
|
||||
|
||||
function workbenchSampleAppShellNotReady(sample) {
|
||||
if (!sample || !isWorkbenchPathSample(sample)) return false;
|
||||
const routeSessionId = stringOrNull(sample.routeSessionId) || workbenchSessionIdFromPath(samplePathname(sample));
|
||||
if (!routeSessionId) return false;
|
||||
const messageCount = Array.isArray(sample.messages) ? sample.messages.length : Number(sample.messageCount ?? 0);
|
||||
const traceRowCount = Array.isArray(sample.traceRows) ? sample.traceRows.length : Number(sample.traceRowCount ?? 0);
|
||||
const turnCount = Array.isArray(sample.turns) ? sample.turns.length : Number(sample.turnCount ?? 0);
|
||||
const loadingCount = Array.isArray(sample.loadings) ? sample.loadings.length : 0;
|
||||
const diagnosticCount = Array.isArray(sample.diagnostics) ? sample.diagnostics.length : 0;
|
||||
const railVisibleCount = Number(sample?.sessionRail?.visibleCount ?? 0);
|
||||
const composer = objectValue(sample.composer);
|
||||
const composerPresent = composer.inputPresent === true || composer.submitPresent === true;
|
||||
const provenance = objectValue(sample.pageProvenance);
|
||||
const hasWorkbenchAssets = Number(provenance.scriptCount ?? 0) > 0 || Number(provenance.stylesheetCount ?? 0) > 0;
|
||||
return !stringOrNull(sample.activeSessionId)
|
||||
&& Number(messageCount) === 0
|
||||
&& Number(traceRowCount) === 0
|
||||
&& Number(turnCount) === 0
|
||||
&& Number(loadingCount) === 0
|
||||
&& Number(diagnosticCount) === 0
|
||||
&& Number(railVisibleCount) === 0
|
||||
&& !composerPresent
|
||||
&& hasWorkbenchAssets;
|
||||
}
|
||||
|
||||
function detectWorkbenchAppShellNotReady(samples) {
|
||||
const rows = (Array.isArray(samples) ? samples : [])
|
||||
.filter(workbenchSampleAppShellNotReady)
|
||||
.map((sample) => workbenchAppShellNotReadyRef(sample));
|
||||
return annotateWorkbenchAppShellNotReadyTiming(rows);
|
||||
}
|
||||
|
||||
function annotateWorkbenchAppShellNotReadyTiming(rows) {
|
||||
const groups = new Map();
|
||||
const splitGapMs = Math.max(1000, Number(alertThresholds.crossPageProjectionDivergenceRedMs || alertThresholds.visibleLoadingSlowMs || 10_000));
|
||||
for (const row of rows.slice().sort((a, b) => timestampMs(a.ts) - timestampMs(b.ts))) {
|
||||
const ms = timestampMs(row.ts);
|
||||
const key = [row.pageRole || "control", row.pageId || "default", row.routeSessionId || row.url || ""].join(":");
|
||||
const group = groups.get(key) || [];
|
||||
let segment = group.at(-1);
|
||||
if (!segment || !Number.isFinite(ms) || !Number.isFinite(segment.lastMs) || ms - segment.lastMs > splitGapMs) {
|
||||
segment = { rows: [], firstMs: Number.isFinite(ms) ? ms : null, lastMs: Number.isFinite(ms) ? ms : null };
|
||||
group.push(segment);
|
||||
}
|
||||
segment.rows.push(row);
|
||||
if (Number.isFinite(ms)) {
|
||||
if (segment.firstMs === null || ms < segment.firstMs) segment.firstMs = ms;
|
||||
if (segment.lastMs === null || ms > segment.lastMs) segment.lastMs = ms;
|
||||
}
|
||||
groups.set(key, group);
|
||||
}
|
||||
const result = [];
|
||||
for (const group of groups.values()) {
|
||||
for (let segmentIndex = 0; segmentIndex < group.length; segmentIndex += 1) {
|
||||
const segment = group[segmentIndex];
|
||||
const observedSpanMs = segment.firstMs === null || segment.lastMs === null ? null : segment.lastMs - segment.firstMs;
|
||||
for (const row of segment.rows) {
|
||||
result.push({
|
||||
...row,
|
||||
segmentIndex,
|
||||
observedFirstAt: segment.firstMs === null ? null : new Date(segment.firstMs).toISOString(),
|
||||
observedLastAt: segment.lastMs === null ? null : new Date(segment.lastMs).toISOString(),
|
||||
observedSpanMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function workbenchAppShellNotReadyRef(sample) {
|
||||
const provenance = objectValue(sample?.pageProvenance);
|
||||
const performance = workbenchAssetPerformanceSummary(sample);
|
||||
return {
|
||||
...ref(sample),
|
||||
title: stringOrNull(sample?.title),
|
||||
messageCount: Array.isArray(sample?.messages) ? sample.messages.length : 0,
|
||||
turnCount: Array.isArray(sample?.turns) ? sample.turns.length : 0,
|
||||
traceRowCount: Array.isArray(sample?.traceRows) ? sample.traceRows.length : 0,
|
||||
sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0),
|
||||
composerInputPresent: sample?.composer?.inputPresent === true,
|
||||
composerSubmitPresent: sample?.composer?.submitPresent === true,
|
||||
pageProvenance: {
|
||||
documentReadyState: stringOrNull(provenance.documentReadyState),
|
||||
pageLoadSeq: numberOrNull(provenance.pageLoadSeq),
|
||||
timeOrigin: numberOrNull(provenance.timeOrigin),
|
||||
assetFingerprint: stringOrNull(provenance.assetFingerprint),
|
||||
scriptCount: numberOrNull(provenance.scriptCount),
|
||||
stylesheetCount: numberOrNull(provenance.stylesheetCount),
|
||||
scripts: arrayStrings(provenance.scripts).slice(0, 8),
|
||||
stylesheets: arrayStrings(provenance.stylesheets).slice(0, 8),
|
||||
valuesRedacted: true
|
||||
},
|
||||
assetPerformance: performance,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function workbenchAssetPerformanceSummary(sample) {
|
||||
const assets = (Array.isArray(sample?.performance) ? sample.performance : [])
|
||||
.filter((entry) => /^(script|link|css)$/iu.test(String(entry?.initiatorType || "")) || /\.(?:js|css)$/iu.test(String(entry?.name || "")))
|
||||
.map((entry) => ({
|
||||
path: urlPath(entry?.name),
|
||||
initiatorType: entry?.initiatorType ?? null,
|
||||
duration: numberOrNull(entry?.duration),
|
||||
responseStatus: numberOrNull(entry?.responseStatus),
|
||||
transferSize: numberOrNull(entry?.transferSize),
|
||||
encodedBodySize: numberOrNull(entry?.encodedBodySize),
|
||||
decodedBodySize: numberOrNull(entry?.decodedBodySize),
|
||||
nextHopProtocol: entry?.nextHopProtocol ?? null,
|
||||
valuesRedacted: true
|
||||
}));
|
||||
const responseStatusCounts = {};
|
||||
for (const item of assets) {
|
||||
const key = item.responseStatus === null ? "unknown" : String(item.responseStatus);
|
||||
responseStatusCounts[key] = (responseStatusCounts[key] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
assetCount: assets.length,
|
||||
zeroStatusCount: assets.filter((item) => item.responseStatus === 0).length,
|
||||
missingStatusCount: assets.filter((item) => item.responseStatus === null).length,
|
||||
responseStatusCounts,
|
||||
assets: assets.slice(0, 12),
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function stringOrNull(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function arrayStrings(value) {
|
||||
return Array.isArray(value) ? value.map((item) => String(item || "")).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function timestampMs(value) {
|
||||
const parsed = Date.parse(String(value || ""));
|
||||
return Number.isFinite(parsed) ? parsed : NaN;
|
||||
}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user