663 lines
27 KiB
TypeScript
663 lines
27 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" | "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;
|
|
}
|
|
|
|
interface TraceEventTimingInput {
|
|
traceId: string | null | undefined;
|
|
events: TraceEvent[];
|
|
transport: "sse" | "rest_gap" | "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 = 2500;
|
|
const FLUSH_BATCH_THRESHOLD = 60;
|
|
const MAX_QUEUE = 200;
|
|
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 flushTimer: 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();
|
|
enqueueWorkbenchUiEvent({
|
|
eventType: "api_request",
|
|
loadingScope: "api",
|
|
state: "request",
|
|
reason: "api_request",
|
|
route,
|
|
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
|
|
});
|
|
}
|
|
|
|
export function recordWorkbenchSseLifecycle(input: { state: "connect" | "open" | "error" | "close"; route: string; sessionId?: string | null; traceId?: string | null; errorName?: string | null }): void {
|
|
ensureInstalled();
|
|
const state = input.state;
|
|
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: 0,
|
|
startedAtEpochMs: wallNow(),
|
|
endedAtEpochMs: wallNow(),
|
|
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 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: input.messages.length > 0 ? "ok" : "empty", 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 && typeof window !== "undefined") window.clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
installed = 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 (typeof window === "undefined" || flushTimer !== null) return;
|
|
flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS);
|
|
}
|
|
|
|
function flushWorkbenchPerformance(useBeacon: boolean): void {
|
|
if (flushTimer !== null && typeof window !== "undefined") {
|
|
window.clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
if (!queue.length || typeof window === "undefined") return;
|
|
const events = queue.splice(0, queue.length);
|
|
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 ensureInstalled(): void {
|
|
if (installed || typeof document === "undefined") return;
|
|
installed = true;
|
|
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 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();
|
|
return text === "rest_gap" || 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") return "/workbench";
|
|
return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench");
|
|
}
|
|
|
|
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));
|
|
return `/${segments.join("/")}`;
|
|
}
|
|
|
|
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 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() : "";
|
|
}
|