949 lines
40 KiB
TypeScript
949 lines
40 KiB
TypeScript
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
|
// Browser-side Workbench journey probe. Prometheus label shaping is enforced again by cloud-api.
|
|
|
|
import type { ChatMessage, TraceEvent } from "@/types";
|
|
|
|
type WorkbenchEventKind = "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible" | "workbench_ui_event";
|
|
type WorkbenchOutcome = "ok" | "timeout" | "error" | "dropped" | "stale" | "partial" | "empty" | "network" | "denied" | "unknown";
|
|
type WorkbenchLoadingScope = "workbench" | "session_list" | "session_detail" | "api" | "sse" | "page";
|
|
type WorkbenchUiState = "enter" | "exit" | "request" | "connect" | "open" | "message" | "close" | "error" | "sample";
|
|
type WorkbenchUiReason = "hydrate" | "select_session" | "create_session" | "api_request" | "sse_connect" | "sse_open" | "sse_error" | "sse_close" | "sse_flush" | "pagehide" | "visibility_hidden" | "unknown";
|
|
|
|
interface WorkbenchPerformanceEvent {
|
|
kind: WorkbenchEventKind;
|
|
valueMs: number;
|
|
route?: string;
|
|
journey?: string;
|
|
phase?: string;
|
|
eventType?: string;
|
|
backend?: string;
|
|
transport?: string;
|
|
entry?: string;
|
|
source?: string;
|
|
targetState?: string;
|
|
cache?: string;
|
|
authState?: string;
|
|
visibility?: string;
|
|
outcome?: WorkbenchOutcome;
|
|
eventName?: string;
|
|
uiTraceId?: string;
|
|
otelTraceId?: string;
|
|
spanId?: string;
|
|
parentSpanId?: string;
|
|
loadingScope?: WorkbenchLoadingScope;
|
|
state?: WorkbenchUiState;
|
|
reason?: WorkbenchUiReason;
|
|
sessionHash?: string;
|
|
traceHash?: string;
|
|
method?: string;
|
|
status?: number;
|
|
statusClass?: string;
|
|
startedAtEpochMs?: number;
|
|
endedAtEpochMs?: number;
|
|
errorName?: string;
|
|
timeoutName?: string;
|
|
activityWaitingFor?: string | null;
|
|
activityLastEventLabel?: string | null;
|
|
activityIdleMs?: number;
|
|
resourceDurationMs?: number;
|
|
resourceFetchToRequestMs?: number;
|
|
resourceRequestWaitMs?: number;
|
|
resourceResponseTransferMs?: number;
|
|
resourceTransferSize?: number;
|
|
resourceEncodedBodySize?: number;
|
|
resourceDecodedBodySize?: number;
|
|
resourceNextHopProtocol?: string;
|
|
resourceServerTiming?: string;
|
|
module?: string;
|
|
diagnosticCode?: string;
|
|
rootCause?: string;
|
|
recoveryAction?: string;
|
|
contractVersion?: string;
|
|
realtimeAuthority?: string;
|
|
sinceOutboxSeq?: number;
|
|
syncCursorOutboxSeq?: number;
|
|
entityFamilyCount?: number;
|
|
entityFamilies?: string;
|
|
maxEntityVersion?: number;
|
|
projectionRevision?: string;
|
|
terminalSeal?: boolean;
|
|
detailProjection?: boolean;
|
|
transportState?: string;
|
|
scopedKey?: string;
|
|
eventCount?: number;
|
|
deliveredCount?: number;
|
|
droppedCount?: number;
|
|
chunkCount?: number;
|
|
replacedByKey?: number;
|
|
flushDurationMs?: number;
|
|
maxItemsPerChunk?: number;
|
|
maxChunkMs?: number;
|
|
}
|
|
|
|
interface TraceEventTimingInput {
|
|
traceId: string | null | undefined;
|
|
events: TraceEvent[];
|
|
transport: "sse" | "sync_replay" | "detail_history" | "poll";
|
|
serverSentAt?: string | null | undefined;
|
|
eventCreatedAt?: string | null | undefined;
|
|
traceSeq?: number | string | null | undefined;
|
|
}
|
|
|
|
interface OpenJourneyState {
|
|
startAt: number;
|
|
route: string;
|
|
cache: string;
|
|
authState: string;
|
|
firstVisibleReported: boolean;
|
|
fullLoadReported: boolean;
|
|
}
|
|
|
|
interface SessionSwitchState {
|
|
sessionId: string;
|
|
startAt: number;
|
|
route: string;
|
|
source: string;
|
|
targetState: string;
|
|
cache: string;
|
|
firstVisibleReported: boolean;
|
|
fullLoadReported: boolean;
|
|
}
|
|
|
|
interface SubmitJourneyState {
|
|
traceId: string;
|
|
sessionId: string;
|
|
startAt: number;
|
|
route: string;
|
|
entry: string;
|
|
backend: string;
|
|
transport: string;
|
|
firstVisibleReported: boolean;
|
|
apiAcceptedReported: boolean;
|
|
}
|
|
|
|
interface TraceEventState {
|
|
traceId: string;
|
|
receivedAt: number;
|
|
projectedAt: number | null;
|
|
eventCreatedAt: number | null;
|
|
appendedAt: number | null;
|
|
serverSentAt: number | null;
|
|
traceSeq: number | null;
|
|
eventType: string;
|
|
backend: string;
|
|
transport: string;
|
|
visibleOutcome: WorkbenchOutcome;
|
|
visibleReported: boolean;
|
|
}
|
|
|
|
interface WorkbenchUiTrace {
|
|
uiTraceId: string;
|
|
otelTraceId: string;
|
|
traceparent: string;
|
|
startedAtEpochMs: number;
|
|
valuesPrinted: false;
|
|
}
|
|
|
|
interface LoadingStateRecord {
|
|
active: true;
|
|
startAt: number;
|
|
startEpochMs: number;
|
|
reason: WorkbenchUiReason;
|
|
route: string;
|
|
sessionHash?: string;
|
|
traceHash?: string;
|
|
}
|
|
|
|
const SCHEMA_VERSION = "hwlab-web-performance-v2";
|
|
const FLUSH_INTERVAL_MS = 10_000;
|
|
const PRIORITY_FLUSH_DELAY_MS = 250;
|
|
const FLUSH_BATCH_THRESHOLD = 120;
|
|
const MAX_QUEUE = 300;
|
|
const submitJourneys = new Map<string, SubmitJourneyState>();
|
|
const sessionSwitches = new Map<string, SessionSwitchState>();
|
|
const traceEvents = new Map<string, TraceEventState>();
|
|
const loadingStates = new Map<WorkbenchLoadingScope, LoadingStateRecord>();
|
|
let openJourney: OpenJourneyState | null = null;
|
|
let queue: WorkbenchPerformanceEvent[] = [];
|
|
let installed = false;
|
|
let resourceTimingBufferConfigured = false;
|
|
let flushTimer: number | null = null;
|
|
let priorityFlushTimer: number | null = null;
|
|
let uiTrace: WorkbenchUiTrace | null = null;
|
|
|
|
export function currentWorkbenchUiTrace(): WorkbenchUiTrace | null {
|
|
if (typeof window === "undefined") return null;
|
|
if (!uiTrace) {
|
|
const otelTraceId = randomHex(16);
|
|
const parentSpanId = randomHex(8);
|
|
uiTrace = {
|
|
uiTraceId: `ui_${Date.now().toString(36)}_${randomHex(6)}`,
|
|
otelTraceId,
|
|
traceparent: `00-${otelTraceId}-${parentSpanId}-01`,
|
|
startedAtEpochMs: Date.now(),
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
publishWorkbenchUiTrace(uiTrace);
|
|
return uiTrace;
|
|
}
|
|
|
|
export function recordWorkbenchLoadingState(input: { scope: WorkbenchLoadingScope; active: boolean; reason?: WorkbenchUiReason; sessionId?: string | null; traceId?: string | null }): void {
|
|
ensureInstalled();
|
|
const reason = input.reason ?? "unknown";
|
|
const now = monotonicNow();
|
|
const nowEpoch = wallNow();
|
|
const sessionHash = hashIdentifier(input.sessionId, "ses");
|
|
const traceHash = hashIdentifier(input.traceId, "trc");
|
|
const previous = loadingStates.get(input.scope);
|
|
if (input.active) {
|
|
if (previous?.active) return;
|
|
const state: LoadingStateRecord = { active: true, startAt: now, startEpochMs: nowEpoch, reason, route: pageRoute(), sessionHash, traceHash };
|
|
loadingStates.set(input.scope, state);
|
|
enqueueWorkbenchUiEvent({ eventType: "loading_state", loadingScope: input.scope, state: "enter", reason, route: state.route, valueMs: 0, startedAtEpochMs: nowEpoch, endedAtEpochMs: nowEpoch, sessionHash, traceHash, outcome: "ok" });
|
|
return;
|
|
}
|
|
if (!previous?.active) return;
|
|
loadingStates.delete(input.scope);
|
|
const valueMs = Math.max(0, now - previous.startAt);
|
|
enqueueWorkbenchUiEvent({ eventType: "loading_state", loadingScope: input.scope, state: "exit", reason: previous.reason, route: previous.route, valueMs, startedAtEpochMs: previous.startEpochMs, endedAtEpochMs: nowEpoch, sessionHash: previous.sessionHash, traceHash: previous.traceHash, outcome: "ok" });
|
|
}
|
|
|
|
export function recordWorkbenchApiRequest(input: { route: string; method?: string; status?: number; startedAtEpochMs: number; endedAtEpochMs?: number; outcome?: string | null; timeoutName?: string | null; errorName?: string | null; activityWaitingFor?: string | null; activityLastEventLabel?: string | null; activityIdleMs?: number | null }): void {
|
|
if (!isWorkbenchPage()) return;
|
|
const route = safeText(input.route);
|
|
if (!route || route.startsWith("/v1/web-performance")) return;
|
|
ensureInstalled();
|
|
const endedAtEpochMs = input.endedAtEpochMs ?? wallNow();
|
|
const resourceTiming = resourceTimingForApiRequest(route, input.startedAtEpochMs, endedAtEpochMs);
|
|
const routeLabel = routeTemplate(route);
|
|
enqueueWorkbenchUiEvent({
|
|
eventType: "api_request",
|
|
loadingScope: "api",
|
|
state: "request",
|
|
reason: "api_request",
|
|
route: routeLabel,
|
|
method: input.method ?? "GET",
|
|
status: input.status,
|
|
statusClass: statusClass(input.status),
|
|
outcome: normalizeUiOutcome(input.outcome),
|
|
timeoutName: input.timeoutName ? safeText(input.timeoutName).slice(0, 80) : undefined,
|
|
errorName: input.errorName ? safeText(input.errorName).slice(0, 80) : undefined,
|
|
activityWaitingFor: input.activityWaitingFor ? safeText(input.activityWaitingFor).slice(0, 80) : undefined,
|
|
activityLastEventLabel: input.activityLastEventLabel ? safeText(input.activityLastEventLabel).slice(0, 120) : undefined,
|
|
activityIdleMs: Number.isFinite(input.activityIdleMs ?? NaN) ? Math.max(0, Math.round(Number(input.activityIdleMs))) : undefined,
|
|
valueMs: Math.max(0, endedAtEpochMs - input.startedAtEpochMs),
|
|
startedAtEpochMs: input.startedAtEpochMs,
|
|
endedAtEpochMs,
|
|
...resourceTiming
|
|
});
|
|
}
|
|
|
|
function resourceTimingForApiRequest(route: string, startedAtEpochMs: number, endedAtEpochMs: number): Partial<WorkbenchPerformanceEvent> {
|
|
if (typeof window === "undefined" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") return {};
|
|
let target: URL;
|
|
try {
|
|
target = new URL(route, window.location.href);
|
|
} catch {
|
|
return {};
|
|
}
|
|
const timeOrigin = Number.isFinite(performance.timeOrigin) ? performance.timeOrigin : Date.now() - monotonicNow();
|
|
const entries = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
|
const lowerBound = startedAtEpochMs - 2000;
|
|
const upperBound = endedAtEpochMs + 2000;
|
|
const entry = bestResourceTimingEntry(entries, target, timeOrigin, lowerBound, upperBound, startedAtEpochMs);
|
|
clearCollectedResourceTimings(entries.length);
|
|
if (!entry) return {};
|
|
const result: Partial<WorkbenchPerformanceEvent> = {
|
|
resourceDurationMs: roundedNonNegative(entry.duration),
|
|
resourceFetchToRequestMs: roundedDelta(entry.requestStart, entry.fetchStart),
|
|
resourceRequestWaitMs: roundedDelta(entry.responseStart, entry.requestStart),
|
|
resourceResponseTransferMs: roundedDelta(entry.responseEnd, entry.responseStart),
|
|
resourceTransferSize: roundedNonNegative(entry.transferSize),
|
|
resourceEncodedBodySize: roundedNonNegative(entry.encodedBodySize),
|
|
resourceDecodedBodySize: roundedNonNegative(entry.decodedBodySize),
|
|
resourceNextHopProtocol: safeText(entry.nextHopProtocol).slice(0, 40) || undefined,
|
|
resourceServerTiming: serverTimingSummary(entry.serverTiming)
|
|
};
|
|
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined)) as Partial<WorkbenchPerformanceEvent>;
|
|
}
|
|
|
|
interface ResourceTimingCandidate {
|
|
entry: PerformanceResourceTiming;
|
|
queryExact: boolean;
|
|
initiatorRank: number;
|
|
startDistanceMs: number;
|
|
}
|
|
|
|
function bestResourceTimingEntry(entries: PerformanceResourceTiming[], target: URL, timeOrigin: number, lowerBound: number, upperBound: number, startedAtEpochMs: number): PerformanceResourceTiming | undefined {
|
|
let best: ResourceTimingCandidate | null = null;
|
|
for (const entry of entries) {
|
|
const candidate = resourceTimingCandidate(entry, target, timeOrigin, lowerBound, upperBound, startedAtEpochMs);
|
|
if (!candidate) continue;
|
|
if (!best || resourceTimingCandidateRank(candidate, best) < 0) best = candidate;
|
|
}
|
|
return best?.entry;
|
|
}
|
|
|
|
function resourceTimingCandidateRank(left: ResourceTimingCandidate, right: ResourceTimingCandidate): number {
|
|
return Number(right.queryExact) - Number(left.queryExact)
|
|
|| left.initiatorRank - right.initiatorRank
|
|
|| left.startDistanceMs - right.startDistanceMs;
|
|
}
|
|
|
|
function resourceTimingCandidate(entry: PerformanceResourceTiming, target: URL, timeOrigin: number, lowerBound: number, upperBound: number, startedAtEpochMs: number): ResourceTimingCandidate | null {
|
|
if (!entry) return null;
|
|
let url: URL;
|
|
try {
|
|
url = new URL(entry.name);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (url.origin !== target.origin) return null;
|
|
if (url.pathname !== target.pathname) return null;
|
|
const started = timeOrigin + entry.startTime;
|
|
if (started < lowerBound || started > upperBound) return null;
|
|
return {
|
|
entry,
|
|
queryExact: url.search === target.search,
|
|
initiatorRank: resourceInitiatorRank(entry.initiatorType),
|
|
startDistanceMs: Math.abs(started - startedAtEpochMs)
|
|
};
|
|
}
|
|
|
|
function clearCollectedResourceTimings(entryCount: number): void {
|
|
if (entryCount <= 0 || typeof performance === "undefined" || typeof performance.clearResourceTimings !== "function") return;
|
|
performance.clearResourceTimings();
|
|
}
|
|
|
|
function resourceInitiatorRank(value: string): number {
|
|
const initiator = safeText(value).toLowerCase();
|
|
if (initiator === "fetch") return 0;
|
|
if (initiator === "xmlhttprequest") return 1;
|
|
if (!initiator) return 2;
|
|
return 3;
|
|
}
|
|
|
|
function roundedDelta(end: number, start: number): number | undefined {
|
|
if (!Number.isFinite(end) || !Number.isFinite(start) || end < start) return undefined;
|
|
return Math.round(end - start);
|
|
}
|
|
|
|
function roundedNonNegative(value: number): number | undefined {
|
|
if (!Number.isFinite(value) || value < 0) return undefined;
|
|
return Math.round(value);
|
|
}
|
|
|
|
function serverTimingSummary(entries: readonly PerformanceServerTiming[] | undefined): string | undefined {
|
|
if (!Array.isArray(entries) || entries.length === 0) return undefined;
|
|
const summary = entries.slice(0, 8).map((entry) => {
|
|
const name = safeText(entry.name).replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 40) || "metric";
|
|
const duration = Number.isFinite(entry.duration) ? Math.round(entry.duration) : null;
|
|
return duration === null ? name : `${name}:${duration}`;
|
|
}).join(",");
|
|
return summary.slice(0, 240) || undefined;
|
|
}
|
|
|
|
export function recordWorkbenchSseLifecycle(input: { state: "connect" | "open" | "error" | "close"; route: string; sessionId?: string | null; traceId?: string | null; errorName?: string | null; valueMs?: number | null }): void {
|
|
ensureInstalled();
|
|
const state = input.state;
|
|
const endedAt = wallNow();
|
|
const valueMs = roundedNonNegative(Number(input.valueMs ?? 0)) ?? 0;
|
|
enqueueWorkbenchUiEvent({
|
|
eventType: "sse_lifecycle",
|
|
loadingScope: "sse",
|
|
state,
|
|
reason: state === "connect" ? "sse_connect" : state === "open" ? "sse_open" : state === "close" ? "sse_close" : "sse_error",
|
|
route: input.route,
|
|
valueMs,
|
|
startedAtEpochMs: Math.max(0, endedAt - valueMs),
|
|
endedAtEpochMs: endedAt,
|
|
sessionHash: hashIdentifier(input.sessionId, "ses"),
|
|
traceHash: hashIdentifier(input.traceId, "trc"),
|
|
outcome: state === "error" ? "network" : "ok",
|
|
errorName: input.errorName ? safeText(input.errorName).slice(0, 80) : undefined
|
|
});
|
|
}
|
|
|
|
export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null; diagnostic?: Record<string, unknown> | null; sessionId?: string | null; traceId?: string | null; outcome?: WorkbenchOutcome | null }): void {
|
|
ensureInstalled();
|
|
const diagnostic = recordValue(input.diagnostic);
|
|
const endedAt = wallNow();
|
|
const code = diagnosticText(diagnostic, "code") ?? "runtime_diagnostic";
|
|
const flushDurationMs = diagnosticNumber(diagnostic, "flushDurationMs");
|
|
const isSseFlush = code === "workbench_sse_flush";
|
|
enqueueWorkbenchUiEvent({
|
|
eventType: "runtime_diagnostic",
|
|
loadingScope: "sse",
|
|
state: isSseFlush ? "sample" : "error",
|
|
reason: isSseFlush ? "sse_flush" : "sse_error",
|
|
route: pageRoute(),
|
|
valueMs: flushDurationMs ?? 0,
|
|
startedAtEpochMs: Math.max(0, endedAt - (flushDurationMs ?? 0)),
|
|
endedAtEpochMs: endedAt,
|
|
sessionHash: hashIdentifier(input.sessionId ?? diagnosticText(diagnostic, "sessionId"), "ses"),
|
|
traceHash: hashIdentifier(input.traceId ?? diagnosticText(diagnostic, "traceId"), "trc"),
|
|
outcome: input.outcome ?? normalizeUiOutcome(diagnosticText(diagnostic, "outcome")),
|
|
errorName: code.slice(0, 80),
|
|
module: diagnosticLabel(input.module ?? diagnosticText(diagnostic, "module") ?? diagnosticText(diagnostic, "layer")),
|
|
diagnosticCode: diagnosticLabel(code),
|
|
rootCause: diagnosticText(diagnostic, "rootCause", "reason")?.slice(0, 160),
|
|
recoveryAction: diagnosticText(diagnostic, "recoveryAction")?.slice(0, 160),
|
|
contractVersion: diagnosticLabel(diagnosticText(diagnostic, "contractVersion")),
|
|
realtimeAuthority: diagnosticLabel(diagnosticText(diagnostic, "realtimeAuthority", "authority")),
|
|
sinceOutboxSeq: diagnosticNumber(diagnostic, "sinceOutboxSeq"),
|
|
syncCursorOutboxSeq: diagnosticNumber(diagnostic, "syncCursorOutboxSeq", "cursorOutboxSeq"),
|
|
entityFamilyCount: diagnosticNumber(diagnostic, "entityFamilyCount"),
|
|
entityFamilies: diagnosticText(diagnostic, "entityFamilies")?.slice(0, 160),
|
|
maxEntityVersion: diagnosticNumber(diagnostic, "maxEntityVersion", "entityVersion"),
|
|
projectionRevision: diagnosticText(diagnostic, "projectionRevision")?.slice(0, 120),
|
|
terminalSeal: diagnosticBoolean(diagnostic, "terminalSeal"),
|
|
detailProjection: diagnosticBoolean(diagnostic, "detailProjection"),
|
|
transportState: diagnosticLabel(diagnosticText(diagnostic, "transportState")),
|
|
scopedKey: hashIdentifier(diagnosticText(diagnostic, "scopedKey"), "scope"),
|
|
eventCount: diagnosticNumber(diagnostic, "eventCount"),
|
|
deliveredCount: diagnosticNumber(diagnostic, "deliveredCount"),
|
|
droppedCount: diagnosticNumber(diagnostic, "dropped", "droppedCount"),
|
|
chunkCount: diagnosticNumber(diagnostic, "chunkCount"),
|
|
replacedByKey: diagnosticNumber(diagnostic, "replacedByKey"),
|
|
flushDurationMs,
|
|
maxItemsPerChunk: diagnosticNumber(diagnostic, "maxItemsPerChunk"),
|
|
maxChunkMs: diagnosticNumber(diagnostic, "maxChunkMs")
|
|
});
|
|
if (isSseFlush) schedulePriorityFlush();
|
|
}
|
|
|
|
export function startWorkbenchOpenJourney(input: { route?: string; cache?: "warm" | "cold" | "unknown"; authState?: "warm" | "login_redirect" | "unknown" } = {}): void {
|
|
ensureInstalled();
|
|
openJourney = {
|
|
startAt: monotonicNow(),
|
|
route: routeTemplate(input.route ?? pageRoute()),
|
|
cache: input.cache ?? "unknown",
|
|
authState: input.authState ?? "unknown",
|
|
firstVisibleReported: false,
|
|
fullLoadReported: false
|
|
};
|
|
}
|
|
|
|
export function finishWorkbenchOpenFullLoad(outcome: WorkbenchOutcome = "ok"): void {
|
|
if (!openJourney || openJourney.fullLoadReported) return;
|
|
openJourney.fullLoadReported = true;
|
|
enqueue({
|
|
kind: "workbench_journey",
|
|
journey: "workbench_open_full_load",
|
|
route: openJourney.route,
|
|
cache: openJourney.cache,
|
|
authState: openJourney.authState,
|
|
visibility: visibilityState(),
|
|
outcome,
|
|
valueMs: elapsedSince(openJourney.startAt)
|
|
});
|
|
}
|
|
|
|
export function startWorkbenchSessionSwitch(input: { sessionId: string | null | undefined; source: "rail" | "deeplink" | "history" | "direct"; targetState?: string | null; cache?: "warm" | "cold" | "unknown" }): void {
|
|
const sessionId = safeText(input.sessionId);
|
|
if (!sessionId) return;
|
|
ensureInstalled();
|
|
sessionSwitches.set(sessionId, {
|
|
sessionId,
|
|
startAt: monotonicNow(),
|
|
route: pageRoute(),
|
|
source: input.source,
|
|
targetState: normalizeTargetState(input.targetState),
|
|
cache: input.cache ?? "unknown",
|
|
firstVisibleReported: false,
|
|
fullLoadReported: false
|
|
});
|
|
}
|
|
|
|
export function finishWorkbenchSessionSwitchFullLoad(sessionId: string | null | undefined, outcome: WorkbenchOutcome = "ok"): void {
|
|
const state = sessionSwitches.get(safeText(sessionId));
|
|
if (!state || state.fullLoadReported) return;
|
|
state.fullLoadReported = true;
|
|
enqueue({
|
|
kind: "workbench_journey",
|
|
journey: "session_switch_full_load",
|
|
route: state.route,
|
|
source: state.source,
|
|
targetState: state.targetState,
|
|
cache: state.cache,
|
|
visibility: visibilityState(),
|
|
outcome,
|
|
valueMs: elapsedSince(state.startAt)
|
|
});
|
|
}
|
|
|
|
export function failWorkbenchSessionSwitch(sessionId: string | null | undefined): void {
|
|
finishWorkbenchSessionSwitchFullLoad(sessionId, "error");
|
|
}
|
|
|
|
export function startWorkbenchSubmitJourney(input: { traceId: string; sessionId: string; entry: "new" | "existing" | "steer" | "retry"; backend?: string | null; transport?: string | null }): void {
|
|
if (!input.traceId || !input.sessionId) return;
|
|
ensureInstalled();
|
|
submitJourneys.set(input.traceId, {
|
|
traceId: input.traceId,
|
|
sessionId: input.sessionId,
|
|
startAt: monotonicNow(),
|
|
route: pageRoute(),
|
|
entry: input.entry,
|
|
backend: normalizeBackend(input.backend),
|
|
transport: normalizeTransport(input.transport ?? "sse"),
|
|
firstVisibleReported: false,
|
|
apiAcceptedReported: false
|
|
});
|
|
}
|
|
|
|
export function markWorkbenchSubmitApiAccepted(traceId: string | null | undefined): void {
|
|
const state = submitJourneys.get(safeText(traceId));
|
|
if (!state || state.apiAcceptedReported) return;
|
|
state.apiAcceptedReported = true;
|
|
enqueue({
|
|
kind: "workbench_event_phase",
|
|
phase: "user_submit_to_api_accepted",
|
|
eventType: "request",
|
|
backend: state.backend,
|
|
transport: state.transport,
|
|
outcome: "ok",
|
|
valueMs: elapsedSince(state.startAt)
|
|
});
|
|
}
|
|
|
|
export function failWorkbenchSubmitJourney(traceId: string | null | undefined, outcome: WorkbenchOutcome = "error"): void {
|
|
const state = submitJourneys.get(safeText(traceId));
|
|
if (!state || state.firstVisibleReported) return;
|
|
state.firstVisibleReported = true;
|
|
enqueue({
|
|
kind: "workbench_journey",
|
|
journey: "submit_to_failure",
|
|
route: state.route,
|
|
entry: state.entry,
|
|
backend: state.backend,
|
|
transport: state.transport,
|
|
visibility: visibilityState(),
|
|
outcome,
|
|
valueMs: elapsedSince(state.startAt)
|
|
});
|
|
submitJourneys.delete(state.traceId);
|
|
}
|
|
|
|
export function markWorkbenchTraceEventsReceived(input: TraceEventTimingInput): void {
|
|
const traceId = safeText(input.traceId);
|
|
if (!traceId) return;
|
|
const event = input.events.find(isUserVisibleTraceEvent) ?? input.events[0];
|
|
if (!event) return;
|
|
const eventType = eventTypeFromTraceEvent(event);
|
|
const backend = backendFromTraceEvent(event);
|
|
const eventCreatedAt = traceEventCreatedAt(event, input.eventCreatedAt);
|
|
const appendedAt = timestampMs(event.appendedAt);
|
|
const serverSentAt = timestampMs(input.serverSentAt);
|
|
const receivedAt = monotonicNow();
|
|
const receivedWallAt = wallNow();
|
|
const traceSeq = Number(input.traceSeq ?? event.seq ?? event.sourceSeq ?? NaN);
|
|
const visibleOutcome: WorkbenchOutcome = input.transport === "sse" && serverSentAt !== null ? "ok" : "stale";
|
|
traceEvents.set(traceId, { traceId, receivedAt, projectedAt: null, eventCreatedAt, appendedAt, serverSentAt, traceSeq: Number.isFinite(traceSeq) ? Math.trunc(traceSeq) : null, eventType, backend, transport: input.transport, visibleOutcome, visibleReported: false });
|
|
if (eventCreatedAt !== null && appendedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "created_to_append", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, appendedAt - eventCreatedAt) });
|
|
if (appendedAt !== null && serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "append_to_sse", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, serverSentAt - appendedAt) });
|
|
if (serverSentAt !== null) enqueue({ kind: "workbench_event_phase", phase: "sse_to_receive", eventType, backend, transport: input.transport, outcome: "ok", valueMs: Math.max(0, receivedWallAt - serverSentAt) });
|
|
}
|
|
|
|
export function markWorkbenchTraceProjected(traceId: string | null | undefined): void {
|
|
const state = traceEvents.get(safeText(traceId));
|
|
if (!state || state.projectedAt !== null) return;
|
|
const projectedAt = monotonicNow();
|
|
state.projectedAt = projectedAt;
|
|
enqueue({ kind: "workbench_event_phase", phase: "receive_to_project", eventType: state.eventType, backend: state.backend, transport: state.transport, outcome: "ok", valueMs: Math.max(0, projectedAt - state.receivedAt) });
|
|
}
|
|
|
|
export function acknowledgeWorkbenchVisibleAfterPaint(input: { messages: ChatMessage[]; activeSessionId?: string | null; detailLoading?: boolean }): void {
|
|
afterNextPaint(() => acknowledgeWorkbenchVisible(input));
|
|
}
|
|
|
|
export function acknowledgeWorkbenchVisible(input: { messages: ChatMessage[]; activeSessionId?: string | null; detailLoading?: boolean }): void {
|
|
const now = monotonicNow();
|
|
const route = pageRoute();
|
|
if (openJourney && !openJourney.firstVisibleReported) {
|
|
openJourney.firstVisibleReported = true;
|
|
enqueue({ kind: "workbench_journey", journey: "workbench_open_first_visible", route: openJourney.route || route, cache: openJourney.cache, authState: openJourney.authState, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - openJourney.startAt) });
|
|
}
|
|
const activeSessionId = safeText(input.activeSessionId);
|
|
const session = activeSessionId ? sessionSwitches.get(activeSessionId) : null;
|
|
if (session && !session.firstVisibleReported && !input.detailLoading) {
|
|
session.firstVisibleReported = true;
|
|
enqueue({ kind: "workbench_journey", journey: "session_switch_first_visible", route: session.route, source: session.source, targetState: session.targetState, cache: session.cache, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - session.startAt) });
|
|
}
|
|
for (const message of input.messages) {
|
|
const traceId = safeText(message.traceId ?? message.runnerTrace?.traceId);
|
|
if (!traceId || message.role !== "agent") continue;
|
|
const submit = submitJourneys.get(traceId);
|
|
const outputVisible = isVisibleAgentOutputMessage(message);
|
|
if (submit && !submit.firstVisibleReported && outputVisible) {
|
|
submit.firstVisibleReported = true;
|
|
enqueue({ kind: "workbench_journey", journey: "submit_to_first_visible", route: submit.route, entry: submit.entry, backend: submit.backend, transport: submit.transport, visibility: visibilityState(), outcome: "ok", valueMs: Math.max(0, now - submit.startAt) });
|
|
submitJourneys.delete(traceId);
|
|
}
|
|
const event = traceEvents.get(traceId);
|
|
if (event && !event.visibleReported && isVisibleTraceEventMessage(message)) {
|
|
event.visibleReported = true;
|
|
if (event.projectedAt !== null) enqueue({ kind: "workbench_event_phase", phase: "project_to_paint", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: "ok", valueMs: Math.max(0, now - event.projectedAt) });
|
|
if (event.eventCreatedAt !== null && event.visibleOutcome === "ok") enqueue({ kind: "workbench_backend_event_visible", eventType: event.eventType, backend: event.backend, transport: event.transport, outcome: event.visibleOutcome, valueMs: Math.max(0, wallNow() - event.eventCreatedAt) });
|
|
traceEvents.delete(traceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resetWorkbenchPerformanceForTest(): void {
|
|
submitJourneys.clear();
|
|
sessionSwitches.clear();
|
|
traceEvents.clear();
|
|
loadingStates.clear();
|
|
openJourney = null;
|
|
queue = [];
|
|
uiTrace = null;
|
|
if (flushTimer !== null && hasBrowserTimerRuntime()) window.clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
if (priorityFlushTimer !== null && hasBrowserTimerRuntime()) window.clearTimeout(priorityFlushTimer);
|
|
priorityFlushTimer = null;
|
|
installed = false;
|
|
resourceTimingBufferConfigured = false;
|
|
}
|
|
|
|
export function drainWorkbenchPerformanceEventsForTest(): WorkbenchPerformanceEvent[] {
|
|
const events = queue;
|
|
queue = [];
|
|
return events;
|
|
}
|
|
|
|
function enqueueWorkbenchUiEvent(event: Omit<WorkbenchPerformanceEvent, "kind" | "uiTraceId" | "otelTraceId" | "spanId">): void {
|
|
const trace = currentWorkbenchUiTrace();
|
|
if (!trace) return;
|
|
enqueue({
|
|
...event,
|
|
kind: "workbench_ui_event",
|
|
uiTraceId: trace.uiTraceId,
|
|
otelTraceId: trace.otelTraceId,
|
|
spanId: randomHex(8),
|
|
visibility: visibilityState()
|
|
});
|
|
}
|
|
|
|
function enqueue(event: WorkbenchPerformanceEvent): void {
|
|
if (!Number.isFinite(event.valueMs) || event.valueMs < 0) return;
|
|
queue.push({ ...event, valueMs: Math.round(event.valueMs * 1000) / 1000 });
|
|
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
|
|
if (queue.length >= FLUSH_BATCH_THRESHOLD) {
|
|
flushWorkbenchPerformance(false);
|
|
return;
|
|
}
|
|
scheduleFlush();
|
|
}
|
|
|
|
function scheduleFlush(): void {
|
|
if (!hasBrowserTimerRuntime() || flushTimer !== null) return;
|
|
flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS);
|
|
}
|
|
|
|
function schedulePriorityFlush(): void {
|
|
if (!hasBrowserTimerRuntime() || priorityFlushTimer !== null) return;
|
|
priorityFlushTimer = window.setTimeout(() => {
|
|
priorityFlushTimer = null;
|
|
flushWorkbenchPerformance(false);
|
|
}, PRIORITY_FLUSH_DELAY_MS);
|
|
}
|
|
|
|
function flushWorkbenchPerformance(useBeacon: boolean): void {
|
|
if (flushTimer !== null && hasBrowserTimerRuntime()) {
|
|
window.clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (priorityFlushTimer !== null && hasBrowserTimerRuntime()) {
|
|
window.clearTimeout(priorityFlushTimer);
|
|
priorityFlushTimer = null;
|
|
}
|
|
if (!queue.length || typeof window === "undefined") return;
|
|
const events = queue.splice(0, queue.length).map(enrichWorkbenchEventForFlush);
|
|
const body = JSON.stringify({ schemaVersion: SCHEMA_VERSION, serviceId: "hwlab-cloud-web", page: pageRoute(), events });
|
|
if (useBeacon && navigator.sendBeacon) {
|
|
navigator.sendBeacon("/v1/web-performance", new Blob([body], { type: "application/json" }));
|
|
return;
|
|
}
|
|
void fetch("/v1/web-performance", { method: "POST", headers: { "content-type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => undefined);
|
|
}
|
|
|
|
function enrichWorkbenchEventForFlush(event: WorkbenchPerformanceEvent): WorkbenchPerformanceEvent {
|
|
if (event.kind !== "workbench_ui_event" || event.eventType !== "api_request") return event;
|
|
if (event.resourceDurationMs !== undefined) return event;
|
|
const route = safeText(event.route);
|
|
if (!route || !Number.isFinite(event.startedAtEpochMs) || !Number.isFinite(event.endedAtEpochMs)) return event;
|
|
const timing = resourceTimingForApiRequest(route, Number(event.startedAtEpochMs), Number(event.endedAtEpochMs));
|
|
return Object.keys(timing).length > 0 ? { ...event, ...timing } : event;
|
|
}
|
|
|
|
function ensureInstalled(): void {
|
|
if (installed || typeof document === "undefined") return;
|
|
installed = true;
|
|
configureResourceTimingBuffer();
|
|
currentWorkbenchUiTrace();
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "hidden") {
|
|
enqueueWorkbenchUiEvent({ eventType: "page_lifecycle", loadingScope: "page", state: "close", reason: "visibility_hidden", route: pageRoute(), valueMs: 0, startedAtEpochMs: wallNow(), endedAtEpochMs: wallNow(), outcome: "ok" });
|
|
flushWorkbenchPerformance(true);
|
|
}
|
|
});
|
|
window.addEventListener("pagehide", () => {
|
|
enqueueWorkbenchUiEvent({ eventType: "page_lifecycle", loadingScope: "page", state: "close", reason: "pagehide", route: pageRoute(), valueMs: 0, startedAtEpochMs: wallNow(), endedAtEpochMs: wallNow(), outcome: "ok" });
|
|
flushWorkbenchPerformance(true);
|
|
});
|
|
}
|
|
|
|
function configureResourceTimingBuffer(): void {
|
|
if (resourceTimingBufferConfigured || typeof performance === "undefined" || typeof performance.setResourceTimingBufferSize !== "function") return;
|
|
resourceTimingBufferConfigured = true;
|
|
performance.setResourceTimingBufferSize(2000);
|
|
}
|
|
|
|
function afterNextPaint(callback: () => void): void {
|
|
if (typeof requestAnimationFrame !== "function") {
|
|
callback();
|
|
return;
|
|
}
|
|
requestAnimationFrame(() => requestAnimationFrame(callback));
|
|
}
|
|
|
|
function isVisibleAgentOutputMessage(message: ChatMessage): boolean {
|
|
if (isTerminalStatusText(message.status) && String(message.text ?? "").trim()) return true;
|
|
return traceEventsFromMessage(message).some(isSubmitFirstVisibleTraceEvent);
|
|
}
|
|
|
|
function isVisibleTraceEventMessage(message: ChatMessage): boolean {
|
|
return traceEventsFromMessage(message).some(isUserVisibleTraceEvent);
|
|
}
|
|
|
|
function traceEventsFromMessage(message: ChatMessage): TraceEvent[] {
|
|
return Array.isArray(message.runnerTrace?.events) ? message.runnerTrace.events : [];
|
|
}
|
|
|
|
function isUserVisibleTraceEvent(event: TraceEvent): boolean {
|
|
const type = eventTypeFromTraceEvent(event);
|
|
return type === "assistant" || type === "tool_call" || type === "backend" || type === "terminal" || type === "error";
|
|
}
|
|
|
|
function isSubmitFirstVisibleTraceEvent(event: TraceEvent): boolean {
|
|
const type = eventTypeFromTraceEvent(event);
|
|
if (type === "tool_call") return true;
|
|
return type === "assistant" && isTerminalTraceEvent(event) && traceEventHasDisplayText(event);
|
|
}
|
|
|
|
function isTerminalTraceEvent(event: TraceEvent): boolean {
|
|
return event.terminal === true || event.final === true || isTerminalStatusText(event.status);
|
|
}
|
|
|
|
function isTerminalStatusText(value: unknown): boolean {
|
|
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(safeText(value).toLowerCase().replace(/_/gu, "-"));
|
|
}
|
|
|
|
function traceEventHasDisplayText(event: TraceEvent): boolean {
|
|
return [event.message, event.text, event.outputSummary, event.stdoutSummary, event.stderrSummary, event.command].some((value) => Boolean(safeText(value)));
|
|
}
|
|
|
|
function eventTypeFromTraceEvent(event: TraceEvent | null | undefined): string {
|
|
const raw = safeText(event?.eventType ?? event?.type ?? event?.kind ?? event?.label ?? event?.status).toLowerCase();
|
|
if (/tool/u.test(raw)) return "tool_call";
|
|
if (/assistant|message|delta|reply/u.test(raw)) return "assistant";
|
|
if (/terminal|complete|final/u.test(raw)) return "terminal";
|
|
if (/error|fail|timeout/u.test(raw)) return "error";
|
|
if (/backend|runner|agentrun/u.test(raw)) return "backend";
|
|
if (/request|accepted/u.test(raw)) return "request";
|
|
return "status";
|
|
}
|
|
|
|
function backendFromTraceEvent(event: TraceEvent | null | undefined): string {
|
|
const direct = safeText(event?.backend ?? event?.backendProfile ?? event?.provider ?? event?.runnerKind);
|
|
return normalizeBackend(direct);
|
|
}
|
|
|
|
function normalizeBackend(value: unknown): string {
|
|
const text = safeText(value).toLowerCase();
|
|
if (!text) return "unknown";
|
|
if (text.includes("/")) return text;
|
|
if (/^(?:codex|deepseek|openai|claude|hyue)/u.test(text)) return `agentrun-v01/${text}`;
|
|
return text;
|
|
}
|
|
|
|
function normalizeTransport(value: unknown): string {
|
|
const text = safeText(value).toLowerCase();
|
|
if (text === "rest_gap") return "detail_history";
|
|
return text === "detail_history" || text === "sync_replay" || text === "poll" || text === "sse" ? text : "unknown";
|
|
}
|
|
|
|
function normalizeTargetState(value: unknown): string {
|
|
const text = safeText(value).toLowerCase();
|
|
if (text === "running" || text === "terminal" || text === "empty") return text;
|
|
if (["completed", "failed", "timeout", "canceled"].includes(text)) return "terminal";
|
|
return "unknown";
|
|
}
|
|
|
|
function traceEventCreatedAt(event: TraceEvent, fallback?: unknown): number | null {
|
|
const raw = safeText(event.createdAt ?? fallback ?? event.ts ?? event.timestamp);
|
|
const parsed = raw ? Date.parse(raw) : NaN;
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function timestampMs(value: unknown): number | null {
|
|
const raw = safeText(value);
|
|
const parsed = raw ? Date.parse(raw) : NaN;
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function pageRoute(): string {
|
|
if (typeof window === "undefined" || !window.location) return "/workbench";
|
|
return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench");
|
|
}
|
|
|
|
function hasBrowserTimerRuntime(): boolean {
|
|
return typeof window !== "undefined" && typeof window.setTimeout === "function" && typeof window.clearTimeout === "function";
|
|
}
|
|
|
|
function routeTemplate(input: string): string {
|
|
const raw = safeText(input) || "/workbench";
|
|
const pathOnly = raw.startsWith("#") ? raw.slice(1) || "/" : raw.split(/[?#]/u, 1)[0] || "/";
|
|
const segments = pathOnly.split("/").filter(Boolean).map((part) => routeSegmentTemplate(part));
|
|
const path = `/${segments.join("/")}`;
|
|
return `${path}${routeQueryTemplate(raw)}`;
|
|
}
|
|
|
|
function routeQueryTemplate(input: string): string {
|
|
try {
|
|
const url = new URL(input.startsWith("#") ? input.slice(1) || "/" : input, "https://hwlab.local");
|
|
if (!/^\/v1\/workbench\/sessions\/[^/]+$/u.test(url.pathname)) return "";
|
|
if (url.searchParams.get("includeMessages") === "false") return "?includeMessages=false";
|
|
if (url.searchParams.get("messages") === "0") return "?messages=0";
|
|
} catch {
|
|
// Keep route templates low-cardinality when the URL is not parseable.
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function routeSegmentTemplate(segment: string): string {
|
|
const decoded = safeDecode(segment);
|
|
if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(decoded)) return ":id";
|
|
if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(decoded)) return ":uuid";
|
|
if (/^[A-Za-z0-9_-]{20,}$/u.test(decoded)) return ":id";
|
|
if (/^\d{5,}$/u.test(decoded)) return ":id";
|
|
return decoded.replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80) || "_";
|
|
}
|
|
|
|
function safeDecode(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function visibilityState(): string {
|
|
if (typeof document === "undefined") return "unknown";
|
|
return document.visibilityState === "hidden" ? "background" : "foreground";
|
|
}
|
|
|
|
function publishWorkbenchUiTrace(trace: WorkbenchUiTrace): void {
|
|
if (typeof window === "undefined") return;
|
|
window.__HWLAB_WORKBENCH_UI_TRACE__ = { ...trace };
|
|
}
|
|
|
|
function randomHex(bytes: number): string {
|
|
const values = new Uint8Array(Math.max(1, bytes));
|
|
const cryptoSource = typeof crypto !== "undefined" ? crypto : typeof window !== "undefined" ? window.crypto : null;
|
|
if (cryptoSource?.getRandomValues) cryptoSource.getRandomValues(values);
|
|
else for (let index = 0; index < values.length; index += 1) values[index] = Math.floor(Math.random() * 256);
|
|
return [...values].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function hashIdentifier(value: unknown, prefix: string): string | undefined {
|
|
const text = safeText(value);
|
|
if (!text) return undefined;
|
|
let hash = 0x811c9dc5;
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
hash ^= text.charCodeAt(index);
|
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
}
|
|
return `${prefix}_${hash.toString(16).padStart(8, "0")}`;
|
|
}
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
}
|
|
|
|
function diagnosticText(record: Record<string, unknown> | null, ...keys: string[]): string | undefined {
|
|
if (!record) return undefined;
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function diagnosticNumber(record: Record<string, unknown> | null, ...keys: string[]): number | undefined {
|
|
if (!record) return undefined;
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
const number = Number(value);
|
|
if (Number.isFinite(number) && number >= 0) return Math.round(number);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function diagnosticBoolean(record: Record<string, unknown> | null, ...keys: string[]): boolean | undefined {
|
|
if (!record) return undefined;
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (typeof value === "boolean") return value;
|
|
if (typeof value === "string") {
|
|
const text = value.trim().toLowerCase();
|
|
if (text === "true") return true;
|
|
if (text === "false") return false;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function diagnosticLabel(value: unknown): string | undefined {
|
|
const text = safeText(value).replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80);
|
|
return text || undefined;
|
|
}
|
|
|
|
function isWorkbenchPage(): boolean {
|
|
return pageRoute().startsWith("/workbench");
|
|
}
|
|
|
|
function statusClass(status: unknown): string {
|
|
const code = typeof status === "number" ? status : Number(status);
|
|
if (Number.isFinite(code) && code >= 100 && code < 600) return `${Math.floor(code / 100)}xx`;
|
|
if (code === 0) return "network";
|
|
return "unknown";
|
|
}
|
|
|
|
function normalizeUiOutcome(value: unknown): WorkbenchOutcome {
|
|
const text = safeText(value).toLowerCase();
|
|
if (text === "ok" || text === "timeout" || text === "error" || text === "dropped" || text === "stale" || text === "partial" || text === "empty" || text === "network" || text === "unknown") return text;
|
|
if (text === "http_error") return "error";
|
|
if (text === "network_error") return "network";
|
|
return "unknown";
|
|
}
|
|
|
|
function monotonicNow(): number {
|
|
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
}
|
|
|
|
function wallNow(): number {
|
|
return Date.now();
|
|
}
|
|
|
|
function elapsedSince(startAt: number): number {
|
|
return Math.max(0, monotonicNow() - startAt);
|
|
}
|
|
|
|
function safeText(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|