diff --git a/src/utils/account-id.ts b/src/utils/account-id.ts index 21287c84b7608b..42a867c74c9b64 100644 --- a/src/utils/account-id.ts +++ b/src/utils/account-id.ts @@ -1,5 +1,12 @@ import { normalizeOptionalAccountId } from "../routing/account-id.js"; +/** + * Compatibility wrapper for account-id normalization. + * + * Runtime code imports this utility when it needs the older utils path while + * the canonical normalization logic lives in routing/account-id. + */ +/** Normalize an optional account id, returning undefined for blank/invalid input. */ export function normalizeAccountId(value?: string): string | undefined { return normalizeOptionalAccountId(value); } diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index ebf21ccf3de8a3..91407812f40f71 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -31,6 +31,12 @@ import { } from "./message-channel-constants.js"; import { normalizeMessageChannel } from "./message-channel-normalize.js"; +/** + * Message channel and Gateway client classification helpers. + * + * This module keeps channel normalization, client identity checks, and markdown + * capability lookup in one place for send/render decisions. + */ export { GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_MODES }; export type { GatewayClientName, GatewayClientMode }; export { normalizeGatewayClientName, normalizeGatewayClientMode }; @@ -40,24 +46,29 @@ type GatewayClientInfoLike = { id?: string | null; }; +/** Return whether a Gateway client is the CLI transport. */ export function isGatewayCliClient(client?: GatewayClientInfoLike | null): boolean { return normalizeGatewayClientMode(client?.mode) === GATEWAY_CLIENT_MODES.CLI; } +/** Return whether a client is one of the operator UI clients. */ export function isOperatorUiClient(client?: GatewayClientInfoLike | null): boolean { const clientId = normalizeGatewayClientName(client?.id); return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI || clientId === GATEWAY_CLIENT_NAMES.TUI; } +/** Return whether a client is the browser Control UI. */ export function isBrowserOperatorUiClient(client?: GatewayClientInfoLike | null): boolean { const clientId = normalizeGatewayClientName(client?.id); return clientId === GATEWAY_CLIENT_NAMES.CONTROL_UI; } +/** Return whether a raw channel id resolves to OpenClaw's internal channel. */ export function isInternalMessageChannel(raw?: string | null): raw is InternalMessageChannel { return normalizeMessageChannel(raw) === INTERNAL_MESSAGE_CHANNEL; } +/** Return whether a Gateway client is the public webchat surface. */ export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean { const mode = normalizeGatewayClientMode(client?.mode); if (mode === GATEWAY_CLIENT_MODES.WEBCHAT) { @@ -66,6 +77,7 @@ export function isWebchatClient(client?: GatewayClientInfoLike | null): boolean return normalizeGatewayClientName(client?.id) === GATEWAY_CLIENT_NAMES.WEBCHAT_UI; } +/** Resolve whether a channel can receive markdown without plain-text downgrade. */ export function isMarkdownCapableMessageChannel(raw?: string | null): boolean { const channel = normalizeMessageChannel(raw); if (!channel) { @@ -80,6 +92,7 @@ export function isMarkdownCapableMessageChannel(raw?: string | null): boolean { if (builtInMeta) { return builtInMeta.markdownCapable === true; } + // Catalog metadata covers bundled channels whose runtime plugin is not loaded yet. const catalogMeta = listBundledChannelCatalogEntries().find( (entry) => entry.id === builtInChannel, ); diff --git a/src/utils/queue-helpers.ts b/src/utils/queue-helpers.ts index 59a45488760f54..89b9f2c7c7a25b 100644 --- a/src/utils/queue-helpers.ts +++ b/src/utils/queue-helpers.ts @@ -1,21 +1,32 @@ +/** + * Shared queue overflow, debounce, and collection helpers. + * + * Queue owners use these helpers to cap pending work, summarize dropped items, + * debounce drains, and force individual collection when cross-channel ordering matters. + */ +/** Mutable summary state for a capped queue. */ export type QueueSummaryState = { dropPolicy: "summarize" | "old" | "new"; droppedCount: number; summaryLines: string[]; }; +/** Queue overflow strategy. */ export type QueueDropPolicy = QueueSummaryState["dropPolicy"]; +/** Generic capped queue state with shared overflow summary fields. */ export type QueueState = QueueSummaryState & { items: T[]; cap: number; }; +/** Clear accumulated overflow summary state after it has been emitted. */ export function clearQueueSummaryState(state: QueueSummaryState): void { state.droppedCount = 0; state.summaryLines = []; } +/** Build a summary prompt preview without mutating the source queue state. */ export function previewQueueSummaryPrompt(params: { state: QueueSummaryState; noun: string; @@ -32,6 +43,7 @@ export function previewQueueSummaryPrompt(params: { }); } +/** Apply runtime queue settings while preserving previous values for omitted fields. */ export function applyQueueRuntimeSettings(params: { target: { mode: TMode; @@ -58,6 +70,7 @@ export function applyQueueRuntimeSettings(params: { params.target.dropPolicy = params.settings.dropPolicy ?? params.target.dropPolicy; } +/** Trim queue summary text to a bounded single-line preview. */ export function elideQueueText(text: string, limit = 140): string { if (text.length <= limit) { return text; @@ -65,11 +78,13 @@ export function elideQueueText(text: string, limit = 140): string { return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; } +/** Normalize whitespace and elide one dropped item for queue summaries. */ export function buildQueueSummaryLine(text: string, limit = 160): string { const cleaned = text.replace(/\s+/g, " ").trim(); return elideQueueText(cleaned, limit); } +/** Run optional duplicate detection before an item enters a queue. */ export function shouldSkipQueueItem(params: { item: T; items: T[]; @@ -81,6 +96,7 @@ export function shouldSkipQueueItem(params: { return params.dedupe(params.item, params.items); } +/** Apply overflow policy before enqueueing another item. */ export function applyQueueDropPolicy(params: { queue: QueueState; summarize: (item: T) => string; @@ -102,6 +118,7 @@ export function applyQueueDropPolicy(params: { params.queue.droppedCount += 1; params.queue.summaryLines.push(buildQueueSummaryLine(params.summarize(item))); } + // Summary memory is bounded independently from the item cap to avoid prompt blowups. const limit = Math.max(0, params.summaryLimit ?? cap); while (params.queue.summaryLines.length > limit) { params.queue.summaryLines.shift(); @@ -110,11 +127,13 @@ export function applyQueueDropPolicy(params: { return true; } +/** Wait until the queue has been quiet for its debounce window. */ export function waitForQueueDebounce(queue: { debounceMs: number; lastEnqueuedAt: number; }): Promise { if (process.env.OPENCLAW_TEST_FAST === "1") { + // Tests use this escape hatch so debounce logic does not slow deterministic queue specs. return Promise.resolve(); } const debounceMs = Math.max(0, queue.debounceMs); @@ -134,6 +153,7 @@ export function waitForQueueDebounce(queue: { }); } +/** Mark one queue as draining unless another drain is already active. */ export function beginQueueDrain( map: Map, key: string, @@ -146,6 +166,7 @@ export function beginQueueDrain( return queue; } +/** Run and remove the next queued item, returning false when empty. */ export async function drainNextQueueItem( items: T[], run: (item: T) => Promise, @@ -159,6 +180,7 @@ export async function drainNextQueueItem( return true; } +/** Drain one item when collect mode requires individual processing. */ export async function drainCollectItemIfNeeded(params: { forceIndividualCollect: boolean; isCrossChannel: boolean; @@ -170,12 +192,14 @@ export async function drainCollectItemIfNeeded(params: { return "skipped"; } if (params.isCrossChannel) { + // Once cross-channel items appear, future collection stays individual to preserve ordering. params.setForceIndividualCollect?.(true); } const drained = await drainNextQueueItem(params.items, params.run); return drained ? "drained" : "empty"; } +/** Drain one collect step using mutable queue collection state. */ export async function drainCollectQueueStep(params: { collectState: { forceIndividualCollect: boolean }; isCrossChannel: boolean; @@ -193,6 +217,7 @@ export async function drainCollectQueueStep(params: { }); } +/** Build and consume the queue overflow summary prompt. */ export function buildQueueSummaryPrompt(params: { state: QueueSummaryState; noun: string; @@ -216,6 +241,7 @@ export function buildQueueSummaryPrompt(params: { return lines.join("\n"); } +/** Render a collect prompt from queued items and optional overflow summary. */ export function buildCollectPrompt(params: { title: string; items: T[]; @@ -232,6 +258,7 @@ export function buildCollectPrompt(params: { return blocks.join("\n\n"); } +/** Return true when queued items span keys or explicitly mark cross-channel state. */ export function hasCrossChannelItems( items: T[], resolveKey: (item: T) => { key?: string; cross?: boolean }, diff --git a/src/utils/safe-json.ts b/src/utils/safe-json.ts index f61c89f9f0b73f..430ed924f6d181 100644 --- a/src/utils/safe-json.ts +++ b/src/utils/safe-json.ts @@ -1,3 +1,10 @@ +/** + * Defensive JSON stringify helper for diagnostics. + * + * The replacer handles values common in runtime logs that JSON.stringify would + * otherwise reject or erase, and returns null for circular structures. + */ +/** Safely stringify diagnostic values, preserving bigint/errors/functions in readable form. */ export function safeJsonStringify(value: unknown): string | null { try { return JSON.stringify(value, (_key, val) => { @@ -11,6 +18,7 @@ export function safeJsonStringify(value: unknown): string | null { return { name: val.name, message: val.message, stack: val.stack }; } if (val instanceof Uint8Array) { + // Binary payloads are base64 encoded so diagnostic JSON remains valid UTF-8 text. return { type: "Uint8Array", data: Buffer.from(val).toString("base64") }; } return val; diff --git a/src/utils/with-timeout.ts b/src/utils/with-timeout.ts index 225d2e965e0db3..8734383d460f1b 100644 --- a/src/utils/with-timeout.ts +++ b/src/utils/with-timeout.ts @@ -1 +1,7 @@ +/** + * Compatibility export for timeout-wrapped operations. + * + * The implementation lives in infra/fs-safe; this keeps older utils imports on + * the same public helper without duplicating timeout behavior. + */ export { withTimeout } from "../infra/fs-safe.js";