1431 lines
63 KiB
TypeScript
1431 lines
63 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-read-model-timeline-contract; draft-2026-06-28-p0-d518-session-timeline-consistency; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-workbench-read-api-contract; PJ2026-010401 Web工作台 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-workbench-read-model-contract; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
|
|
* 职责: Workbench REST read model。只读投影 session/message/turn/trace facts,不执行 AgentRun sync、billing finalize 或 workspace repair。
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
|
|
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
|
import {
|
|
parsePositiveInteger,
|
|
safeConversationId,
|
|
safeOpaqueId,
|
|
safeSessionId,
|
|
safeTraceId,
|
|
sendJson
|
|
} from "./server-http-utils.ts";
|
|
import { createWorkbenchReadModel } from "./workbench-read-model.ts";
|
|
import { createWorkbenchRuntimeClient } from "./workbench-runtime-client.ts";
|
|
import { buildWorkbenchSessionDetail, compactLaunchContext, includeMessagesForSessionDetail } from "./workbench-session-detail-response.ts";
|
|
import { handleWorkbenchSyncHttp } from "./workbench-realtime-authority.ts";
|
|
import { workbenchRealtimeCapabilities } from "./workbench-realtime-capabilities.ts";
|
|
import { durableTraceStatus, RUNNING_STATUSES, terminalFinalResponse, TERMINAL_STATUSES } from "./workbench-turn-projection.ts";
|
|
import { emitCodeAgentOtelSpan, emitHttpServerRequestSpan } from "./otel-trace.ts";
|
|
import * as workbenchFacts from "./server-workbench-facts.ts";
|
|
|
|
const DEFAULT_PAGE_LIMIT = 50;
|
|
const DEFAULT_SESSION_LIST_LIMIT = 20;
|
|
const MAX_PAGE_LIMIT = 100;
|
|
const WORKBENCH_SESSION_LIST_PAGE_FAMILIES = Object.freeze(["sessions"]);
|
|
const WORKBENCH_SESSION_DETAIL_FAMILIES = Object.freeze(["sessions", "messages", "parts", "turns", "checkpoints"]);
|
|
const WORKBENCH_SESSION_MESSAGE_PAGE_FAMILIES = Object.freeze(["sessions", "messages", "parts", "turns", "checkpoints"]);
|
|
const WORKBENCH_TRACE_EVENT_METADATA_FAMILIES = Object.freeze(["sessions", "messages", "parts", "turns", "checkpoints"]);
|
|
const WORKBENCH_TRACE_EVENT_PAGE_FAMILIES = Object.freeze(["traceEvents"]);
|
|
|
|
function attachWorkbenchReadModelDiagnosticOtel(request, fields = {}) {
|
|
const context = request?.hwlabHttpRequestContext;
|
|
if (!context) return;
|
|
const code = textValue(fields.code) || "workbench_read_model_not_found";
|
|
const route = textValue(fields.route) || context.route || "/v1/workbench";
|
|
context.route = route;
|
|
context.lastHttpError = { code, category: "workbench_read_model", layer: "workbench.read_model" };
|
|
context.otelAttributes = {
|
|
...(context.otelAttributes ?? {}),
|
|
"workbench.route": route,
|
|
"workbench.read_model.route": route,
|
|
"workbench.read_model.result": code,
|
|
"workbench.read_model.count": Number.isFinite(Number(fields.count)) ? Math.trunc(Number(fields.count)) : null,
|
|
"workbench.session_id": fields.sessionId ?? null,
|
|
"workbench.trace_id": fields.traceId ?? null,
|
|
"workbench.turn_id": fields.turnId ?? null,
|
|
traceId: fields.traceId ?? null,
|
|
"error.code": code,
|
|
"error.category": "workbench_read_model",
|
|
"error.layer": "workbench.read_model"
|
|
};
|
|
}
|
|
|
|
const {
|
|
factSessionSummary,
|
|
factSessionDetail,
|
|
factMessagesForSession,
|
|
canonicalFactMessageGroupsForSession,
|
|
factMessageCanonicalGroupKey,
|
|
selectCanonicalFactMessage,
|
|
factMessageCanonicalScore,
|
|
factMessageHasFinalResponsePart,
|
|
workbenchLifecycleMessageId,
|
|
factMessageDto,
|
|
factMessageAuthorityText,
|
|
factPartsHaveFinalResponse,
|
|
factSyntheticTerminalFinalResponse,
|
|
isTerminalProjectionStatus,
|
|
firstFactPartText,
|
|
factMessageFinalResponseText,
|
|
factTerminalMessageForTrace,
|
|
factTerminalStatusFromMessageProjection,
|
|
factPartDto,
|
|
factTurnForTrace,
|
|
factTurnSnapshot,
|
|
factTraceSnapshot,
|
|
factTraceTimingProjection,
|
|
factTraceEventIsTerminalAuthority,
|
|
factTraceEventTerminalTimes,
|
|
factTraceEventDto,
|
|
factProjectionForTrace,
|
|
factTimingProjection,
|
|
factCombinedTimingProjection,
|
|
factTimingSource,
|
|
firstTimestampIso,
|
|
latestTimestampIso,
|
|
elapsedFactMs,
|
|
timestampIso,
|
|
factCheckpointForTrace,
|
|
workbenchProjectionStoreError,
|
|
nonNegativeIntegerOrNull,
|
|
factSessionId,
|
|
factLastTraceId,
|
|
factLatestTraceIdForSession,
|
|
factCurrentTraceForSession,
|
|
factMessageTraceCandidate,
|
|
factTraceCandidate,
|
|
compareTraceCandidatesDesc,
|
|
nonUnknownStatus,
|
|
factTurnId,
|
|
factUpdatedAt,
|
|
factSeq,
|
|
factProjectedSeq,
|
|
normalizeProjectionStatus,
|
|
normalizeProjectionHealth,
|
|
compareFactSessionsDesc,
|
|
compareFactMessagesAsc,
|
|
compareFactMessagesForSessionAsc,
|
|
factMessageTimelineKey,
|
|
factTimelineAnchorMessageForTrace,
|
|
factMessageTimelineRoleRank,
|
|
firstFiniteNumber,
|
|
compareOptionalNumberAsc,
|
|
compareFactPartsAsc,
|
|
compareFactTraceEventsAsc,
|
|
compareFactRecordsDesc,
|
|
compareTimestampAsc,
|
|
compareTimestampDesc,
|
|
compareOptionalTimestampAsc,
|
|
compareNumberAsc,
|
|
compareNumberDesc,
|
|
compareText,
|
|
timestampMs,
|
|
optionalTimestampMs,
|
|
numericValue,
|
|
mergeFactSets,
|
|
emptyFactSet,
|
|
factArray,
|
|
uniqueText,
|
|
eventSeq,
|
|
normalizeStatus,
|
|
normalizeTerminalStatus,
|
|
firstUserPreview,
|
|
latestUserPreview,
|
|
latestValidMessagePreview,
|
|
latestAssistantPreview,
|
|
sessionTitleFromMessages,
|
|
sessionPreviewFromMessages,
|
|
boundedPreviewText,
|
|
isAssistantLikeRole,
|
|
objectValue,
|
|
textValue,
|
|
messageAuthorityTextValue,
|
|
safeTurnId,
|
|
safeMessageId,
|
|
safePartId,
|
|
hash
|
|
} = workbenchFacts;
|
|
|
|
export async function handleWorkbenchReadModelHttp(request, response, url, options = {}) {
|
|
try {
|
|
const perf = options.backendPerformance;
|
|
const auth = perf ? await perf.measure("workbench_auth", () => authenticateWorkbenchRead(request, response, options)) : await authenticateWorkbenchRead(request, response, options);
|
|
if (!auth) return;
|
|
|
|
if (url.pathname === "/v1/workbench/sync") {
|
|
const capabilities = workbenchRealtimeCapabilities(options.env ?? process.env);
|
|
if (!capabilities.projectionRealtime) {
|
|
sendJson(response, 503, {
|
|
ok: false,
|
|
error: {
|
|
code: "workbench_projection_realtime_disabled",
|
|
message: "Workbench projection sync/replay capability is disabled.",
|
|
capabilities,
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
await (perf ? perf.measure("workbench_sync", () => handleWorkbenchSyncHttp(request, response, url, options, auth.actor)) : handleWorkbenchSyncHttp(request, response, url, options, auth.actor));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/v1/workbench/sessions") {
|
|
if (request.method !== "GET") return methodNotAllowed(response, "GET");
|
|
await (perf ? perf.measure("workbench_session_list", () => handleWorkbenchSessionList(request, response, url, options, auth.actor)) : handleWorkbenchSessionList(request, response, url, options, auth.actor));
|
|
return;
|
|
}
|
|
|
|
const sessionMessagesMatch = url.pathname.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u);
|
|
if (sessionMessagesMatch) {
|
|
if (request.method !== "GET") return methodNotAllowed(response, "GET");
|
|
await (perf ? perf.measure("workbench_message_page", () => handleWorkbenchMessagePage(request, response, url, options, auth.actor, decodeURIComponent(sessionMessagesMatch[1]))) : handleWorkbenchMessagePage(request, response, url, options, auth.actor, decodeURIComponent(sessionMessagesMatch[1])));
|
|
return;
|
|
}
|
|
|
|
const sessionMatch = url.pathname.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u);
|
|
if (sessionMatch) {
|
|
if (request.method !== "GET") return methodNotAllowed(response, "GET");
|
|
await (perf ? perf.measure("workbench_session_detail", () => handleWorkbenchSessionDetail(request, response, url, options, auth.actor, decodeURIComponent(sessionMatch[1]))) : handleWorkbenchSessionDetail(request, response, url, options, auth.actor, decodeURIComponent(sessionMatch[1])));
|
|
return;
|
|
}
|
|
|
|
const turnMatch = url.pathname.match(/^\/v1\/workbench\/turns\/([^/]+)$/u);
|
|
if (turnMatch) {
|
|
if (request.method !== "GET") return methodNotAllowed(response, "GET");
|
|
await (perf ? perf.measure("workbench_turn_snapshot", () => handleWorkbenchTurnSnapshot(request, response, url, options, auth.actor, decodeURIComponent(turnMatch[1]))) : handleWorkbenchTurnSnapshot(request, response, url, options, auth.actor, decodeURIComponent(turnMatch[1])));
|
|
return;
|
|
}
|
|
|
|
const traceEventsMatch = url.pathname.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u);
|
|
if (traceEventsMatch) {
|
|
if (request.method !== "GET") return methodNotAllowed(response, "GET");
|
|
await (perf ? perf.measure("workbench_trace_events", () => handleWorkbenchTraceEventPage(request, response, url, options, auth.actor, decodeURIComponent(traceEventsMatch[1]))) : handleWorkbenchTraceEventPage(request, response, url, options, auth.actor, decodeURIComponent(traceEventsMatch[1])));
|
|
return;
|
|
}
|
|
|
|
sendJson(response, 404, workbenchError("workbench_route_not_found", "Workbench read model route is not implemented.", { route: url.pathname }));
|
|
} catch (error) {
|
|
handleWorkbenchReadModelFailure(response, url, options, error);
|
|
}
|
|
}
|
|
|
|
|
|
export function recordWorkbenchTurnReadMetric(options = {}, url, statusCode, body = {}, startedAt = nowMs()) {
|
|
const performanceStore = options.backendPerformanceStore ?? options.backendPerformance;
|
|
if (!performanceStore?.recordWorkbenchTurnRead) return;
|
|
performanceStore.recordWorkbenchTurnRead({
|
|
route: url?.pathname ?? "/v1/workbench/turns/:id",
|
|
status: body?.status ?? statusClassLabel(statusCode),
|
|
degradedReason: workbenchTurnDegradedReason(body),
|
|
durationMs: nowMs() - startedAt,
|
|
responseBytes: responseBodyBytes(body),
|
|
timedOut: statusCode === 504 || body?.error?.code === "timeout"
|
|
});
|
|
}
|
|
|
|
export function recordWorkbenchCacheMetric(options = {}, url, body = {}, startedAt = nowMs()) {
|
|
const performanceStore = options.backendPerformanceStore ?? options.backendPerformance;
|
|
if (!performanceStore?.recordWorkbenchCache) return;
|
|
const cache = body?.cache && typeof body.cache === "object" ? body.cache : null;
|
|
if (!cache) return;
|
|
performanceStore.recordWorkbenchCache({
|
|
route: url?.pathname ?? "/v1/workbench",
|
|
cacheKeyClass: cache.class,
|
|
cacheStatus: cache.status,
|
|
operation: "read",
|
|
durationMs: nowMs() - startedAt,
|
|
payloadBytes: cache.payloadBytes,
|
|
cacheAgeMs: cache.cacheAgeMs,
|
|
dbQueryAvoided: cache.dbQueryAvoided,
|
|
freshnessSloMs: cache.freshnessSloMs,
|
|
projectionSeq: cache.projectionSeq
|
|
});
|
|
}
|
|
|
|
export function recordWorkbenchProjectionMetric(options = {}, { result = null, trace = null, projection = null, diagnostic = null } = {}) {
|
|
const performanceStore = options.backendPerformanceStore ?? options.backendPerformance;
|
|
if (!performanceStore?.recordWorkbenchProjection || !projection) return;
|
|
const sourceLatestSeq = workbenchProjectionSourceLatestSeq(result, trace, projection);
|
|
const lastProjectedSeq = Number(diagnostic?.lastProjectedSeq ?? projection?.lastProjectedSeq ?? 0);
|
|
if (!Number.isFinite(sourceLatestSeq) && !Number.isFinite(lastProjectedSeq)) return;
|
|
performanceStore.recordWorkbenchProjection({
|
|
sourceLatestSeq: Number.isFinite(sourceLatestSeq) ? sourceLatestSeq : lastProjectedSeq,
|
|
lastProjectedSeq: Number.isFinite(lastProjectedSeq) ? lastProjectedSeq : 0,
|
|
sourceLatestEventAt: workbenchProjectionSourceLatestAt(result, trace, projection),
|
|
projectedLatestEventAt: diagnostic?.updatedAt ?? projection?.updatedAt ?? trace?.updatedAt ?? result?.updatedAt ?? null,
|
|
terminalSourceAt: workbenchTerminalSourceAt(result, projection),
|
|
terminalProjectedAt: diagnostic?.projectionStatus === "caught-up" ? diagnostic?.updatedAt ?? projection?.updatedAt ?? trace?.updatedAt ?? result?.updatedAt ?? null : null,
|
|
status: projection?.status ?? "unknown",
|
|
reason: workbenchProjectionReason(diagnostic),
|
|
projectionStatus: diagnostic?.projectionStatus ?? "unknown",
|
|
source: "workbench_read_model"
|
|
});
|
|
}
|
|
|
|
export function workbenchProjectionSourceLatestSeq(result = null, trace = null, projection = null) {
|
|
const candidates = [
|
|
result?.agentRun?.lastSeq,
|
|
result?.traceSummary?.agentRun?.lastSeq,
|
|
result?.traceSummary?.lastSeq,
|
|
result?.providerTrace?.lastSeq,
|
|
result?.runnerTrace?.lastEvent?.sourceSeq,
|
|
result?.runnerTrace?.lastEvent?.seq,
|
|
result?.runnerTrace?.eventCount,
|
|
trace?.lastSeq,
|
|
trace?.lastEvent?.sourceSeq,
|
|
trace?.lastEvent?.seq,
|
|
trace?.eventCount,
|
|
projection?.lastProjectedSeq,
|
|
projection?.eventCount
|
|
];
|
|
for (const value of candidates) {
|
|
const seq = Number(value);
|
|
if (Number.isFinite(seq) && seq >= 0) return Math.floor(seq);
|
|
}
|
|
return NaN;
|
|
}
|
|
|
|
export function workbenchProjectionSourceLatestAt(result = null, trace = null, projection = null) {
|
|
return result?.traceSummary?.updatedAt
|
|
?? result?.agentRun?.updatedAt
|
|
?? result?.providerTrace?.updatedAt
|
|
?? result?.runnerTrace?.updatedAt
|
|
?? trace?.updatedAt
|
|
?? result?.updatedAt
|
|
?? projection?.updatedAt
|
|
?? null;
|
|
}
|
|
|
|
export function workbenchTerminalSourceAt(result = null, projection = null) {
|
|
if (projection?.terminal !== true) return null;
|
|
return result?.traceSummary?.updatedAt
|
|
?? result?.agentRun?.updatedAt
|
|
?? result?.updatedAt
|
|
?? projection?.updatedAt
|
|
?? null;
|
|
}
|
|
|
|
export function workbenchProjectionReason(diagnostic = null) {
|
|
if (diagnostic?.blocker) return diagnostic.blocker.code ?? "projection_blocked";
|
|
if (diagnostic?.projectionStatus === "projecting") return "projecting";
|
|
if (diagnostic?.projectionStatus === "caught-up") return "none";
|
|
return diagnostic?.projectionStatus ?? "unknown";
|
|
}
|
|
|
|
export function workbenchTurnDegradedReason(body = {}) {
|
|
if (body?.projection?.blocker?.code) return body.projection.blocker.code;
|
|
if (body?.blocker?.code) return body.blocker.code;
|
|
if (body?.error?.code) return body.error.code;
|
|
if (body?.projectionStatus === "projecting") return "projecting";
|
|
if (body?.projectionStatus === "blocked") return "projection_blocked";
|
|
return "none";
|
|
}
|
|
|
|
export function responseBodyBytes(body = {}) {
|
|
try { return Buffer.byteLength(JSON.stringify(body)); } catch { return 0; }
|
|
}
|
|
|
|
export function statusClassLabel(value) {
|
|
const status = Number(value);
|
|
if (!Number.isFinite(status) || status <= 0) return "unknown";
|
|
return `${Math.floor(status / 100)}xx`;
|
|
}
|
|
|
|
export async function authenticateWorkbenchRead(request, response, options) {
|
|
const access = options.accessController;
|
|
if (!access?.authenticate) {
|
|
sendJson(response, 503, workbenchError("workbench_access_controller_missing", "Workbench read model requires access controller."));
|
|
return null;
|
|
}
|
|
await access.ensureBootstrap?.();
|
|
const auth = await access.authenticate(request, { required: true });
|
|
if (!auth?.ok) {
|
|
sendJson(response, auth?.status ?? 401, auth ?? workbenchError("auth_required", "Authentication is required."));
|
|
return null;
|
|
}
|
|
return auth;
|
|
}
|
|
|
|
export async function handleWorkbenchSessionList(request, response, url, options, actor) {
|
|
const startedAt = nowMs();
|
|
if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return sendJson(response, 400, workbenchError("workbench_authority_removed", "Workbench session list is keyed by sessionId only."));
|
|
const limit = boundedSessionListLimit(url.searchParams.get("limit"));
|
|
const offset = cursorOffset(url.searchParams.get("cursor") ?? url.searchParams.get("after"));
|
|
const includeSessionId = safeSessionId(url.searchParams.get("includeSessionId"));
|
|
const includeRouteId = includeSessionId ?? safeConversationId(url.searchParams.get("includeSessionId"));
|
|
const runtime = workbenchRuntimeReader(request, options);
|
|
if (typeof runtime?.listWorkbenchSessions !== "function") {
|
|
if (typeof runtime?.queryWorkbenchFacts !== "function") {
|
|
throw runtimeDependencyError("workbench_runtime_unconfigured", "Workbench runtime service is required for session list.", false);
|
|
}
|
|
const readModel = workbenchRuntimeFactsReadModel(request, options, actor);
|
|
const payload = await handleWorkbenchSessionListFromFacts(readModel, url, options, actor, { limit, offset, includeRouteId });
|
|
recordWorkbenchCacheMetric(options, url, payload, startedAt);
|
|
emitWorkbenchSessionListReadOtel(request, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 200,
|
|
source: "facts",
|
|
limit,
|
|
offset,
|
|
includeSessionId: includeRouteId,
|
|
payload
|
|
});
|
|
return sendJson(response, 200, payload);
|
|
}
|
|
const payload = await runtime.listWorkbenchSessions({
|
|
limit,
|
|
offset,
|
|
cursor: url.searchParams.get("cursor") ?? url.searchParams.get("after") ?? null,
|
|
includeSessionId: includeRouteId ?? null,
|
|
actor: { id: actor.id, role: actor.role ?? "user", valuesRedacted: true }
|
|
});
|
|
recordWorkbenchCacheMetric(options, url, payload, startedAt);
|
|
emitWorkbenchSessionListReadOtel(request, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 200,
|
|
source: "runtime",
|
|
limit,
|
|
offset,
|
|
includeSessionId: includeRouteId,
|
|
payload
|
|
});
|
|
return sendJson(response, 200, payload);
|
|
}
|
|
|
|
export async function handleWorkbenchSessionListFromFacts(readModel, url, options, actor, { limit, offset, includeRouteId }) {
|
|
const perf = options.backendPerformance;
|
|
const pageQuery = { ownerUserId: actor.role === "admin" ? undefined : actor.id, limit: limit + offset + 1, families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES, sessionsOrder: "updated_desc", sessionProjection: "summary" };
|
|
const includeQueryPromise = includeRouteId ? queryWorkbenchSessionInclude(readModel, includeRouteId, perf) : null;
|
|
const pageResult = await (perf ? perf.measure("workbench_session_page_query", () => readModel.queryFacts(pageQuery)) : readModel.queryFacts(pageQuery));
|
|
if (pageResult.error) throw pageResult.error;
|
|
let pageFacts = pageResult.facts;
|
|
let naturalPage = visibleFactSessions(pageFacts, actor).slice(offset, offset + limit + 1);
|
|
if (includeRouteId && !naturalPage.some((session) => factSessionMatchesRouteId(session, includeRouteId))) {
|
|
const includeResult = await includeQueryPromise;
|
|
if (includeResult.error) throw includeResult.error;
|
|
const included = visibleFactSessions(includeResult.facts, actor)[0] ?? null;
|
|
if (included) {
|
|
pageFacts = mergeFactSets(pageFacts, includeResult.facts);
|
|
naturalPage = [included, ...naturalPage];
|
|
}
|
|
}
|
|
const pageSessions = naturalPage.slice(0, limit);
|
|
const summaryResult = await (perf ? perf.measure("workbench_session_summary_query", () => queryFactsForSessionSummaries(readModel, pageSessions)) : queryFactsForSessionSummaries(readModel, pageSessions));
|
|
if (summaryResult.error) throw summaryResult.error;
|
|
pageFacts = mergeFactSets(pageFacts, summaryResult.facts);
|
|
const summaries = pageSessions.map((session) => factSessionSummary(session, pageFacts)).filter(Boolean);
|
|
const hasMore = naturalPage.length > limit;
|
|
return {
|
|
ok: true,
|
|
status: "succeeded",
|
|
contractVersion: "workbench-sessions-v1",
|
|
sessions: summaries,
|
|
count: summaries.length,
|
|
cursor: offset > 0 ? cursorFromOffset(offset) : null,
|
|
hasMore,
|
|
nextCursor: hasMore ? cursorFromOffset(offset + limit) : null,
|
|
cache: pageResult.cache ?? summaryResult.cache ?? null,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
};
|
|
}
|
|
|
|
export function runtimeDependencyError(code, message, retryable = true) {
|
|
const error = new Error(message || code);
|
|
error.name = "WorkbenchRuntimeDependencyError";
|
|
error.code = code;
|
|
error.data = { serviceId: "hwlab-workbench-runtime", retryable, transient: retryable, valuesRedacted: true };
|
|
return error;
|
|
}
|
|
|
|
export function workbenchRuntimeReader(request, options) {
|
|
return options.workbenchRuntime
|
|
?? options.runtimeStore
|
|
?? createWorkbenchRuntimeClient({ env: options.env ?? process.env, fetch: options.fetch, logger: options.logger, traceparent: request?.hwlabHttpRequestContext?.traceparent });
|
|
}
|
|
|
|
export function workbenchRuntimeFactsReadModel(request, options, actor) {
|
|
const runtime = workbenchRuntimeReader(request, options);
|
|
if (typeof runtime?.queryWorkbenchFacts !== "function") {
|
|
throw runtimeDependencyError("workbench_runtime_unconfigured", "Workbench runtime service is required for Workbench facts reads.", false);
|
|
}
|
|
return {
|
|
async queryFacts(query = {}) {
|
|
try {
|
|
return await runtime.queryWorkbenchFacts({
|
|
...query,
|
|
actor: { id: actor.id, role: actor.role ?? "user", valuesRedacted: true }
|
|
});
|
|
} catch (error) {
|
|
return { facts: emptyFactSet(), count: 0, durable: true, error: workbenchRuntimeFactsQueryError(error), persistence: null };
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export function workbenchRuntimeFactsQueryError(error) {
|
|
const normalized = error instanceof Error ? error : new Error(String(error ?? "Workbench facts query failed."));
|
|
const data = objectValue(normalized.data);
|
|
normalized.data = {
|
|
...data,
|
|
retryable: data.retryable !== false,
|
|
transient: data.transient !== false,
|
|
retryAfterMs: data.retryAfterMs ?? 5000,
|
|
valuesRedacted: true
|
|
};
|
|
return normalized;
|
|
}
|
|
|
|
export function queryWorkbenchSessionInclude(readModel, includeRouteId, perf = null) {
|
|
const run = () => queryFactsByRouteId(readModel, includeRouteId, { families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES });
|
|
const promise = perf ? perf.measure("workbench_session_include_query", run) : run();
|
|
return promise.catch((error) => ({ error }));
|
|
}
|
|
|
|
export async function queryFactsByRouteId(readModel, routeId, query = {}) {
|
|
const baseQuery = { ...query, limit: query.limit ?? MAX_PAGE_LIMIT };
|
|
const sessionId = safeSessionId(routeId);
|
|
if (sessionId) return readModel.queryFacts({ ...baseQuery, sessionId });
|
|
const conversationId = safeConversationId(routeId);
|
|
if (conversationId) {
|
|
const sessionResult = await readModel.queryFacts({ ...baseQuery, families: WORKBENCH_SESSION_LIST_PAGE_FAMILIES, conversationId });
|
|
if (sessionResult.error || queryRequestsOnlySessionFacts(baseQuery)) return sessionResult;
|
|
const resolvedSessionId = factSessionId(factArray(sessionResult.facts?.sessions)[0] ?? null);
|
|
if (!resolvedSessionId) return sessionResult;
|
|
const relatedResult = await readModel.queryFacts({ ...baseQuery, sessionId: resolvedSessionId });
|
|
if (relatedResult.error) return relatedResult;
|
|
const facts = mergeFactSets(sessionResult.facts, relatedResult.facts);
|
|
return { facts, count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0), durable: relatedResult.durable ?? sessionResult.durable, persistence: relatedResult.persistence ?? sessionResult.persistence ?? null };
|
|
}
|
|
return { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
|
|
}
|
|
|
|
export function queryRequestsOnlySessionFacts(query = {}) {
|
|
const families = Array.isArray(query.families) ? query.families : [];
|
|
return families.length === 1 && families[0] === "sessions";
|
|
}
|
|
|
|
export async function queryFactsForSessionSummaries(readModel, sessions = []) {
|
|
const sessionIds = uniqueText(sessions.map(factSessionId));
|
|
if (sessionIds.length === 0 && uniqueText(sessions.map(factLastTraceId)).length === 0) return { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
|
|
// Session list summary is hot and is also sampled by the two-page web-probe
|
|
// observer. Avoid parallel fan-out here so one list request cannot consume
|
|
// multiple durable-read connections while another page is refreshing.
|
|
const bySession = sessionIds.length > 0
|
|
? await readModel.queryFacts({ sessionIds, families: ["messages", "parts", "turns"] })
|
|
: { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
|
|
if (bySession.error) return bySession;
|
|
const traceIds = summaryTraceIds(sessions, bySession.facts);
|
|
const byTrace = traceIds.length > 0
|
|
? await readModel.queryFacts({ traceIds, families: ["checkpoints"] })
|
|
: { facts: emptyFactSet(), count: 0, durable: false, persistence: null };
|
|
if (byTrace.error) return byTrace;
|
|
const facts = mergeFactSets(bySession.facts, byTrace.facts);
|
|
return { facts, count: Object.values(facts).reduce((sum, rows) => sum + rows.length, 0), durable: bySession.durable ?? byTrace.durable, persistence: bySession.persistence ?? byTrace.persistence ?? null };
|
|
}
|
|
|
|
export function summaryTraceIds(sessions = [], facts = {}) {
|
|
return uniqueText([
|
|
...sessions.map(factLastTraceId),
|
|
...sessions.map((session) => factCurrentTraceForSession(facts, factSessionId(session), session)?.traceId)
|
|
]);
|
|
}
|
|
|
|
export function visibleFactSessions(facts, actor) {
|
|
return hideShadowedAgentRunAliasSessions(factArray(facts?.sessions)
|
|
.filter((session) => canActorReadFactSession(session, actor))
|
|
.sort(compareFactSessionsDesc));
|
|
}
|
|
|
|
export function hideShadowedAgentRunAliasSessions(sessions = []) {
|
|
const canonicalTraceIds = new Set(sessions
|
|
.filter((session) => !isAgentRunAliasSessionId(factSessionId(session)))
|
|
.map(factLastTraceId)
|
|
.filter(Boolean));
|
|
if (canonicalTraceIds.size === 0) return sessions;
|
|
return sessions.filter((session) => !isAgentRunAliasSessionId(factSessionId(session)) || !canonicalTraceIds.has(factLastTraceId(session)));
|
|
}
|
|
|
|
export function isAgentRunAliasSessionId(sessionId) {
|
|
return /^ses_agentrun_/u.test(textValue(sessionId));
|
|
}
|
|
|
|
export function canActorReadFactSession(session, actor) {
|
|
if (!session || !actor) return false;
|
|
if (normalizeStatus(session.status) === "archived") return false;
|
|
if (actor.role === "admin") return true;
|
|
return session.ownerUserId === actor.id;
|
|
}
|
|
|
|
export async function visibleFactSessionForTrace(readModel, facts, actor, traceId) {
|
|
const visible = visibleFactSessions(facts, actor);
|
|
const byLastTrace = visible.find((item) => item.lastTraceId === traceId) ?? null;
|
|
if (byLastTrace) return { session: byLastTrace, facts };
|
|
const relatedSessionIds = uniqueText([
|
|
factTurnForTrace(facts, traceId)?.sessionId,
|
|
factArray(facts.messages).find((message) => message.traceId === traceId)?.sessionId,
|
|
factCheckpointForTrace(facts, traceId)?.sessionId
|
|
]);
|
|
const alreadyLoaded = visible.find((item) => relatedSessionIds.includes(factSessionId(item))) ?? null;
|
|
if (alreadyLoaded) return { session: alreadyLoaded, facts };
|
|
for (const sessionId of relatedSessionIds) {
|
|
const result = await queryFactsByRouteId(readModel, sessionId, { families: ["sessions"], limit: 1 });
|
|
if (result.error) return { error: result.error };
|
|
const session = visibleFactSessions(result.facts, actor)[0] ?? null;
|
|
if (session) return { session, facts: mergeFactSets(facts, result.facts) };
|
|
}
|
|
return { session: null, facts };
|
|
}
|
|
|
|
export function factSessionMatchesRouteId(session, routeId) {
|
|
const sessionId = safeSessionId(routeId);
|
|
if (sessionId && factSessionId(session) === sessionId) return true;
|
|
const conversationId = safeConversationId(routeId);
|
|
return Boolean(conversationId && session?.conversationId === conversationId);
|
|
}
|
|
|
|
|
|
export async function handleWorkbenchSessionDetail(request, response, url, options, actor, sessionId) {
|
|
const readModel = workbenchRuntimeFactsReadModel(request, options, actor);
|
|
const includeMessages = includeMessagesForSessionDetail(url.searchParams);
|
|
const result = await queryFactsByRouteId(readModel, sessionId, { families: WORKBENCH_SESSION_DETAIL_FAMILIES });
|
|
if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error));
|
|
const session = visibleFactSessions(result.facts, actor)[0] ?? null;
|
|
if (!session) {
|
|
const body = workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId });
|
|
attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/sessions/:id", code: "workbench_session_not_found", sessionId, count: result.count });
|
|
return sendJson(response, 404, body);
|
|
}
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
status: "found",
|
|
contractVersion: "workbench-session-detail-v1",
|
|
session: factSessionDetail(session, result.facts, { includeMessages }),
|
|
detailMode: includeMessages ? "full" : "metadata-only",
|
|
messagesIncluded: includeMessages,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
});
|
|
}
|
|
|
|
export async function handleWorkbenchMessagePage(request, response, url, options, actor, sessionId) {
|
|
const startedAt = nowMs();
|
|
const readModel = workbenchRuntimeFactsReadModel(request, options, actor);
|
|
const limit = boundedLimit(url.searchParams.get("limit"));
|
|
const cursorParam = url.searchParams.get("cursor") ?? url.searchParams.get("after");
|
|
const hasCursor = Boolean(textValue(cursorParam));
|
|
const result = await queryFactsByRouteId(readModel, sessionId, {
|
|
families: WORKBENCH_SESSION_MESSAGE_PAGE_FAMILIES,
|
|
...(hasCursor ? {} : { limit: limit + 1, messagesOrder: "updated_desc", partsOrder: "updated_desc", turnsOrder: "updated_desc", checkpointsOrder: "updated_desc" })
|
|
});
|
|
if (result.error) return sendJson(response, 503, workbenchProjectionStoreError(result.error));
|
|
const session = visibleFactSessions(result.facts, actor)[0] ?? null;
|
|
if (!session) {
|
|
const body = workbenchError("workbench_session_not_found", "Workbench session is not visible to the current actor.", { sessionId });
|
|
attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/sessions/:id/messages", code: "workbench_session_not_found", sessionId, count: result.count });
|
|
return sendJson(response, 404, body);
|
|
}
|
|
const messages = factMessagesForSession(session, result.facts);
|
|
const offset = hasCursor ? cursorOffset(cursorParam) : 0;
|
|
const page = messages.slice(offset, offset + limit);
|
|
const nextOffset = offset + page.length;
|
|
const resolvedSessionId = factSessionId(session);
|
|
const body = {
|
|
ok: true,
|
|
status: "succeeded",
|
|
contractVersion: "workbench-message-page-v1",
|
|
sessionId: resolvedSessionId,
|
|
messages: page,
|
|
count: page.length,
|
|
total: messages.length,
|
|
cursor: offset > 0 ? cursorFromOffset(offset) : null,
|
|
nextCursor: nextOffset < messages.length ? cursorFromOffset(nextOffset) : null,
|
|
hasMore: nextOffset < messages.length,
|
|
timelineDigest: workbenchTimelineDigest(page),
|
|
roleSequencePrefix: messagesRoleSequence(page).slice(0, 32),
|
|
adjacentSameRoleCount: adjacentSameRoleCount(page),
|
|
traceIds: uniqueText(page.map((message) => message?.traceId)).slice(0, 16),
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
};
|
|
emitWorkbenchSessionMessagesReadOtel(request, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 200,
|
|
sessionId: resolvedSessionId,
|
|
limit,
|
|
offset,
|
|
messages: page,
|
|
total: messages.length,
|
|
hasMore: body.hasMore
|
|
});
|
|
sendJson(response, 200, body);
|
|
}
|
|
|
|
export async function handleWorkbenchTurnSnapshot(request, response, url, options, actor, rawTurnId) {
|
|
const startedAt = nowMs();
|
|
const queryTraceId = safeTraceId(url.searchParams.get("traceId"));
|
|
const traceId = safeTraceId(rawTurnId) ?? queryTraceId;
|
|
const turnId = safeTurnId(rawTurnId) || traceId || rawTurnId;
|
|
if (!traceId) {
|
|
const body = workbenchError("invalid_turn_id", "turnId must currently be a trace id or include traceId query.", { turnId });
|
|
recordWorkbenchTurnReadMetric(options, url, 400, body, startedAt);
|
|
return sendJson(response, 400, body);
|
|
}
|
|
const readModel = workbenchRuntimeFactsReadModel(request, options, actor);
|
|
const result = await readModel.queryFacts({ traceId, limit: MAX_PAGE_LIMIT, families: [...WORKBENCH_TRACE_EVENT_METADATA_FAMILIES, ...WORKBENCH_TRACE_EVENT_PAGE_FAMILIES] });
|
|
if (result.error) {
|
|
const body = workbenchProjectionStoreError(result.error);
|
|
emitWorkbenchTurnStatusReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 503,
|
|
phase: "query_failed",
|
|
turnId,
|
|
errorCode: result.error?.code ?? "workbench_turn_query_failed"
|
|
}, result.error);
|
|
recordWorkbenchTurnReadMetric(options, url, 503, body, startedAt);
|
|
return sendJson(response, 503, body);
|
|
}
|
|
const session = visibleFactSessions(result.facts, actor).find((item) => item.lastTraceId === traceId) ?? visibleFactSessions(result.facts, actor)[0] ?? null;
|
|
const turn = factTurnForTrace(result.facts, traceId, turnId);
|
|
const found = Boolean(session || turn);
|
|
if (!found) {
|
|
const body = workbenchError("workbench_turn_not_found", "Workbench turn is not visible to the current actor.", { turnId, traceId });
|
|
attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/turns/:id", code: "workbench_turn_not_found", traceId, turnId, count: result.count });
|
|
emitWorkbenchTurnStatusReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 404,
|
|
phase: "not_found",
|
|
turnId,
|
|
readCount: result.count,
|
|
errorCode: "workbench_turn_not_found"
|
|
});
|
|
recordWorkbenchTurnReadMetric(options, url, 404, body, startedAt);
|
|
return sendJson(response, 404, body);
|
|
}
|
|
const projection = factProjectionForTrace(result.facts, traceId);
|
|
const snapshot = factTurnSnapshot({ turn, session, facts: result.facts, traceId, turnId });
|
|
const status = snapshot.status;
|
|
const body = {
|
|
ok: true,
|
|
status,
|
|
contractVersion: "workbench-turn-snapshot-v1",
|
|
turn: snapshot,
|
|
projection,
|
|
cache: result.cache ?? null,
|
|
projectionStatus: projection.projectionStatus,
|
|
projectionHealth: projection.projectionHealth,
|
|
lastProjectedSeq: projection.lastProjectedSeq,
|
|
sourceRunId: projection.sourceRunId,
|
|
sourceCommandId: projection.sourceCommandId,
|
|
staleMs: projection.staleMs,
|
|
blocker: projection.blocker,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
};
|
|
recordWorkbenchProjectionMetric(options, {
|
|
projection: { ...projection, status, eventCount: snapshot.trace?.eventCount ?? projection.lastProjectedSeq ?? 0 },
|
|
diagnostic: projection
|
|
});
|
|
emitWorkbenchTurnStatusReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 200,
|
|
phase: "succeeded",
|
|
status,
|
|
terminal: snapshot.terminal,
|
|
sessionId: snapshot.sessionId,
|
|
turnId: snapshot.turnId,
|
|
traceEventCount: snapshot.trace?.eventCount ?? null,
|
|
latestProjectedSeq: projection.lastProjectedSeq,
|
|
projectionStatus: projection.projectionStatus,
|
|
projectionHealth: projection.projectionHealth,
|
|
sourceRunId: projection.sourceRunId,
|
|
sourceCommandId: projection.sourceCommandId,
|
|
readCount: result.count
|
|
});
|
|
recordWorkbenchTurnReadMetric(options, url, 200, body, startedAt);
|
|
recordWorkbenchCacheMetric(options, url, body, startedAt);
|
|
sendJson(response, 200, body);
|
|
}
|
|
|
|
export async function handleWorkbenchTraceEventPage(request, response, url, options, actor, rawTraceId) {
|
|
const startedAt = nowMs();
|
|
const traceId = safeTraceId(rawTraceId);
|
|
if (!traceId) return sendJson(response, 400, workbenchError("invalid_trace_id", "traceId must start with trc_.", { traceId: rawTraceId }));
|
|
const readModel = workbenchRuntimeFactsReadModel(request, options, actor);
|
|
const pageOptions = tracePageOptions(url);
|
|
const metadata = await readModel.queryFacts({ traceId, families: WORKBENCH_TRACE_EVENT_METADATA_FAMILIES, limit: 1 });
|
|
if (metadata.error) {
|
|
emitWorkbenchTraceEventsReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 503,
|
|
phase: "metadata_query_failed",
|
|
pageOptions,
|
|
errorCode: metadata.error?.code ?? "workbench_trace_metadata_query_failed"
|
|
}, metadata.error);
|
|
return sendJson(response, 503, workbenchProjectionStoreError(metadata.error));
|
|
}
|
|
const resolved = await visibleFactSessionForTrace(readModel, metadata.facts, actor, traceId);
|
|
if (resolved.error) {
|
|
emitWorkbenchTraceEventsReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 503,
|
|
phase: "session_lookup_failed",
|
|
pageOptions,
|
|
metadataCount: metadata.count,
|
|
errorCode: resolved.error?.code ?? "workbench_trace_session_lookup_failed"
|
|
}, resolved.error);
|
|
return sendJson(response, 503, workbenchProjectionStoreError(resolved.error));
|
|
}
|
|
const session = resolved.session;
|
|
const metadataFacts = resolved.facts ?? metadata.facts;
|
|
if (!session) {
|
|
const context = await readModel.queryFacts({ traceId, families: ["sessions", "turns", "checkpoints"], limit: MAX_PAGE_LIMIT });
|
|
if (context.error) {
|
|
emitWorkbenchTraceEventsReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 503,
|
|
phase: "context_query_failed",
|
|
pageOptions,
|
|
metadataCount: metadata.count,
|
|
errorCode: context.error?.code ?? "workbench_trace_context_query_failed"
|
|
}, context.error);
|
|
return sendJson(response, 503, workbenchProjectionStoreError(context.error));
|
|
}
|
|
const contextSessions = visibleFactSessions(context.facts, actor);
|
|
const contextSession = contextSessions.find((item) => item.lastTraceId === traceId) ?? contextSessions[0] ?? null;
|
|
const contextTurn = factTurnForTrace(context.facts, traceId, traceId);
|
|
if (contextSession || contextTurn) {
|
|
const projection = factProjectionForTrace(context.facts, traceId);
|
|
const blocker = traceEventsReadModelBlocker("workbench_trace_metadata_missing", "Workbench trace metadata is missing from the trace events read model.", { traceId, projection, session: contextSession, turn: contextTurn, route: url.pathname });
|
|
attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/traces/:id/events", code: "workbench_trace_metadata_missing", sessionId: factSessionId(contextSession), traceId, count: context.count });
|
|
emitWorkbenchTraceEventsReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 404,
|
|
phase: "metadata_missing",
|
|
pageOptions,
|
|
projection,
|
|
sessionId: factSessionId(contextSession),
|
|
turnId: factTurnId(contextTurn, traceId),
|
|
readCount: context.count,
|
|
metadataCount: metadata.count,
|
|
errorCode: "workbench_trace_metadata_missing"
|
|
});
|
|
return sendJson(response, 404, workbenchTraceEventsReadModelError(blocker, { traceId, projection, session: contextSession, turn: contextTurn }));
|
|
}
|
|
attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/traces/:id/events", code: "workbench_trace_not_found", traceId, count: context.count });
|
|
emitWorkbenchTraceEventsReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 404,
|
|
phase: "not_found",
|
|
pageOptions,
|
|
readCount: context.count,
|
|
metadataCount: metadata.count,
|
|
errorCode: "workbench_trace_not_found"
|
|
});
|
|
return sendJson(response, 404, workbenchError("workbench_trace_not_found", "Workbench trace is not visible to the current actor.", { traceId }));
|
|
}
|
|
const projection = factProjectionForTrace(metadataFacts, traceId);
|
|
const pageResult = await readModel.queryFacts({ traceId, families: WORKBENCH_TRACE_EVENT_PAGE_FAMILIES, afterProjectedSeq: pageOptions.afterProjectedSeq, limit: pageOptions.limit + 1 });
|
|
if (pageResult.error) {
|
|
emitWorkbenchTraceEventsReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 503,
|
|
phase: "events_query_failed",
|
|
pageOptions,
|
|
projection,
|
|
sessionId: factSessionId(session),
|
|
metadataCount: metadata.count,
|
|
errorCode: pageResult.error?.code ?? "workbench_trace_events_query_failed"
|
|
}, pageResult.error);
|
|
return sendJson(response, 503, workbenchProjectionStoreError(pageResult.error));
|
|
}
|
|
const traceTurn = factTurnForTrace(metadataFacts, traceId, traceId);
|
|
const turnTraceStatus = normalizeStatus(traceTurn?.status);
|
|
const traceStatus = turnTraceStatus !== "unknown" ? turnTraceStatus : durableTraceStatus(pageResult.facts.traceEvents);
|
|
const page = traceEventPageFromFacts(pageResult.facts.traceEvents, pageOptions, { total: projection.lastProjectedSeq, traceLastSeq: projection.lastProjectedSeq, traceStatus });
|
|
const missingTraceEvents = traceEventPageMissing(page, projection, pageOptions);
|
|
const missingTraceEventsDiagnostic = missingTraceEvents
|
|
? traceEventsReadModelBlocker("workbench_trace_events_missing", "Workbench trace events are still catching up in the durable read model.", { traceId, projection, session, turn: traceTurn, route: url.pathname })
|
|
: null;
|
|
if (missingTraceEvents) {
|
|
attachWorkbenchReadModelDiagnosticOtel(request, { route: "/v1/workbench/traces/:id/events", code: "workbench_trace_events_missing", sessionId: factSessionId(session), traceId, count: pageResult.count });
|
|
}
|
|
const responseProjection = page.blocker ? blockedTraceEventProjection(projection, page.blocker) : missingTraceEvents ? catchingUpTraceEventProjection(projection, missingTraceEventsDiagnostic) : projection;
|
|
const body = {
|
|
ok: true,
|
|
status: page.blocker ? "blocked" : missingTraceEvents ? "projecting" : "succeeded",
|
|
contractVersion: "workbench-trace-events-v1",
|
|
traceId,
|
|
sessionId: factSessionId(session),
|
|
threadId: safeOpaqueId(session?.threadId) ?? (textValue(session?.threadId) || null),
|
|
...page,
|
|
events: factArray(page.events).map((event) => ({ ...event, detailProjection: true, authority: "trace-detail-only" })),
|
|
projection: responseProjection,
|
|
detailProjection: true,
|
|
authority: "trace-detail-only",
|
|
realtimeAuthority: "workbench-realtime-authority-v2",
|
|
cache: pageResult.cache ?? metadata.cache ?? null,
|
|
projectionStatus: responseProjection.projectionStatus,
|
|
projectionHealth: responseProjection.projectionHealth,
|
|
lastProjectedSeq: responseProjection.lastProjectedSeq,
|
|
sourceRunId: responseProjection.sourceRunId,
|
|
sourceCommandId: responseProjection.sourceCommandId,
|
|
staleMs: responseProjection.staleMs,
|
|
blocker: responseProjection.blocker,
|
|
timing: responseProjection.timing,
|
|
startedAt: responseProjection.startedAt,
|
|
lastEventAt: responseProjection.lastEventAt,
|
|
finishedAt: responseProjection.finishedAt,
|
|
durationMs: responseProjection.durationMs,
|
|
diagnostic: responseProjection.diagnostic ?? null,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
};
|
|
recordWorkbenchCacheMetric(options, url, body, startedAt);
|
|
emitWorkbenchTraceEventsReadOtel(traceId, options, {
|
|
startTimeMs: startedAt,
|
|
statusCode: 200,
|
|
phase: page.blocker ? "blocked" : missingTraceEvents ? "events_catching_up" : "succeeded",
|
|
pageOptions,
|
|
page,
|
|
projection: responseProjection,
|
|
sessionId: body.sessionId,
|
|
turnId: factTurnId(traceTurn, traceId),
|
|
readCount: pageResult.count,
|
|
rawEventCount: factArray(pageResult.facts?.traceEvents).length,
|
|
metadataCount: metadata.count,
|
|
traceStatus
|
|
});
|
|
sendJson(response, 200, body);
|
|
}
|
|
|
|
export function emitWorkbenchTurnStatusReadOtel(traceId, options = {}, fields = {}, error = null) {
|
|
const safeId = safeTraceId(traceId);
|
|
if (!safeId) return;
|
|
const statusCode = Number.isInteger(Number(fields.statusCode)) ? Number(fields.statusCode) : 0;
|
|
void emitCodeAgentOtelSpan("turn_status_read", safeId, options.env ?? process.env, {
|
|
startTimeMs: fields.startTimeMs,
|
|
status: statusCode >= 500 || error ? "error" : "ok",
|
|
error,
|
|
attributes: {
|
|
"http.method": "GET",
|
|
"http.route": "/v1/workbench/turns/:id",
|
|
"http.status_code": statusCode || null,
|
|
phase: fields.phase ?? null,
|
|
status: fields.status ?? null,
|
|
terminal: typeof fields.terminal === "boolean" ? fields.terminal : null,
|
|
sessionId: fields.sessionId ?? null,
|
|
turnId: fields.turnId ?? safeId,
|
|
readCount: nonNegativeInteger(fields.readCount),
|
|
traceEventCount: nonNegativeInteger(fields.traceEventCount),
|
|
latestProjectedSeq: nonNegativeInteger(fields.latestProjectedSeq),
|
|
projectionStatus: fields.projectionStatus ?? null,
|
|
projectionHealth: fields.projectionHealth ?? null,
|
|
sourceRunId: fields.sourceRunId ?? null,
|
|
sourceCommandId: fields.sourceCommandId ?? null,
|
|
errorCode: fields.errorCode ?? null,
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
}
|
|
|
|
export function emitWorkbenchTraceEventsReadOtel(traceId, options = {}, fields = {}, error = null) {
|
|
const safeId = safeTraceId(traceId);
|
|
if (!safeId) return;
|
|
const page = fields.page ?? {};
|
|
const range = page.range ?? {};
|
|
const projection = fields.projection ?? {};
|
|
const pageOptions = fields.pageOptions ?? {};
|
|
const statusCode = Number.isInteger(Number(fields.statusCode)) ? Number(fields.statusCode) : 0;
|
|
const returnedEvents = Array.isArray(page.events) ? page.events.length : nonNegativeInteger(fields.returnedEvents);
|
|
const afterProjectedSeq = nonNegativeInteger(pageOptions.afterProjectedSeq ?? range.afterProjectedSeq);
|
|
const limit = nonNegativeInteger(pageOptions.limit ?? range.limit);
|
|
const hasMore = typeof page.hasMore === "boolean" ? page.hasMore : null;
|
|
void emitCodeAgentOtelSpan("trace_events_read", safeId, options.env ?? process.env, {
|
|
startTimeMs: fields.startTimeMs,
|
|
status: statusCode >= 500 || error ? "error" : "ok",
|
|
error,
|
|
attributes: {
|
|
"http.method": "GET",
|
|
"http.route": "/v1/workbench/traces/:id/events",
|
|
"http.status_code": statusCode || null,
|
|
phase: fields.phase ?? null,
|
|
sessionId: fields.sessionId ?? null,
|
|
turnId: fields.turnId ?? safeId,
|
|
returnedEvents,
|
|
rawEventCount: nonNegativeInteger(fields.rawEventCount),
|
|
readCount: nonNegativeInteger(fields.readCount),
|
|
metadataCount: nonNegativeInteger(fields.metadataCount),
|
|
sinceSeq: afterProjectedSeq,
|
|
afterProjectedSeq,
|
|
limit,
|
|
fromSeq: nonNegativeInteger(range.fromProjectedSeq),
|
|
toSeq: nonNegativeInteger(range.toProjectedSeq),
|
|
totalEvents: nonNegativeInteger(range.total ?? page.eventCount),
|
|
hasMore,
|
|
fullTraceLoaded: typeof page.fullTraceLoaded === "boolean" ? page.fullTraceLoaded : hasMore === null ? null : !hasMore && !page.blocker,
|
|
traceLastSeq: nonNegativeInteger(page.traceLastSeq ?? projection.lastProjectedSeq),
|
|
projectionStatus: projection.projectionStatus ?? null,
|
|
projectionHealth: projection.projectionHealth ?? null,
|
|
sourceRunId: projection.sourceRunId ?? null,
|
|
sourceCommandId: projection.sourceCommandId ?? null,
|
|
traceStatus: fields.traceStatus ?? page.traceStatus ?? null,
|
|
errorCode: fields.errorCode ?? null,
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
}
|
|
|
|
export function emitWorkbenchSessionListReadOtel(request, options = {}, fields = {}, error = null) {
|
|
const payload = fields.payload && typeof fields.payload === "object" ? fields.payload : {};
|
|
const sessions = Array.isArray(payload.sessions) ? payload.sessions : [];
|
|
const fallbackTitleCount = sessions.filter(sessionSummaryNeedsTitleFallback).length;
|
|
const traceIds = uniqueText(sessions.map((session) => session?.lastTraceId)).slice(0, 8);
|
|
const businessTraceId = (safeTraceId(traceIds[0]) ?? textValue(fields.includeSessionId)) || "workbench_session_list";
|
|
void emitCodeAgentOtelSpan("session_list_read", businessTraceId, options.env ?? process.env, {
|
|
startTimeMs: fields.startTimeMs,
|
|
status: error ? "error" : "ok",
|
|
error,
|
|
attributes: {
|
|
"http.method": "GET",
|
|
"http.route": "/v1/workbench/sessions",
|
|
"http.status_code": Number.isInteger(Number(fields.statusCode)) ? Number(fields.statusCode) : null,
|
|
source: fields.source ?? null,
|
|
limit: nonNegativeInteger(fields.limit),
|
|
offset: nonNegativeInteger(fields.offset),
|
|
includeSessionId: fields.includeSessionId ?? null,
|
|
sessionCount: sessions.length,
|
|
fallbackTitleCount,
|
|
fallbackTitleRatio: sessions.length > 0 ? Number((fallbackTitleCount / sessions.length).toFixed(4)) : 0,
|
|
emptyPreviewCount: sessions.filter((session) => !sessionPreviewText(session)).length,
|
|
sessionIds: uniqueText(sessions.map((session) => session?.sessionId)).slice(0, 8).join(","),
|
|
traceIds: traceIds.join(","),
|
|
hasMore: payload.hasMore === true,
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
}
|
|
|
|
export function emitWorkbenchSessionMessagesReadOtel(request, options = {}, fields = {}, error = null) {
|
|
const messages = Array.isArray(fields.messages) ? fields.messages : [];
|
|
const traceIds = uniqueText(messages.map((message) => message?.traceId)).slice(0, 8);
|
|
const businessTraceId = (safeTraceId(traceIds[0]) ?? textValue(fields.sessionId)) || "workbench_session_messages";
|
|
const roleSequence = messagesRoleSequence(messages);
|
|
void emitCodeAgentOtelSpan("session_messages_read", businessTraceId, options.env ?? process.env, {
|
|
startTimeMs: fields.startTimeMs,
|
|
status: error ? "error" : "ok",
|
|
error,
|
|
attributes: {
|
|
"http.method": "GET",
|
|
"http.route": "/v1/workbench/sessions/:id/messages",
|
|
"http.status_code": Number.isInteger(Number(fields.statusCode)) ? Number(fields.statusCode) : null,
|
|
sessionId: fields.sessionId ?? null,
|
|
limit: nonNegativeInteger(fields.limit),
|
|
offset: nonNegativeInteger(fields.offset),
|
|
returnedMessages: messages.length,
|
|
totalMessages: nonNegativeInteger(fields.total),
|
|
hasMore: fields.hasMore === true,
|
|
roleSequencePrefix: roleSequence.slice(0, 32),
|
|
consecutiveUserPrefix: consecutiveRolePrefix(messages, "user"),
|
|
adjacentSameRoleCount: adjacentSameRoleCount(messages),
|
|
timelineDigest: workbenchTimelineDigest(messages),
|
|
userCount: messages.filter((message) => normalizeRole(message?.role) === "user").length,
|
|
agentCount: messages.filter((message) => isAssistantLikeRole(message?.role)).length,
|
|
traceIds: traceIds.join(","),
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
}
|
|
|
|
export function messagesRoleSequence(messages = []) {
|
|
return messages.map((message) => roleSequenceSymbol(message?.role)).join("");
|
|
}
|
|
|
|
export function workbenchTimelineDigest(messages = []) {
|
|
const payload = messages.map((message) => {
|
|
const terminal = isTerminalProjectionStatus(normalizeStatus(message?.status)) || Boolean(message?.finishedAt);
|
|
return {
|
|
messageId: message?.messageId ?? null,
|
|
role: normalizeRole(message?.role),
|
|
sessionId: message?.sessionId ?? null,
|
|
traceId: message?.traceId ?? null,
|
|
turnId: message?.turnId ?? null,
|
|
status: normalizeStatus(message?.status),
|
|
partIds: factArray(message?.parts).map((part) => part?.partId ?? null),
|
|
textHash: textValue(message?.text || message?.textPreview) ? hash(textValue(message?.text || message?.textPreview)).slice(0, 16) : null,
|
|
startedAt: message?.startedAt ?? null,
|
|
finishedAt: terminal ? message?.finishedAt ?? null : null,
|
|
durationMs: terminal && Number.isFinite(Number(message?.durationMs)) ? Math.trunc(Number(message.durationMs)) : null
|
|
};
|
|
});
|
|
return `sha256:${hash(JSON.stringify(payload))}`;
|
|
}
|
|
|
|
export function sessionSummaryNeedsTitleFallback(session) {
|
|
if (!session || typeof session !== "object") return true;
|
|
return !sessionPreviewText(session);
|
|
}
|
|
|
|
export function sessionPreviewText(session) {
|
|
return textValue(
|
|
session?.firstUserMessagePreview
|
|
?? session?.snapshot?.firstUserMessagePreview
|
|
?? session?.userPreview
|
|
?? session?.title
|
|
?? session?.name
|
|
);
|
|
}
|
|
|
|
export function roleSequenceSymbol(role) {
|
|
const normalized = normalizeRole(role);
|
|
if (normalized === "user") return "U";
|
|
if (normalized === "agent" || normalized === "assistant") return "A";
|
|
if (normalized === "system") return "S";
|
|
return "?";
|
|
}
|
|
|
|
export function normalizeRole(role) {
|
|
return textValue(role).toLowerCase();
|
|
}
|
|
|
|
export function consecutiveRolePrefix(messages = [], role) {
|
|
let count = 0;
|
|
for (const message of messages) {
|
|
if (normalizeRole(message?.role) !== role) break;
|
|
count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
export function adjacentSameRoleCount(messages = []) {
|
|
let count = 0;
|
|
for (let index = 1; index < messages.length; index += 1) {
|
|
const previous = normalizeRole(messages[index - 1]?.role);
|
|
if (previous && previous === normalizeRole(messages[index]?.role)) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
export function traceEventPageFromFacts(sourceEvents, options, metadata = {}) {
|
|
const rows = factArray(sourceEvents).filter((event) => factProjectedSeq(event)).sort(compareFactTraceEventsAsc);
|
|
const collision = projectedSeqCollision(rows);
|
|
if (collision) return blockedTraceEventPage(options, metadata, collision);
|
|
const pageRows = rows.slice(0, options.limit);
|
|
const events = pageRows.map(factTraceEventDto).filter(Boolean);
|
|
const toProjectedSeq = events.length ? events.at(-1).projectedSeq : null;
|
|
const hasMore = rows.length > options.limit;
|
|
const total = Number.isFinite(Number(metadata.total)) ? Math.trunc(Number(metadata.total)) : Math.max(options.afterProjectedSeq, toProjectedSeq ?? options.afterProjectedSeq);
|
|
const traceLastSeq = Number.isFinite(Number(metadata.traceLastSeq)) ? Math.trunc(Number(metadata.traceLastSeq)) : total;
|
|
return {
|
|
events,
|
|
eventCount: total ?? Math.max(options.afterProjectedSeq, toProjectedSeq ?? options.afterProjectedSeq),
|
|
range: {
|
|
afterProjectedSeq: options.afterProjectedSeq,
|
|
fromProjectedSeq: events.length ? events[0].projectedSeq : null,
|
|
toProjectedSeq,
|
|
limit: options.limit,
|
|
returned: events.length,
|
|
total
|
|
},
|
|
hasMore,
|
|
fullTraceLoaded: !hasMore && (toProjectedSeq ?? options.afterProjectedSeq) >= traceLastSeq,
|
|
traceLastSeq,
|
|
nextProjectedSeq: events.length ? toProjectedSeq : options.afterProjectedSeq,
|
|
nextCursor: hasMore && toProjectedSeq ? `projected:${toProjectedSeq}` : null,
|
|
traceStatus: metadata.traceStatus ?? "unknown",
|
|
updatedAt: events.at(-1)?.updatedAt ?? events.at(-1)?.createdAt ?? null
|
|
};
|
|
}
|
|
|
|
export function projectedSeqCollision(rows = []) {
|
|
const seen = new Map();
|
|
for (const row of rows) {
|
|
const projectedSeq = factProjectedSeq(row);
|
|
if (!projectedSeq) continue;
|
|
const id = textValue(row?.id ?? row?.sourceEventId) || null;
|
|
const existing = seen.get(projectedSeq);
|
|
if (existing && existing.id !== id) {
|
|
return {
|
|
code: "projected_seq_collision",
|
|
category: "workbench-projection",
|
|
layer: "workbench-trace-events",
|
|
message: "Trace projection contains duplicate projectedSeq values; canonical event order is blocked until projection is rebuilt.",
|
|
projectedSeq,
|
|
eventIds: [existing.id, id].filter(Boolean),
|
|
retryable: false,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
if (!existing) seen.set(projectedSeq, { id });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function blockedTraceEventPage(options, metadata = {}, blocker) {
|
|
const total = Number.isFinite(Number(metadata.total)) ? Math.trunc(Number(metadata.total)) : null;
|
|
return {
|
|
events: [],
|
|
eventCount: total ?? options.afterProjectedSeq,
|
|
range: {
|
|
afterProjectedSeq: options.afterProjectedSeq,
|
|
fromProjectedSeq: null,
|
|
toProjectedSeq: null,
|
|
limit: options.limit,
|
|
returned: 0,
|
|
total
|
|
},
|
|
hasMore: false,
|
|
nextProjectedSeq: options.afterProjectedSeq,
|
|
nextCursor: null,
|
|
traceStatus: metadata.traceStatus ?? "unknown",
|
|
updatedAt: null,
|
|
blocker
|
|
};
|
|
}
|
|
|
|
export function blockedTraceEventProjection(projection = {}, blocker) {
|
|
return {
|
|
...projection,
|
|
projectionStatus: "blocked",
|
|
projectionHealth: "degraded",
|
|
blocker,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
export function catchingUpTraceEventProjection(projection = {}, diagnostic = null) {
|
|
const status = projection.projectionStatus === "caught-up" || !projection.projectionStatus ? "projecting" : projection.projectionStatus;
|
|
const health = projection.projectionHealth === "caught-up" || projection.projectionHealth === "healthy" || !projection.projectionHealth ? "projecting" : projection.projectionHealth;
|
|
return {
|
|
...projection,
|
|
projectionStatus: status,
|
|
projectionHealth: health,
|
|
blocker: null,
|
|
diagnostic: diagnostic ? { ...diagnostic, projectionStatus: status, projectionHealth: health } : null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
export function traceEventPageMissing(page = {}, projection = {}, options = {}) {
|
|
if (page.blocker) return false;
|
|
const expectedSeq = Number(projection?.lastProjectedSeq);
|
|
if (!Number.isFinite(expectedSeq) || expectedSeq <= 0) return false;
|
|
return factArray(page.events).length === 0 && expectedSeq > Number(options.afterProjectedSeq ?? 0);
|
|
}
|
|
|
|
export function traceEventsReadModelBlocker(code, message, { traceId, projection = {}, session = null, turn = null, route = null } = {}) {
|
|
return {
|
|
code,
|
|
message,
|
|
userMessage: message,
|
|
layer: "workbench-read-model",
|
|
category: "trace-events",
|
|
retryable: true,
|
|
route,
|
|
traceId,
|
|
sessionId: factSessionId(session) ?? turn?.sessionId ?? null,
|
|
turnId: factTurnId(turn, traceId),
|
|
projectionStatus: projection.projectionStatus ?? null,
|
|
projectionHealth: "degraded",
|
|
lastProjectedSeq: projection.lastProjectedSeq ?? null,
|
|
sourceRunId: projection.sourceRunId ?? null,
|
|
sourceCommandId: projection.sourceCommandId ?? null,
|
|
retryAfterMs: 5000,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function workbenchTraceEventsReadModelError(blocker, { traceId, projection = {}, session = null, turn = null } = {}) {
|
|
const responseProjection = blockedTraceEventProjection(projection, blocker);
|
|
return workbenchError(blocker.code, blocker.message, {
|
|
traceId,
|
|
sessionId: blocker.sessionId ?? factSessionId(session) ?? turn?.sessionId ?? null,
|
|
turnId: blocker.turnId ?? factTurnId(turn, traceId),
|
|
route: blocker.route ?? null,
|
|
layer: blocker.layer,
|
|
category: blocker.category,
|
|
retryable: blocker.retryable,
|
|
projection: responseProjection,
|
|
projectionStatus: responseProjection.projectionStatus,
|
|
projectionHealth: responseProjection.projectionHealth,
|
|
lastProjectedSeq: responseProjection.lastProjectedSeq,
|
|
sourceRunId: responseProjection.sourceRunId,
|
|
sourceCommandId: responseProjection.sourceCommandId,
|
|
blocker,
|
|
diagnostic: {
|
|
contractVersion: "hwlab-error-diagnostic-v1",
|
|
route: blocker.route ?? null,
|
|
layer: blocker.layer,
|
|
category: blocker.category,
|
|
code: blocker.code,
|
|
httpStatus: 404,
|
|
source: "server",
|
|
retryable: blocker.retryable,
|
|
valuesPrinted: false
|
|
}
|
|
});
|
|
}
|
|
|
|
export function projectionText(...values) {
|
|
for (const value of values) {
|
|
if (value && typeof value === "object") {
|
|
const nested = messageAuthorityTextValue(value.text ?? value.content ?? value.message ?? value.summary ?? value.preview ?? value.title);
|
|
if (nested) return nested;
|
|
continue;
|
|
}
|
|
const text = messageAuthorityTextValue(value);
|
|
if (text) return text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function partFact(part, index, messageId, traceId) {
|
|
const type = textValue(part?.type) || "text";
|
|
const text = messageAuthorityTextValue(part?.text ?? part?.content ?? part?.message);
|
|
return {
|
|
partId: safePartId(part?.partId ?? part?.id) || `prt_${hash(`${messageId}:${index}:${type}`).slice(0, 24)}`,
|
|
messageId,
|
|
traceId,
|
|
type,
|
|
text: text || null,
|
|
status: normalizeStatus(part?.status ?? "completed"),
|
|
toolName: textValue(part?.toolName ?? part?.name) || null,
|
|
createdAt: textValue(part?.createdAt ?? part?.timestamp) || null,
|
|
valuesRedacted: part?.valuesRedacted !== false
|
|
};
|
|
}
|
|
|
|
export function tracePageOptions(url) {
|
|
const cursor = textValue(url.searchParams.get("cursor"));
|
|
const cursorProjectedSeq = cursor.startsWith("projected:") ? Number.parseInt(cursor.slice("projected:".length), 10) : NaN;
|
|
const afterProjectedSeq = Number.isInteger(cursorProjectedSeq) && cursorProjectedSeq >= 0
|
|
? cursorProjectedSeq
|
|
: nonNegativeInteger(url.searchParams.get("afterProjectedSeq"), 0);
|
|
return { afterProjectedSeq, limit: boundedLimit(url.searchParams.get("limit")) };
|
|
}
|
|
|
|
|
|
export function handleWorkbenchReadModelFailure(response, url, options = {}, error) {
|
|
const classified = classifyWorkbenchReadModelFailure(error);
|
|
const route = workbenchReadRouteTemplate(url?.pathname ?? "/v1/workbench");
|
|
logWorkbenchReadModelFailure(options.logger ?? console, {
|
|
event: "workbench_read_model_route_failed",
|
|
ok: false,
|
|
route,
|
|
statusCode: classified.statusCode,
|
|
errorCode: classified.code,
|
|
errorName: error?.name ?? "Error",
|
|
message: error instanceof Error ? error.message : String(error ?? "unknown"),
|
|
valuesRedacted: true
|
|
});
|
|
if (response.headersSent || response.destroyed) return;
|
|
sendJson(response, classified.statusCode, workbenchError(classified.code, classified.message, {
|
|
route,
|
|
errorName: error?.name ?? "Error",
|
|
retryable: classified.retryable === true,
|
|
transient: classified.transient === true,
|
|
serviceId: classified.serviceId ?? null,
|
|
runtimeStatus: classified.runtimeStatus ?? null
|
|
}));
|
|
}
|
|
|
|
export function classifyWorkbenchReadModelFailure(error) {
|
|
const message = String(error?.message ?? error ?? "");
|
|
const code = String(error?.code ?? "").toLowerCase();
|
|
const runtimeStatus = Number(error?.data?.status ?? 0);
|
|
const runtimePayloadError = error?.data?.payload?.error ?? null;
|
|
if (error?.name === "WorkbenchRuntimeDependencyError" || code.startsWith("workbench_runtime_")) {
|
|
const retryable = error?.data?.retryable === true || error?.data?.transient === true || runtimePayloadError?.retryable === true;
|
|
return {
|
|
statusCode: retryable || (Number.isFinite(runtimeStatus) && runtimeStatus >= 500) ? 503 : 500,
|
|
code: String(runtimePayloadError?.code ?? error?.code ?? "workbench_runtime_dependency_failed"),
|
|
message: String(runtimePayloadError?.message ?? error?.message ?? "Workbench runtime dependency failed."),
|
|
retryable,
|
|
transient: retryable,
|
|
serviceId: String(error?.data?.serviceId ?? "hwlab-workbench-runtime"),
|
|
runtimeStatus: Number.isFinite(runtimeStatus) && runtimeStatus > 0 ? runtimeStatus : null
|
|
};
|
|
}
|
|
if (/timeout|connection terminated|terminating connection|econnreset|econnrefused|etimedout/iu.test(message) || /timeout|econnreset|econnrefused|etimedout/u.test(code)) {
|
|
return {
|
|
statusCode: 503,
|
|
code: "workbench_read_model_store_unavailable",
|
|
message: "Workbench read model store is temporarily unavailable."
|
|
};
|
|
}
|
|
return {
|
|
statusCode: 500,
|
|
code: "workbench_read_model_failed",
|
|
message: "Workbench read model failed."
|
|
};
|
|
}
|
|
|
|
export function workbenchReadRouteTemplate(pathname) {
|
|
const path = String(pathname ?? "");
|
|
if (path === "/v1/workbench/sessions") return path;
|
|
if (/^\/v1\/workbench\/sessions\/[^/]+\/messages$/u.test(path)) return "/v1/workbench/sessions/:id/messages";
|
|
if (/^\/v1\/workbench\/sessions\/[^/]+$/u.test(path)) return "/v1/workbench/sessions/:id";
|
|
if (/^\/v1\/workbench\/turns\/[^/]+$/u.test(path)) return "/v1/workbench/turns/:id";
|
|
if (/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(path)) return "/v1/workbench/traces/:id/events";
|
|
return path || "/v1/workbench";
|
|
}
|
|
|
|
export function logWorkbenchReadModelFailure(logger, payload) {
|
|
try {
|
|
const line = JSON.stringify(payload);
|
|
if (typeof logger?.error === "function") logger.error(line);
|
|
else if (typeof logger?.warn === "function") logger.warn(line);
|
|
} catch {
|
|
// Read-model failure logging must not affect response generation.
|
|
}
|
|
}
|
|
|
|
export function methodNotAllowed(response, allowed) {
|
|
sendJson(response, 405, workbenchError("method_not_allowed", `Use ${allowed} for this Workbench read model route.`));
|
|
}
|
|
|
|
export function workbenchError(code, message, extra = {}) {
|
|
return {
|
|
ok: false,
|
|
status: "failed",
|
|
error: { code, message, ...extra },
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
};
|
|
}
|
|
|
|
export function boundedLimit(value) {
|
|
return Math.min(MAX_PAGE_LIMIT, parsePositiveInteger(value, DEFAULT_PAGE_LIMIT));
|
|
}
|
|
|
|
export function boundedSessionListLimit(value) {
|
|
return Math.min(MAX_PAGE_LIMIT, parsePositiveInteger(value, DEFAULT_SESSION_LIST_LIMIT));
|
|
}
|
|
|
|
export function cursorOffset(value) {
|
|
const text = textValue(value);
|
|
if (!text) return 0;
|
|
if (text.startsWith("idx:")) return nonNegativeInteger(text.slice(4), 0);
|
|
return nonNegativeInteger(text, 0);
|
|
}
|
|
|
|
export function cursorFromOffset(offset) {
|
|
return `idx:${Math.max(0, Math.trunc(Number(offset) || 0))}`;
|
|
}
|
|
|
|
export function nonNegativeInteger(value, fallback) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
}
|
|
|
|
|
|
export function nowMs() {
|
|
if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
|
|
return Date.now();
|
|
}
|