UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

610 lines (609 loc) 24.3 kB
import { d as asPositiveSafeInteger, w as parseStrictPositiveInteger } from "./number-coercion-CLj0HTDM.js"; import "./src-vebZIeLe.js"; import { t as expectDefined } from "./expect-CyE8FADM.js"; import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js"; import { r as resolveRealpathOrAbsolute } from "./boundary-path-DMNeww4q.js"; import { n as ok, t as err } from "./result-BQGgYouL.js"; import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js"; import "./session-key-BnWWjqNc.js"; import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { n as GATEWAY_CLIENT_IDS, r as GATEWAY_CLIENT_MODES } from "./client-info-B1bPgeKr.js"; import "./io-bdCzpGWJ.js"; import "./version-CwNT1gaY.js"; import { n as authorizeOperatorScopesForMethod } from "./method-scopes-K6J_UQGL.js"; import { M as isSessionTranscriptProjectionUnavailableError } from "./session-accessor-YsytfDtG.js"; import { o as readSessionTranscriptUpdateVersion, r as onInternalSessionTranscriptUpdate } from "./transcript-events-wgPr4ILk.js"; import { u as getMaxChatHistoryMessagesBytes } from "./server-constants-BrVEC7RW.js"; import { E as projectChatDisplayMessagesWithState, S as resolveCurrentUserProfileDisplay, T as projectChatDisplayMessages, c as readSessionMessagesPageWithStatsAsync, l as readSessionMessagesWithSourceAsync, y as attachOpenClawTranscriptMeta } from "./session-transcript-readers-CYDRQsH5.js"; import { a as resolveSessionTranscriptCandidates } from "./session-transcript-files.fs-D0B5TSty.js"; import { t as DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS } from "./chat-display-projection.helpers-DYkWC7LH.js"; import { f as resolveGatewaySessionStoreTargetWithStore, s as resolveCanonicalSessionEntryFromStoreKeys } from "./session-utils-store-CInT2loy.js"; import "./session-utils-Cai0_C6U.js"; import { _ as resolveSharedSecretHttpOperatorScopes, c as authorizeScopedGatewayHttpRequestOrReply, d as getHeader, l as checkGatewayHttpRequestAuth } from "./http-auth-utils-C3lb4QXY.js"; import { b as hasExplicitAcceptableMediaRange, c as sendMethodNotAllowed, o as sendInvalidRequest, p as setSseHeaders, s as sendJson, t as SSE_CONTENT_TYPE } from "./http-common-BaZaosnr.js"; import { r as createSessionListEntryFilter, z as resolveSessionSharingTarget } from "./session-sharing-B7MI8hNo.js"; import "./http-utils-BHgXp7Zb.js"; import { n as readChatHistoryMessageSeq, r as readIncrementalChatHistoryTail } from "./session-history-tail-BuFrV1Wb.js"; import { isDeepStrictEqual } from "node:util"; //#region src/gateway/session-transcript-path.ts /** Resolve a transcript file path into a stable comparison key. */ function resolveTranscriptPathForComparison(value) { const trimmed = normalizeOptionalString(value); if (!trimmed) return; return resolveRealpathOrAbsolute(trimmed); } //#endregion //#region src/gateway/session-history-state.ts function readMessageIdempotencyKey(message) { if (!message || typeof message !== "object" || Array.isArray(message)) return; const value = message.idempotencyKey; return typeof value === "string" && value.trim() ? value : void 0; } /** Owns both complete history snapshots and bounded visible-message pages. */ async function readSessionHistoryRawSnapshotAsync(params) { if (typeof params.limit !== "number") { const snapshot = await readSessionMessagesWithSourceAsync(params.target, { mode: "full", reason: "session history cursor pagination", allowResetArchiveFallback: true }); return { rawMessages: snapshot.messages, transcriptPath: snapshot.transcriptPath }; } const cursorSeq = resolveCursorSeq(params.cursor); const offset = cursorSeq === void 0 ? void 0 : Math.max(0, (await readSessionMessagesPageWithStatsAsync(params.target, { offset: 0, maxMessages: 0, allowResetArchiveFallback: true })).totalMessages - cursorSeq + 1); const tail = await readIncrementalChatHistoryTail({ entry: params.target.sessionEntry, readScope: params.target, effectiveMaxChars: params.maxChars ?? 8e3, max: params.limit, maxBytes: getMaxChatHistoryMessagesBytes(), ...offset === void 0 ? {} : { offset }, preserveProjectionContext: true }); return { projection: tail.projection, rawMessages: tail.rawMessages, rawTranscriptSeq: tail.readPage.totalMessages, totalRawMessages: tail.readPage.totalMessages, transcriptPath: tail.readPage.transcriptPath }; } function resolveCursorSeq(cursor) { if (!cursor) return; const normalized = cursor.startsWith("seq:") ? cursor.slice(4) : cursor; if (!/^\d+$/.test(normalized)) return; const value = Number(normalized); return Number.isSafeInteger(value) && value > 0 ? value : void 0; } function toSessionHistoryMessages(messages) { return messages.filter((message) => Boolean(message) && typeof message === "object" && !Array.isArray(message)); } function buildPaginatedSessionHistory(params) { return { items: params.messages, messages: params.messages, hasMore: params.hasMore, ...params.nextCursor ? { nextCursor: params.nextCursor } : {} }; } function isMessageToolMirrorMessage(message) { return message.openclawMessageToolMirror !== void 0; } function paginateSessionMessages(messages, limit, cursor) { const cursorSeq = resolveCursorSeq(cursor); let endExclusive = messages.length; if (typeof cursorSeq === "number") { endExclusive = messages.findIndex((message, index) => { const seq = readChatHistoryMessageSeq(message); if (typeof seq === "number") return seq >= cursorSeq; return index + 1 >= cursorSeq; }); if (endExclusive < 0) endExclusive = messages.length; } let start = typeof limit === "number" && limit > 0 ? Math.max(0, endExclusive - limit) : 0; const pageSeqs = new Set(messages.slice(start, endExclusive).map(readChatHistoryMessageSeq).filter(Boolean)); const gapSeqs = /* @__PURE__ */ new Set(); for (let index = start - 1; index >= 0; index--) { const seq = readChatHistoryMessageSeq(messages[index]); if (seq === void 0) continue; gapSeqs.add(seq); if (!pageSeqs.has(seq)) continue; start = index; gapSeqs.forEach((gapSeq) => pageSeqs.add(gapSeq)); gapSeqs.clear(); } const paginatedMessages = messages.slice(start, endExclusive); const firstSeq = readChatHistoryMessageSeq(paginatedMessages[0]); return buildPaginatedSessionHistory({ messages: paginatedMessages, hasMore: start > 0, ...start > 0 && typeof firstSeq === "number" ? { nextCursor: String(firstSeq) } : {} }); } /** Builds the display history snapshot and raw transcript sequence watermark. */ function buildSessionHistorySnapshot(params) { const projected = params.projection ?? projectChatDisplayMessagesWithState(params.rawMessages, { includeCommentaryFallbacks: true, maxChars: params.maxChars ?? 8e3, resolveCurrentUserProfileDisplay }); const visibleMessages = projected.messages; const rawHistoryMessages = toSessionHistoryMessages(params.rawMessages); const history = paginateSessionMessages(visibleMessages, params.limit, params.cursor); if (typeof params.totalRawMessages === "number" && params.totalRawMessages > params.rawMessages.length && (!params.cursor || (readChatHistoryMessageSeq(rawHistoryMessages[0]) ?? 0) > 1)) { const firstSeq = readChatHistoryMessageSeq(history.messages[0] ?? rawHistoryMessages[0]); history.hasMore = true; if (typeof firstSeq === "number") history.nextCursor = String(firstSeq); } return { history, rawTranscriptSeq: params.rawTranscriptSeq ?? readChatHistoryMessageSeq(rawHistoryMessages.at(-1)) ?? rawHistoryMessages.length, turnBoundaryPending: projected.turnBoundaryPending, streamErrorFallbackPending: projected.streamErrorFallbackPending }; } /** Tracks session-history SSE state and decides when inline appends are still valid. */ var SessionHistorySseState = class SessionHistorySseState { static fromRawSnapshot(params) { return new SessionHistorySseState(params); } constructor(params) { this.target = params.target; this.maxChars = params.maxChars ?? 8e3; this.limit = params.limit; this.cursor = params.cursor; const snapshot = this.buildSnapshot(params); this.sentHistory = snapshot.history; this.rawTranscriptSeq = snapshot.rawTranscriptSeq; this.turnBoundaryPending = snapshot.turnBoundaryPending; this.streamErrorFallbackPending = snapshot.streamErrorFallbackPending; this.transcriptPath = normalizeTranscriptPathForComparison(params.transcriptPath); } snapshot() { return this.sentHistory; } retainRecentMessages(maxMessages) { if (this.sentHistory.messages.length <= maxMessages) return this.snapshot(); const messages = this.sentHistory.messages.slice(-maxMessages); const firstSeq = readChatHistoryMessageSeq(messages[0]); this.sentHistory = buildPaginatedSessionHistory({ messages, hasMore: true, ...firstSeq !== void 0 ? { nextCursor: String(firstSeq) } : {} }); return this.snapshot(); } appendInlineMessage(update) { if (this.limit !== void 0 || this.cursor !== void 0) return null; const carriedSeq = asPositiveSafeInteger(update.messageSeq); if (carriedSeq !== void 0) { if (carriedSeq <= this.rawTranscriptSeq) return { shouldRefresh: true }; this.rawTranscriptSeq = carriedSeq; } else this.rawTranscriptSeq += 1; const idempotencyKey = readMessageIdempotencyKey(update.message); const nextMessage = attachOpenClawTranscriptMeta(update.message, { ...typeof update.messageId === "string" ? { id: update.messageId } : {}, ...idempotencyKey ? { idempotencyKey } : {}, seq: this.rawTranscriptSeq }); const hadPendingTurnBoundary = this.turnBoundaryPending; const nextProjection = projectChatDisplayMessagesWithState([nextMessage], { includeCommentaryFallbacks: true, maxChars: this.maxChars, turnBoundaryPending: hadPendingTurnBoundary, streamErrorFallbackPending: this.streamErrorFallbackPending }); this.turnBoundaryPending = nextProjection.turnBoundaryPending; this.streamErrorFallbackPending = nextProjection.streamErrorFallbackPending; if (nextProjection.streamErrorFallbackRepaired) return { shouldRefresh: true }; const projectedMessages = projectChatDisplayMessages([...this.sentHistory.messages, nextMessage], { includeCommentaryFallbacks: true, maxChars: this.maxChars, resolveCurrentUserProfileDisplay }); const projectedPrefix = projectedMessages.slice(0, this.sentHistory.messages.length); if (projectedMessages.length > this.sentHistory.messages.length && !isDeepStrictEqual(projectedPrefix, this.sentHistory.messages)) { this.sentHistory = buildPaginatedSessionHistory({ messages: projectedMessages, hasMore: false }); return { shouldRefresh: true }; } if (projectedMessages.length > this.sentHistory.messages.length) { const addedMessages = projectedMessages.slice(this.sentHistory.messages.length); if (hadPendingTurnBoundary && !this.turnBoundaryPending) { const firstAdded = attachOpenClawTranscriptMeta(addedMessages[0], { turnBoundary: true }); addedMessages[0] = firstAdded; projectedMessages[this.sentHistory.messages.length] = firstAdded; } if (addedMessages.length > 1) { this.sentHistory = buildPaginatedSessionHistory({ messages: projectedMessages, hasMore: false }); return { shouldRefresh: true }; } const projectedMessage = expectDefined(addedMessages[0], "projected inline message"); const emittedMessage = isMessageToolMirrorMessage(projectedMessage) || readChatHistoryMessageSeq(projectedMessage) === void 0 ? attachOpenClawTranscriptMeta(projectedMessage, { seq: this.rawTranscriptSeq }) : projectedMessage; this.sentHistory = buildPaginatedSessionHistory({ messages: [...this.sentHistory.messages, emittedMessage], hasMore: false }); return { message: emittedMessage, messageSeq: readChatHistoryMessageSeq(emittedMessage) }; } if (nextProjection.messages.length === 0 && projectedMessages.length === this.sentHistory.messages.length) return null; this.sentHistory = buildPaginatedSessionHistory({ messages: projectedMessages, hasMore: false }); return { shouldRefresh: true }; } shouldRefreshForTranscriptPath(updatePath) { const nextPath = normalizeTranscriptPathForComparison(updatePath); return Boolean(this.transcriptPath && nextPath && this.transcriptPath !== nextPath); } async refreshAsync() { const rawSnapshot = await readSessionHistoryRawSnapshotAsync({ target: this.target, maxChars: this.maxChars, limit: this.limit, cursor: this.cursor }); const snapshot = this.buildSnapshot(rawSnapshot); this.rawTranscriptSeq = snapshot.rawTranscriptSeq; this.turnBoundaryPending = snapshot.turnBoundaryPending; this.streamErrorFallbackPending = snapshot.streamErrorFallbackPending; this.transcriptPath = normalizeTranscriptPathForComparison(rawSnapshot.transcriptPath); this.sentHistory = snapshot.history; return snapshot.history; } buildSnapshot(rawSnapshot) { return buildSessionHistorySnapshot({ projection: rawSnapshot.projection, rawMessages: rawSnapshot.rawMessages, maxChars: this.maxChars, limit: this.limit, cursor: this.cursor, rawTranscriptSeq: rawSnapshot.rawTranscriptSeq, totalRawMessages: rawSnapshot.totalRawMessages }); } }; function normalizeTranscriptPathForComparison(filePath) { return typeof filePath === "string" ? resolveTranscriptPathForComparison(filePath) : void 0; } //#endregion //#region src/gateway/sessions-history-http.ts const log = createSubsystemLogger("gateway/sessions-history-sse"); const MAX_SESSION_HISTORY_LIMIT = 1e3; function resolveSessionHistoryPath(req) { const match = new URL(req.url ?? "/", "http://localhost").pathname.match(/^\/sessions\/([^/]+)\/history$/); if (!match) return { matched: false }; try { const sessionKey = normalizeOptionalString(decodeURIComponent(match[1] ?? "")); return sessionKey ? { matched: true, sessionKey } : { error: "invalid-session-key", matched: true }; } catch { return { error: "invalid-session-key", matched: true }; } } function shouldStreamSse(req) { return hasExplicitAcceptableMediaRange(getHeader(req, "accept"), SSE_CONTENT_TYPE); } function getRequestUrl(req) { return new URL(req.url ?? "/", "http://localhost"); } function resolveLimit(req) { const raw = getRequestUrl(req).searchParams.get("limit"); if (raw == null) return ok(void 0); const trimmed = raw.trim(); const value = parseStrictPositiveInteger(trimmed); if (value !== void 0) return ok(Math.min(MAX_SESSION_HISTORY_LIMIT, value)); if (/^\d+$/.test(trimmed) && /[1-9]/.test(trimmed)) return ok(MAX_SESSION_HISTORY_LIMIT); return err("limit must be a positive integer"); } function sseWrite(res, event, payload) { res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(payload)}\n\n`); } function resolveSessionHistoryHttpClient(requestAuth, scopes) { if (!requestAuth.authenticatedUserProfile) return null; return { connect: { minProtocol: 4, maxProtocol: 4, client: { id: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, version: "internal", platform: "node", mode: GATEWAY_CLIENT_MODES.BACKEND }, role: "operator", scopes }, authenticatedUserProfile: requestAuth.authenticatedUserProfile }; } /** Handle `/sessions/:sessionKey/history` JSON/SSE requests. */ async function handleSessionHistoryHttpRequest(req, res, opts) { const sessionKeyResolution = resolveSessionHistoryPath(req); if (!sessionKeyResolution.matched) return false; if ("error" in sessionKeyResolution) { sendInvalidRequest(res, "invalid session key"); return true; } const { sessionKey } = sessionKeyResolution; if (req.method !== "GET") { sendMethodNotAllowed(res, "GET"); return true; } const authResult = await authorizeScopedGatewayHttpRequestOrReply({ req, res, auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter, operatorMethod: "chat.history", resolveOperatorScopes: resolveSharedSecretHttpOperatorScopes }); if (!authResult) return true; const { cfg, requestAuth, operatorScopes } = authResult; let target; let entry; try { target = resolveGatewaySessionStoreTargetWithStore({ cfg, key: sessionKey }); entry = resolveCanonicalSessionEntryFromStoreKeys(target.store, target.storeKeys); } catch (error) { if (error?.code !== "SESSION_CANONICAL_KEY_MIGRATION_REQUIRED") throw error; sendJson(res, 409, { ok: false, error: { type: "migration_required", message: error instanceof Error ? error.message : String(error) } }); return true; } const historyClient = resolveSessionHistoryHttpClient(requestAuth, operatorScopes); if (!entry?.sessionId || createSessionListEntryFilter({ cfg, client: historyClient })?.(target.canonicalKey, entry) === false) { sendJson(res, 404, { ok: false, error: { type: "not_found", message: `Session not found: ${sessionKey}` } }); return true; } const limitResult = resolveLimit(req); if (!limitResult.ok) { sendInvalidRequest(res, limitResult.error); return true; } const limit = limitResult.value; const cursor = normalizeOptionalString(getRequestUrl(req).searchParams.get("cursor")); if (cursor !== void 0 && resolveCursorSeq(cursor) === void 0) { sendInvalidRequest(res, "cursor must be a positive integer"); return true; } const effectiveMaxChars = DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS; const historyTarget = { agentId: target.agentId, sessionEntry: entry, sessionId: entry.sessionId, sessionKey: target.canonicalKey, storePath: target.storePath }; const snapshotVersion = readSessionTranscriptUpdateVersion(); let rawSnapshot; try { rawSnapshot = await readSessionHistoryRawSnapshotAsync({ cursor, target: historyTarget, limit, maxChars: effectiveMaxChars }); } catch (error) { if (!isSessionTranscriptProjectionUnavailableError(error)) throw error; res.setHeader("Retry-After", "1"); sendJson(res, 503, { ok: false, error: { type: "unavailable", message: "session history is rebuilding; retry shortly", retryable: true } }); return true; } const historySnapshot = { ...rawSnapshot, maxChars: effectiveMaxChars, limit, cursor }; if (!shouldStreamSse(req)) { const history = buildSessionHistorySnapshot(historySnapshot).history; sendJson(res, 200, { sessionKey: target.canonicalKey, ...history }); return true; } const transcriptCandidates = new Set(resolveSessionTranscriptCandidates(historyTarget.sessionId, target.storePath, void 0, target.agentId).map((candidate) => resolveTranscriptPathForComparison(candidate)).filter((candidate) => typeof candidate === "string")); const sseState = SessionHistorySseState.fromRawSnapshot({ ...historySnapshot, target: historyTarget }); let streamStopped = false; let streamQueue = Promise.resolve(); const streamResources = {}; function writeStreamHistory(snapshot) { sseWrite(res, "history", { sessionKey: target.canonicalKey, ...snapshot }); sseState.retainRecentMessages(MAX_SESSION_HISTORY_LIMIT); } function releaseStreamResources() { if (streamStopped) return; streamStopped = true; if (streamResources.heartbeat) clearInterval(streamResources.heartbeat); if (streamResources.unsubscribe) streamResources.unsubscribe(); } function detachStreamListeners() { req.off("close", handleRequestStreamClose); req.off("error", handleRequestStreamError); res.off("close", handleResponseStreamClose); res.off("finish", handleResponseStreamFinish); res.off("error", handleResponseStreamError); } function closeStream() { releaseStreamResources(); if (!res.writableEnded && !res.destroyed) res.end(); } function handleRequestStreamClose() { releaseStreamResources(); req.off("close", handleRequestStreamClose); req.off("error", handleRequestStreamError); } function handleRequestStreamError(error) { log.warn("session history SSE request stream errored; closing stream", { error }); closeStream(); } function handleResponseStreamFinish() { releaseStreamResources(); res.off("finish", handleResponseStreamFinish); } function handleResponseStreamClose() { releaseStreamResources(); detachStreamListeners(); } function handleResponseStreamError(error) { log.warn("session history SSE response stream errored; cleaning up stream", { error }); releaseStreamResources(); } const isStreamClosed = () => streamStopped || res.writableEnded || res.destroyed; req.on("close", handleRequestStreamClose); req.on("error", handleRequestStreamError); res.on("close", handleResponseStreamClose); res.on("finish", handleResponseStreamFinish); res.on("error", handleResponseStreamError); setSseHeaders(res); res.write("retry: 1000\n\n"); if (isStreamClosed()) return true; const queueStreamWork = (work) => { streamQueue = streamQueue.then(async () => { if (isStreamClosed()) return; await work(); }).catch((error) => { log.warn("session history SSE stream work failed; closing stream", { error }); closeStream(); }); }; queueStreamWork(async () => { if (snapshotVersion !== readSessionTranscriptUpdateVersion()) await sseState.refreshAsync(); if (!isStreamClosed()) writeStreamHistory(sseState.snapshot()); }); const isStreamStillAuthorized = async () => { const cfgLocal = getRuntimeConfig(); const currentRequestAuth = await checkGatewayHttpRequestAuth({ req, auth: opts.getResolvedAuth?.() ?? opts.auth, trustedProxies: cfgLocal.gateway?.trustedProxies, allowRealIpFallback: cfgLocal.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, cfg: cfgLocal }); if (!currentRequestAuth.ok) return false; if (currentRequestAuth.requestAuth.authenticatedUserProfile?.profileId !== requestAuth.authenticatedUserProfile?.profileId) return false; const requestedScopes = resolveSharedSecretHttpOperatorScopes(req, currentRequestAuth.requestAuth); if (!authorizeOperatorScopesForMethod("chat.history", requestedScopes).allowed) return false; const currentClient = resolveSessionHistoryHttpClient(currentRequestAuth.requestAuth, requestedScopes); if (!currentClient) return true; const currentTarget = resolveSessionSharingTarget({ cfg: cfgLocal, sessionKey: target.canonicalKey, agentId: target.agentId }); return currentTarget !== null && createSessionListEntryFilter({ cfg: cfgLocal, client: currentClient })?.(currentTarget.canonicalKey, currentTarget.entry) !== false; }; streamResources.heartbeat = setInterval(() => { queueStreamWork(async () => { if (!await isStreamStillAuthorized()) { closeStream(); return; } if (!res.writableEnded) res.write(": keepalive\n\n"); }); }, 15e3); streamResources.unsubscribe = onInternalSessionTranscriptUpdate((update) => { const updateMatchesIdentity = update.target?.sessionId === historyTarget.sessionId && normalizeAgentId(update.target.agentId) === normalizeAgentId(target.agentId); const updatePath = resolveTranscriptPathForComparison(update.sessionFile); if (!updateMatchesIdentity && (!updatePath || !transcriptCandidates.has(updatePath))) return; queueStreamWork(async () => { if (!await isStreamStillAuthorized()) { closeStream(); return; } if (update.message !== void 0 && limit === void 0 && cursor === void 0) { if (sseState.shouldRefreshForTranscriptPath(updatePath)) { writeStreamHistory(await sseState.refreshAsync()); return; } const nextEvent = sseState.appendInlineMessage({ message: update.message, messageId: update.messageId, messageSeq: update.messageSeq }); if (!nextEvent) return; if (nextEvent.shouldRefresh) { writeStreamHistory(await sseState.refreshAsync()); return; } if (nextEvent.message === void 0) return; sseState.retainRecentMessages(MAX_SESSION_HISTORY_LIMIT); sseWrite(res, "message", { sessionKey: target.canonicalKey, message: nextEvent.message, ...typeof update.messageId === "string" ? { messageId: update.messageId } : {}, messageSeq: nextEvent.messageSeq }); return; } writeStreamHistory(await sseState.refreshAsync()); }); }); return true; } //#endregion export { handleSessionHistoryHttpRequest };