diff --git a/packages/acp-core/src/error-format.ts b/packages/acp-core/src/error-format.ts index 67a2a56bd48..f3b30150d71 100644 --- a/packages/acp-core/src/error-format.ts +++ b/packages/acp-core/src/error-format.ts @@ -34,10 +34,12 @@ const SECRET_PATTERNS: RegExp[] = [ let configuredRedactor: ((value: string) => string) | undefined; +/** Installs a host-provided redactor used before ACP fallback secret-pattern redaction. */ export function configureAcpErrorRedactor(redactor: ((value: string) => string) | undefined): void { configuredRedactor = redactor; } +/** Redacts common provider, GitHub, HTTP, payment, bot, and private-key secrets from error text. */ export function redactSensitiveText(value: string): string { if (configuredRedactor) { return configuredRedactor(value); @@ -49,6 +51,7 @@ export function redactSensitiveText(value: string): string { return "[REDACTED_PRIVATE_KEY]"; } const groups = args.slice(0, -2); + // Replace only the captured secret when possible so surrounding diagnostics stay useful. const token = groups.findLast((group) => typeof group === "string" && group.length > 0); return token ? match.replace(token, "[REDACTED]") : "[REDACTED]"; }); diff --git a/packages/acp-core/src/meta.ts b/packages/acp-core/src/meta.ts index 5415869d3b6..3eb42ccea6d 100644 --- a/packages/acp-core/src/meta.ts +++ b/packages/acp-core/src/meta.ts @@ -17,6 +17,7 @@ function readMetaValue( return undefined; } +/** Reads the first present string metadata value from a current-to-legacy key list. */ export function readString( meta: Record | null | undefined, keys: string[], @@ -24,6 +25,7 @@ export function readString( return readMetaValue(meta, keys, normalizeOptionalString); } +/** Reads the first boolean metadata value without dropping false. */ export function readBool( meta: Record | null | undefined, keys: string[], @@ -31,6 +33,7 @@ export function readBool( return readMetaValue(meta, keys, (value) => (typeof value === "boolean" ? value : undefined)); } +/** Reads the first finite numeric metadata value from a current-to-legacy key list. */ export function readNumber( meta: Record | null | undefined, keys: string[], @@ -40,6 +43,7 @@ export function readNumber( ); } +/** Reads the first safe non-negative integer metadata value, preserving zero. */ export function readNonNegativeInteger( meta: Record | null | undefined, keys: string[], diff --git a/packages/acp-core/src/numeric-options.ts b/packages/acp-core/src/numeric-options.ts index 2d8bf7507af..71e689bc22f 100644 --- a/packages/acp-core/src/numeric-options.ts +++ b/packages/acp-core/src/numeric-options.ts @@ -1,5 +1,6 @@ import { resolveIntegerOption as resolveSharedIntegerOption } from "@openclaw/normalization-core/number-coercion"; +/** Resolves ACP integer options through the shared normalization contract. */ export function resolveIntegerOption( value: number | undefined, fallback: number, diff --git a/packages/acp-core/src/runtime/error-text.ts b/packages/acp-core/src/runtime/error-text.ts index e4901e1c869..38ee84a13ea 100644 --- a/packages/acp-core/src/runtime/error-text.ts +++ b/packages/acp-core/src/runtime/error-text.ts @@ -22,6 +22,7 @@ function resolveAcpRuntimeErrorNextStep(error: AcpRuntimeError): string | undefi return undefined; } +/** Formats ACP runtime errors with the operator next-step hint attached when known. */ export function formatAcpRuntimeErrorText(error: AcpRuntimeError): string { const next = resolveAcpRuntimeErrorNextStep(error); if (!next) { @@ -30,6 +31,7 @@ export function formatAcpRuntimeErrorText(error: AcpRuntimeError): string { return `ACP error (${error.code}): ${error.message}\nnext: ${next}`; } +/** Normalizes unknown failures into ACP runtime error text for user-facing surfaces. */ export function toAcpRuntimeErrorText(params: { error: unknown; fallbackCode: AcpRuntimeErrorCode; diff --git a/packages/acp-core/src/runtime/errors.ts b/packages/acp-core/src/runtime/errors.ts index abfb5f11fa8..a4fbe241f03 100644 --- a/packages/acp-core/src/runtime/errors.ts +++ b/packages/acp-core/src/runtime/errors.ts @@ -13,6 +13,7 @@ export const ACP_ERROR_CODES = [ export type AcpRuntimeErrorCode = (typeof ACP_ERROR_CODES)[number]; const ACP_ERROR_CODE_SET = new Set(ACP_ERROR_CODES); +/** Error type used at ACP runtime boundaries so callers can preserve structured failure codes. */ export class AcpRuntimeError extends Error { readonly code: AcpRuntimeErrorCode; override readonly cause?: unknown; @@ -67,10 +68,12 @@ function messageWithAcpRequestErrorDetails(error: Error): string { return `${error.message}: ${details}`; } +/** Recognizes local and cross-realm ACP runtime errors by their stable error code. */ export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError { return value instanceof AcpRuntimeError || getForeignAcpRuntimeError(value) !== null; } +/** Converts arbitrary thrown values into ACP runtime errors with redacted request details. */ export function toAcpRuntimeError(params: { error: unknown; fallbackCode: AcpRuntimeErrorCode; @@ -135,6 +138,7 @@ function renderSingleError(error: Error): string { return `${error.name}${codeSuffix}: ${error.message}`; } +/** Wraps async runtime work and rethrows failures as ACP runtime errors. */ export async function withAcpRuntimeErrorBoundary(params: { run: () => Promise; fallbackCode: AcpRuntimeErrorCode; diff --git a/packages/acp-core/src/runtime/session-identifiers.ts b/packages/acp-core/src/runtime/session-identifiers.ts index 215f2a8d781..953e646e097 100644 --- a/packages/acp-core/src/runtime/session-identifiers.ts +++ b/packages/acp-core/src/runtime/session-identifiers.ts @@ -57,6 +57,7 @@ function resolveAcpAgentResumeHintLine(params: { return resolver ? resolver({ agentSessionId }) : undefined; } +/** Renders status-safe ACP session identifier lines from persisted session metadata. */ export function resolveAcpSessionIdentifierLines(params: { sessionKey: string; meta?: SessionAcpMeta; @@ -70,6 +71,7 @@ export function resolveAcpSessionIdentifierLines(params: { }); } +/** Renders resolved ACP backend/agent ids, hiding pending ids from thread intros. */ export function resolveAcpSessionIdentifierLinesFromIdentity(params: { backend: string; identity?: SessionAcpIdentity; @@ -83,6 +85,8 @@ export function resolveAcpSessionIdentifierLinesFromIdentity(params: { const acpxRecordId = normalizeText(identity?.acpxRecordId); const hasIdentifier = Boolean(agentSessionId || acpxSessionId || acpxRecordId); if (isSessionIdentityPending(identity) && hasIdentifier) { + // Status views explain that ids are still settling; thread intros stay quiet so + // users do not copy provisional backend ids before the first reply resolves them. if (mode === "status") { return ["session ids: pending (available after the first reply)"]; } @@ -101,6 +105,7 @@ export function resolveAcpSessionIdentifierLinesFromIdentity(params: { return lines; } +/** Resolves the runtime cwd, preferring modern runtimeOptions over legacy metadata. */ export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined { const runtimeCwd = normalizeText(meta?.runtimeOptions?.cwd); if (runtimeCwd) { @@ -109,6 +114,7 @@ export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined return normalizeText(meta?.cwd); } +/** Renders thread-detail identifier lines plus a backend-specific resume hint when stable. */ export function resolveAcpThreadSessionDetailLines(params: { sessionKey: string; meta?: SessionAcpMeta; diff --git a/packages/acp-core/src/runtime/types.ts b/packages/acp-core/src/runtime/types.ts index ea12dcf24ca..3f6a6ff9d84 100644 --- a/packages/acp-core/src/runtime/types.ts +++ b/packages/acp-core/src/runtime/types.ts @@ -2,6 +2,7 @@ export type AcpRuntimePromptMode = "prompt" | "steer"; export type AcpRuntimeSessionMode = "persistent" | "oneshot"; +/** Runtime update tags emitted by ACP adapters; unknown backend tags are passed through. */ export type AcpSessionUpdateTag = | "agent_message_chunk" | "agent_thought_chunk" @@ -17,6 +18,7 @@ export type AcpSessionUpdateTag = export type AcpRuntimeControl = "session/set_mode" | "session/set_config_option" | "session/status"; +/** Stable handle returned by ensureSession and passed back into all ACP runtime operations. */ export type AcpRuntimeHandle = { sessionKey: string; backend: string; @@ -35,6 +37,7 @@ export type AcpRuntimeEnsureInput = { sessionKey: string; agent: string; mode: AcpRuntimeSessionMode; + /** Backend or agent session id to resume when reopening an existing conversation. */ resumeSessionId?: string; /** Optional runtime model override that must be available during session creation. */ model?: string; @@ -49,6 +52,7 @@ export type AcpRuntimeTurnAttachment = { data: string; }; +/** Per-turn payload delivered to ACP adapters. */ export type AcpRuntimeTurnInput = { handle: AcpRuntimeHandle; text: string; @@ -86,6 +90,7 @@ export type AcpRuntimeDoctorReport = { details?: string[]; }; +/** Streaming event union produced by ACP adapters while a turn is running. */ export type AcpRuntimeEvent = | { type: "text_delta"; @@ -127,6 +132,7 @@ export type AcpRuntimeTurnResultError = { retryable?: boolean; }; +/** Terminal turn result, separated from the live event stream for reliable failure handling. */ export type AcpRuntimeTurnResult = | { status: "completed"; @@ -145,10 +151,13 @@ export interface AcpRuntimeTurn { readonly requestId: string; readonly events: AsyncIterable; readonly result: Promise; + /** Requests backend cancellation while keeping result/error reporting adapter-owned. */ cancel(input?: { reason?: string }): Promise; + /** Closes the event stream when the caller stops listening before terminal result. */ closeStream(input?: { reason?: string }): Promise; } +/** ACP adapter contract implemented by backend plugins and consumed by gateway/session flows. */ export interface AcpRuntime { ensureSession(input: AcpRuntimeEnsureInput): Promise; diff --git a/packages/acp-core/src/session-interaction-mode.ts b/packages/acp-core/src/session-interaction-mode.ts index cebe3ae7284..9e84a8fb950 100644 --- a/packages/acp-core/src/session-interaction-mode.ts +++ b/packages/acp-core/src/session-interaction-mode.ts @@ -23,6 +23,7 @@ function resolveAcpSessionInteractionMode( return "interactive"; } +/** Returns true for ACP sessions delegated from a parent session instead of user-facing chat. */ export function isParentOwnedBackgroundAcpSession(entry?: SessionInteractionEntry | null): boolean { return resolveAcpSessionInteractionMode(entry) === "parent-owned-background"; } diff --git a/packages/acp-core/src/session-lineage-meta.ts b/packages/acp-core/src/session-lineage-meta.ts index 8826143dcf4..1915cc2f5ee 100644 --- a/packages/acp-core/src/session-lineage-meta.ts +++ b/packages/acp-core/src/session-lineage-meta.ts @@ -7,9 +7,11 @@ type SubagentRole = (typeof SUBAGENT_ROLES)[number]; type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number]; export type AcpSessionLineageMeta = { + /** Stable session key emitted to ACP clients. */ sessionKey: string; kind?: string; channel?: string; + /** Best available parent session id, preferring explicit parentSessionKey over legacy spawnedBy. */ parentSessionId?: string; spawnedBy?: string; spawnDepth?: number; @@ -20,6 +22,7 @@ export type AcpSessionLineageMeta = { }; export type AcpSessionLineageRow = { + /** Raw persisted session key; kept even when other optional fields are malformed. */ key: string; kind?: string; channel?: string; @@ -44,10 +47,13 @@ function readEnum(value: unknown, allowed: readonly T[]): T | return allowed.find((candidate) => candidate === normalized); } +/** Converts persisted session rows into compact ACP lineage metadata for protocol responses. */ export function toAcpSessionLineageMeta(row: AcpSessionLineageRow): AcpSessionLineageMeta { const sessionKey = normalizeOptionalString(row.key) ?? row.key; const kind = normalizeOptionalString(row.kind); const channel = normalizeOptionalString(row.channel); + // Older rows may only carry spawnedBy; expose it as parentSessionId so ACP clients + // can follow lineage without knowing which storage-era field populated it. const parentSessionId = normalizeOptionalString(row.parentSessionKey) ?? normalizeOptionalString(row.spawnedBy); const spawnedBy = normalizeOptionalString(row.spawnedBy); diff --git a/packages/acp-core/src/session.ts b/packages/acp-core/src/session.ts index 2fdda0f06b0..c0f65e1d4a3 100644 --- a/packages/acp-core/src/session.ts +++ b/packages/acp-core/src/session.ts @@ -3,6 +3,7 @@ import { resolveIntegerOption } from "./numeric-options.js"; import type { AcpSession } from "./types.js"; export type AcpSessionStore = { + /** Creates or refreshes an in-memory ACP session under the supplied session id. */ createSession: (params: { sessionKey: string; cwd: string; @@ -12,6 +13,7 @@ export type AcpSessionStore = { hasSession: (sessionId: string) => boolean; getSession: (sessionId: string) => AcpSession | undefined; getSessionByRunId: (runId: string) => AcpSession | undefined; + /** Binds an active runtime run to a session so cancel/close can abort it later. */ setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void; clearActiveRun: (sessionId: string) => void; cancelActiveRun: (sessionId: string) => boolean; @@ -28,6 +30,7 @@ type AcpSessionStoreOptions = { const DEFAULT_MAX_SESSIONS = 5_000; const DEFAULT_IDLE_TTL_MS = 24 * 60 * 60 * 1_000; +/** Creates the bounded in-memory ACP session registry used by local ACP runtime clients. */ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {}): AcpSessionStore { const maxSessions = resolveIntegerOption(options.maxSessions, DEFAULT_MAX_SESSIONS, { min: 1 }); const idleTtlMs = resolveIntegerOption(options.idleTtlMs, DEFAULT_IDLE_TTL_MS, { min: 1_000 }); @@ -98,6 +101,8 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {}) return existingSession; } reapIdleSessions(nowMs); + // Active runs are never evicted to make cancellation ownership explicit; callers must + // clear/cancel them before the soft cap can make room. if (sessions.size >= maxSessions && !evictOldestIdleSession()) { throw new Error( `ACP session limit reached (max ${maxSessions}). Close idle ACP clients and retry.`, diff --git a/packages/acp-core/src/types.ts b/packages/acp-core/src/types.ts index a974d6e93c5..92b05f9da90 100644 --- a/packages/acp-core/src/types.ts +++ b/packages/acp-core/src/types.ts @@ -51,10 +51,12 @@ export type SessionAcpIdentitySource = "ensure" | "status" | "event"; export type SessionAcpIdentityState = "pending" | "resolved"; export type SessionAcpIdentity = { + /** Pending identities may expose provisional ids; resolved identities are safe for resume output. */ state: SessionAcpIdentityState; acpxRecordId?: string; acpxSessionId?: string; agentSessionId?: string; + /** Runtime lifecycle point that last supplied the identity fields. */ source: SessionAcpIdentitySource; lastUpdatedAt: number; }; @@ -82,6 +84,7 @@ export type SessionAcpMeta = { backend: string; agent: string; runtimeSessionName: string; + /** Canonical backend/agent ids used for resume hints and thread/status details. */ identity?: SessionAcpIdentity; mode: "persistent" | "oneshot"; runtimeOptions?: AcpSessionRuntimeOptions; diff --git a/packages/llm-core/src/utils/diagnostics.ts b/packages/llm-core/src/utils/diagnostics.ts index 26db0db39e5..0b7e3286c5f 100644 --- a/packages/llm-core/src/utils/diagnostics.ts +++ b/packages/llm-core/src/utils/diagnostics.ts @@ -12,6 +12,7 @@ export interface AssistantMessageDiagnostic { details?: Record; } +/** Formats arbitrary thrown values into diagnostic-safe text. */ export function formatThrownValue(value: unknown): string { if (value instanceof Error) { return value.message || value.name; @@ -22,6 +23,7 @@ export function formatThrownValue(value: unknown): string { return String(value); } +/** Extracts serializable diagnostic error fields from Error and non-Error throws. */ export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo { if (!(error instanceof Error)) { return { name: "ThrownValue", message: formatThrownValue(error) }; @@ -35,6 +37,7 @@ export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo { }; } +/** Creates a timestamped assistant-message diagnostic entry. */ export function createAssistantMessageDiagnostic( type: string, error: unknown, @@ -43,6 +46,7 @@ export function createAssistantMessageDiagnostic( return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details }; } +/** Appends a diagnostic while preserving existing message diagnostics. */ export function appendAssistantMessageDiagnostic( message: { diagnostics?: AssistantMessageDiagnostic[] }, diagnostic: AssistantMessageDiagnostic, diff --git a/packages/llm-core/src/utils/event-stream.ts b/packages/llm-core/src/utils/event-stream.ts index e364b9d99e1..ac4f8c26238 100644 --- a/packages/llm-core/src/utils/event-stream.ts +++ b/packages/llm-core/src/utils/event-stream.ts @@ -4,7 +4,7 @@ import type { AssistantMessageEventStreamContract, } from "../types.js"; -// Generic event stream class for async iteration +/** Generic async-iterable event stream with a separately awaited final result. */ export class EventStream implements AsyncIterable { private queue: T[] = []; private waiting: ((value: IteratorResult) => void)[] = []; @@ -32,7 +32,6 @@ export class EventStream implements AsyncIterable { this.resolveFinalResult(this.extractResult(event)); } - // Deliver to waiting consumer or queue it const waiter = this.waiting.shift(); if (waiter) { waiter({ value: event, done: false }); @@ -46,7 +45,6 @@ export class EventStream implements AsyncIterable { if (result !== undefined) { this.resolveFinalResult(result); } - // Notify all waiting consumers that we're done while (this.waiting.length > 0) { const waiter = this.waiting.shift()!; waiter({ value: undefined as unknown, done: true }); @@ -76,6 +74,7 @@ export class EventStream implements AsyncIterable { } } +/** Assistant-message event stream that resolves on done/error terminal events. */ export class AssistantMessageEventStream extends EventStream implements AssistantMessageEventStreamContract @@ -95,7 +94,7 @@ export class AssistantMessageEventStream } } -/** Factory function for AssistantMessageEventStream (for use in extensions) */ +/** Creates an assistant-message stream for provider and plugin adapters. */ export function createAssistantMessageEventStream(): AssistantMessageEventStream { return new AssistantMessageEventStream(); } diff --git a/packages/llm-core/src/validation.ts b/packages/llm-core/src/validation.ts index 0ef65cd5ba9..211ee768129 100644 --- a/packages/llm-core/src/validation.ts +++ b/packages/llm-core/src/validation.ts @@ -297,6 +297,8 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown { const validator = getValidator(tool.parameters); if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) { + // TypeBox Value.Convert is intentionally conservative for plain JSON schemas; + // mirror the provider-facing coercions so model-emitted string numbers validate. const coerced = coerceWithJsonSchema(args, tool.parameters); if (coerced !== args) { if (isRecord(args) && isRecord(coerced)) { diff --git a/packages/llm-runtime/src/stream.ts b/packages/llm-runtime/src/stream.ts index aed573d2a20..e06090c4f45 100644 --- a/packages/llm-runtime/src/stream.ts +++ b/packages/llm-runtime/src/stream.ts @@ -8,8 +8,6 @@ import type { SimpleStreamOptions, StreamOptions, } from "../../llm-core/src/index.js"; -// Type-only source import keeps plugin SDK declarations self-contained; package -// runtime emits no llm-core import from this module. import { getApiProvider } from "./api-registry.js"; function resolveApiProvider(api: Api) { @@ -20,6 +18,7 @@ function resolveApiProvider(api: Api) { return provider; } +/** Streams a provider turn through the registered implementation for the model API. */ export function stream( model: Model, context: Context, @@ -29,6 +28,7 @@ export function stream( return provider.stream(model, context, options as StreamOptions); } +/** Runs a provider turn and resolves the final assistant message result. */ export async function complete( model: Model, context: Context, @@ -38,6 +38,7 @@ export async function complete( return s.result(); } +/** Streams a simple provider turn through the registered implementation for the model API. */ export function streamSimple( model: Model, context: Context, @@ -47,6 +48,7 @@ export function streamSimple( return provider.streamSimple(model, context, options); } +/** Runs a simple provider turn and resolves the final assistant message result. */ export async function completeSimple( model: Model, context: Context, diff --git a/packages/markdown-core/src/code-spans.ts b/packages/markdown-core/src/code-spans.ts index 7eaa8d89363..1f2f14b48c9 100644 --- a/packages/markdown-core/src/code-spans.ts +++ b/packages/markdown-core/src/code-spans.ts @@ -5,6 +5,7 @@ export type InlineCodeState = { ticks: number; }; +/** Creates the carry-forward state used when scanning inline code across chunks. */ export function createInlineCodeState(): InlineCodeState { return { open: false, ticks: 0 }; } @@ -20,6 +21,7 @@ type CodeSpanIndex = { isInside: (index: number) => boolean; }; +/** Builds a lookup for fenced and inline code spans while preserving scanner state. */ export function buildCodeSpanIndex( text: string, inlineState?: InlineCodeState, diff --git a/packages/markdown-core/src/fences.ts b/packages/markdown-core/src/fences.ts index 51aa7af80dd..bdb63e7da18 100644 --- a/packages/markdown-core/src/fences.ts +++ b/packages/markdown-core/src/fences.ts @@ -1,3 +1,4 @@ +/** Markdown fenced-code block span with the opener data needed to reopen it. */ export type FenceSpan = { start: number; end: number; @@ -6,6 +7,7 @@ export type FenceSpan = { indent: string; }; +/** Streaming fence scanner state carried across partial markdown chunks. */ export type FenceScanState = { atLineStart?: boolean; open?: { @@ -57,6 +59,8 @@ export function scanFenceSpans( indent, }; } else if (open.markerChar === markerChar && markerLen >= open.markerLen) { + // CommonMark allows a closing fence to be longer than the opener, but + // it must use the same marker character to avoid crossing fence kinds. const end = lineEnd; spans.push({ start: open.start, diff --git a/packages/markdown-core/src/render-aware-chunking.ts b/packages/markdown-core/src/render-aware-chunking.ts index d57e9382d8e..2fad6e2a3f8 100644 --- a/packages/markdown-core/src/render-aware-chunking.ts +++ b/packages/markdown-core/src/render-aware-chunking.ts @@ -6,11 +6,13 @@ import { type MarkdownStyleSpan, } from "./ir.js"; +/** A rendered chunk paired with the Markdown IR slice that produced it. */ export type RenderedMarkdownChunk = { rendered: TRendered; source: MarkdownIR; }; +/** Inputs for chunking Markdown IR against the final rendered payload size. */ export type RenderMarkdownIRChunksWithinLimitOptions = { ir: MarkdownIR; limit: number; @@ -30,6 +32,7 @@ function resolveIntegerOption(value: number, fallback: number, opts: { min: numb return Math.max(opts.min, Math.trunc(value)); } +/** Chunks Markdown IR by rendered size while preserving styles, links, and whitespace. */ export function renderMarkdownIRChunksWithinLimit( options: RenderMarkdownIRChunksWithinLimitOptions, ): RenderedMarkdownChunk[] { @@ -318,6 +321,8 @@ function coalesceWhitespaceOnlyMarkdownIRChunks( } if (prev && next) { + // Split pure whitespace between neighbors before dropping it so list, + // paragraph, and quote spacing survives when both sides still fit. for (let prefixLength = chunkLength - 1; prefixLength >= 1; prefixLength -= 1) { const prefix = sliceMarkdownIR(chunk, 0, prefixLength); const suffix = sliceMarkdownIR(chunk, prefixLength, chunkLength); diff --git a/packages/net-policy/src/ip.ts b/packages/net-policy/src/ip.ts index d4dff3ee61f..64272be9fdf 100644 --- a/packages/net-policy/src/ip.ts +++ b/packages/net-policy/src/ip.ts @@ -12,6 +12,7 @@ function normalizeLowercaseStringOrEmpty(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } +/** Parsed IP address value returned by the net-policy parsing helpers. */ export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6; type Ipv4Range = ReturnType; type Ipv6Range = ReturnType; @@ -48,6 +49,7 @@ const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([ ]); const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; const CLOUD_METADATA_IP_ADDRESSES = new Set(["100.100.100.200", "fd00:ec2::254"]); +/** Per-call exemptions for `isBlockedSpecialUseIpv4Address`. */ export type Ipv4SpecialUseBlockOptions = { allowRfc2544BenchmarkRange?: boolean; }; @@ -146,10 +148,12 @@ function parseIpv6WithEmbeddedIpv4(raw: string): ipaddr.IPv6 | undefined { return ipaddr.IPv6.parse(normalizedIpv6); } +/** Type guard for parsed IPv4 addresses. */ export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 { return address.kind() === "ipv4"; } +/** Type guard for parsed IPv6 addresses. */ export function isIpv6Address(address: ParsedIpAddress): address is ipaddr.IPv6 { return address.kind() === "ipv6"; } @@ -172,6 +176,7 @@ function normalizeIpParseInput(raw: string | undefined): string | undefined { return stripIpv6Brackets(trimmed); } +/** Parses canonical IPv4/IPv6 literals, rejecting legacy IPv4 shorthand forms. */ export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { const normalized = normalizeIpParseInput(raw); if (!normalized) { @@ -189,6 +194,7 @@ export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddres return parseIpv6WithEmbeddedIpv4(normalized); } +/** Parses canonical IP literals plus legacy IPv4 forms needed for SSRF checks. */ export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined { const normalized = normalizeIpParseInput(raw); if (!normalized) { @@ -200,6 +206,7 @@ export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | return parseIpv6WithEmbeddedIpv4(normalized); } +/** Normalizes canonical IP literals and maps IPv4-mapped IPv6 addresses to IPv4 text. */ export function normalizeIpAddress(raw: string | undefined): string | undefined { const parsed = parseCanonicalIpAddress(raw); if (!parsed) { @@ -209,6 +216,7 @@ export function normalizeIpAddress(raw: string | undefined): string | undefined return normalizeLowercaseStringOrEmpty(normalized.toString()); } +/** True only for canonical four-part dotted-decimal IPv4 literals. */ export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean { const trimmed = normalizeOptionalString(raw); if (!trimmed) { @@ -221,6 +229,7 @@ export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean { return ipaddr.IPv4.isValidFourPartDecimal(normalized); } +/** Detects legacy numeric IPv4 forms that canonical parsing deliberately rejects. */ export function isLegacyIpv4Literal(raw: string | undefined): boolean { const trimmed = normalizeOptionalString(raw); if (!trimmed) { @@ -246,6 +255,7 @@ export function isLegacyIpv4Literal(raw: string | undefined): boolean { return true; } +/** True when a canonical IP literal is loopback, including IPv4-mapped IPv6. */ export function isLoopbackIpAddress(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed) { @@ -255,6 +265,7 @@ export function isLoopbackIpAddress(raw: string | undefined): boolean { return normalized.range() === "loopback"; } +/** True for link-local IPs, including legacy and embedded-IPv4 forms. */ export function isLinkLocalIpAddress(raw: string | undefined): boolean { const parsed = parseLooseIpAddress(raw); if (!parsed) { @@ -271,6 +282,7 @@ export function isLinkLocalIpAddress(raw: string | undefined): boolean { return normalized.range() === "linkLocal"; } +/** True for cloud metadata IP literals, including mapped and embedded forms. */ export function isCloudMetadataIpAddress(raw: string | undefined): boolean { const parsed = parseLooseIpAddress(raw); if (!parsed) { @@ -286,6 +298,7 @@ export function isCloudMetadataIpAddress(raw: string | undefined): boolean { return CLOUD_METADATA_IP_ADDRESSES.has(normalized.toString()); } +/** True for canonical private, loopback, link-local, or blocked special-use IPs. */ export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed) { @@ -298,6 +311,7 @@ export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean { return isBlockedSpecialUseIpv6Address(normalized); } +/** Applies the SSRF block policy for parsed IPv6 special-use ranges. */ export function isBlockedSpecialUseIpv6Address( address: ipaddr.IPv6, options: Ipv6SpecialUseBlockOptions = {}, @@ -318,6 +332,7 @@ export function isBlockedSpecialUseIpv6Address( return (address.parts[0] & 0xffc0) === 0xfec0; } +/** True for canonical IPv4 literals in RFC 1918 private ranges. */ export function isRfc1918Ipv4Address(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed || !isIpv4Address(parsed)) { @@ -326,6 +341,7 @@ export function isRfc1918Ipv4Address(raw: string | undefined): boolean { return parsed.range() === "private"; } +/** True for canonical IPv4 literals in the carrier-grade NAT range. */ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { const parsed = parseCanonicalIpAddress(raw); if (!parsed || !isIpv4Address(parsed)) { @@ -334,6 +350,7 @@ export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { return parsed.range() === "carrierGradeNat"; } +/** Applies the SSRF block policy for parsed IPv4 special-use ranges. */ export function isBlockedSpecialUseIpv4Address( address: ipaddr.IPv4, options: Ipv4SpecialUseBlockOptions = {}, @@ -355,6 +372,7 @@ function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { return ipaddr.IPv4.parse(octets.join(".")); } +/** Extracts embedded IPv4 addresses from mapped and transition IPv6 prefixes. */ export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | undefined { if (address.isIPv4MappedAddress()) { return address.toIPv4Address(); @@ -375,6 +393,7 @@ export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | return undefined; } +/** Checks an IP literal against an exact IP or CIDR range, normalizing mapped IPv4. */ export function isIpInCidr(ip: string, cidr: string): boolean { const normalizedIp = parseCanonicalIpAddress(ip); if (!normalizedIp) { diff --git a/packages/net-policy/src/ipv4.ts b/packages/net-policy/src/ipv4.ts index 22638783dbc..e2a7be701c8 100644 --- a/packages/net-policy/src/ipv4.ts +++ b/packages/net-policy/src/ipv4.ts @@ -1,5 +1,6 @@ import { isCanonicalDottedDecimalIPv4 } from "./ip.js"; +/** Validates the custom-bind IPv4 input and returns the user-facing error text. */ export function validateDottedDecimalIPv4Input(value: string | undefined): string | undefined { if (!value) { return "IP address is required for custom bind mode"; diff --git a/packages/net-policy/src/redact-sensitive-url.ts b/packages/net-policy/src/redact-sensitive-url.ts index 6b76c833641..04d3748276e 100644 --- a/packages/net-policy/src/redact-sensitive-url.ts +++ b/packages/net-policy/src/redact-sensitive-url.ts @@ -6,6 +6,7 @@ function normalizeLowercaseStringOrEmpty(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } +/** Config UI hint tag for URL-like values that may embed credentials or tokens. */ export const SENSITIVE_URL_HINT_TAG = "url-secret"; const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([ @@ -26,11 +27,13 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([ "signature", ]); +/** True for auth-like URL query parameter names that should be redacted. */ export function isSensitiveUrlQueryParamName(name: string): boolean { const normalized = normalizeLowercaseStringOrEmpty(name).replaceAll("-", "_"); return SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized); } +/** True for config paths whose URL values may contain credentials or secret query params. */ export function isSensitiveUrlConfigPath(path: string): boolean { if (path.endsWith(".baseUrl") || path.endsWith(".httpUrl")) { return true; @@ -44,10 +47,12 @@ export function isSensitiveUrlConfigPath(path: string): boolean { return /^mcp\.servers\.(?:\*|[^.]+)\.url$/.test(path); } +/** True when a config UI hint explicitly marks a URL-like value as secret-bearing. */ export function hasSensitiveUrlHintTag(hint: ConfigUiHintTags | undefined): boolean { return hint?.tags?.includes(SENSITIVE_URL_HINT_TAG) === true; } +/** Redacts credentials and sensitive query params from parseable URLs. */ export function redactSensitiveUrl(value: string): string { try { const parsed = new URL(value); @@ -69,6 +74,7 @@ export function redactSensitiveUrl(value: string): string { } } +/** Redacts sensitive URL-looking substrings even when the full value is not a valid URL. */ export function redactSensitiveUrlLikeString(value: string): string { const redactedUrl = redactSensitiveUrl(value); if (redactedUrl !== value) { diff --git a/packages/normalization-core/src/record-coerce.ts b/packages/normalization-core/src/record-coerce.ts index 873f0d3bd56..c3e20535620 100644 --- a/packages/normalization-core/src/record-coerce.ts +++ b/packages/normalization-core/src/record-coerce.ts @@ -1,4 +1,3 @@ -// Keep this local so browser bundles do not pull in src/utils.ts and its Node-only side effects. /** Type guard for non-array object records at browser-safe boundaries. */ export function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); diff --git a/packages/normalization-core/src/string-normalization.ts b/packages/normalization-core/src/string-normalization.ts index 8007e8b507e..b6fce15fac7 100644 --- a/packages/normalization-core/src/string-normalization.ts +++ b/packages/normalization-core/src/string-normalization.ts @@ -108,10 +108,12 @@ export function normalizeCsvOrLooseStringList(value: unknown): string[] { } function normalizeSlugInput(raw?: string | null) { + // NFC keeps visually identical composed/decomposed Unicode labels matching the + // same slug while preserving non-Latin channel and room names. return (normalizeOptionalLowercaseString(raw) ?? "").normalize("NFC"); } -/** Normalizes user-facing names into permissive lowercase hyphen slugs. */ +/** Normalizes user-facing names into permissive lowercase slugs that may keep #/@/._+. */ export function normalizeHyphenSlug(raw?: string | null) { const trimmed = normalizeSlugInput(raw); if (!trimmed) { @@ -122,7 +124,7 @@ export function normalizeHyphenSlug(raw?: string | null) { return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); } -/** Normalizes @/#-prefixed names into lowercase hyphen slugs without the prefix. */ +/** Normalizes @/#-prefixed channel names into strict lowercase hyphen slugs without the prefix. */ export function normalizeAtHashSlug(raw?: string | null) { const trimmed = normalizeSlugInput(raw); if (!trimmed) { diff --git a/src/auto-reply/command-status-builders.ts b/src/auto-reply/command-status-builders.ts index fb19db4c337..375c9d0f7fd 100644 --- a/src/auto-reply/command-status-builders.ts +++ b/src/auto-reply/command-status-builders.ts @@ -51,6 +51,7 @@ function groupCommandsByCategory( return grouped; } +/** Builds the compact slash-command help text shown by `/help`. */ export function buildHelpMessage(cfg?: OpenClawConfig): string { const lines = ["ℹ️ Help", ""]; @@ -90,12 +91,14 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string { const COMMANDS_PER_PAGE = 8; +/** Options for rendering `/commands` output for a specific channel surface. */ export type CommandsMessageOptions = { page?: number; surface?: string; forcePaginatedList?: boolean; }; +/** Rendered `/commands` text plus pagination metadata for channel-native lists. */ export type CommandsMessageResult = { text: string; totalPages: number; @@ -181,6 +184,7 @@ function formatCommandList(items: CommandsListItem[]): string { return lines.join("\n"); } +/** Builds `/commands` text, returning only the rendered message body. */ export function buildCommandsMessage( cfg?: OpenClawConfig, skillCommands?: SkillCommandSpec[], @@ -190,6 +194,7 @@ export function buildCommandsMessage( return result.text; } +/** Builds `/commands` text and pagination metadata for surfaces with native list controls. */ export function buildCommandsMessagePaginated( cfg?: OpenClawConfig, skillCommands?: SkillCommandSpec[], @@ -197,6 +202,7 @@ export function buildCommandsMessagePaginated( ): CommandsMessageResult { const page = Math.max(1, options?.page ?? 1); const surface = normalizeOptionalLowercaseString(options?.surface); + // Surfaces with native command-list UI need page metadata; plain text surfaces get one full list. const prefersPaginatedList = options?.forcePaginatedList === true || Boolean(surface && getChannelPlugin(surface)?.commands?.buildCommandsListChannelData); diff --git a/src/channels/message/adapter.ts b/src/channels/message/adapter.ts index da88984f45a..608a40778d7 100644 --- a/src/channels/message/adapter.ts +++ b/src/channels/message/adapter.ts @@ -16,6 +16,7 @@ type ChannelMessageAdapterWithDefaultReceive; }; +/** Defines a message adapter while defaulting receive acknowledgement to manual. */ export function defineChannelMessageAdapter( adapter: TAdapter, ): ChannelMessageAdapter> { diff --git a/src/channels/message/capabilities.ts b/src/channels/message/capabilities.ts index c40c454f997..5749f91048b 100644 --- a/src/channels/message/capabilities.ts +++ b/src/channels/message/capabilities.ts @@ -26,6 +26,7 @@ function setRequired( } } +/** Derives the adapter capabilities core needs before it can require durable final delivery. */ export function deriveDurableFinalDeliveryRequirements( params: DeriveDurableFinalDeliveryRequirementsParams, ): DurableFinalDeliveryRequirementMap { diff --git a/src/channels/message/durable-receive.ts b/src/channels/message/durable-receive.ts index fd43b772ecc..2580e49d2f5 100644 --- a/src/channels/message/durable-receive.ts +++ b/src/channels/message/durable-receive.ts @@ -1,6 +1,7 @@ import type { PluginStateKeyedStore } from "../../plugin-state/plugin-state-store.types.js"; import type { ChannelIngressQueue, ChannelIngressQueuePruneOptions } from "./ingress-queue.js"; +/** Pending inbound receive record kept until agent dispatch or durable send completes. */ export type DurableInboundReceivePendingRecord = { id: string; payload: TPayload; @@ -12,12 +13,14 @@ export type DurableInboundReceivePendingRecord = lastError?: string; }; +/** Completed inbound receive tombstone used to detect duplicate platform events. */ export type DurableInboundReceiveCompletedRecord = { id: string; completedAt: number; metadata?: TMetadata; }; +/** Accept result for a new or duplicate inbound platform event. */ export type DurableInboundReceiveAcceptResult = | { kind: "accepted"; @@ -35,6 +38,7 @@ export type DurableInboundReceiveAcceptResult; }; +/** Store-backed durable receive journal options. */ export type DurableInboundReceiveJournalOptions = { pendingStore: PluginStateKeyedStore>; completedStore: PluginStateKeyedStore>; @@ -43,21 +47,25 @@ export type DurableInboundReceiveJournalOptions = { metadata?: TMetadata; receivedAt?: number; }; +/** Options recorded when marking an inbound event complete. */ export type DurableInboundReceiveCompleteOptions = { metadata?: TCompletedMetadata; completedAt?: number; }; +/** Options recorded when releasing an inbound event for retry. */ export type DurableInboundReceiveReleaseOptions = { lastError?: string; releasedAt?: number; }; +/** Durable receive journal facade used by channel receive pipelines. */ export type DurableInboundReceiveJournal = { accept( id: string, @@ -73,6 +81,7 @@ export type DurableInboundReceiveJournal; }; +/** Queue-backed durable receive journal options with optional retention pruning. */ export type DurableInboundReceiveQueueJournalOptions = { queue: ChannelIngressQueue; retention?: ChannelIngressQueuePruneOptions; @@ -92,6 +101,7 @@ function sortPendingRecords( return records.toSorted((a, b) => a.receivedAt - b.receivedAt || a.id.localeCompare(b.id)); } +/** Creates a store-backed journal for accepting, completing, and retrying inbound events. */ export function createDurableInboundReceiveJournal< TPayload, TMetadata = unknown, @@ -127,6 +137,7 @@ export function createDurableInboundReceiveJournal< const acceptInsertedRecord = async (): Promise< DurableInboundReceiveAcceptResult > => { + // Completion can win the register race; remove the pending copy before reporting duplicate. const completedAfterInsertRace = await options.completedStore.lookup(key); if (completedAfterInsertRace) { await options.pendingStore.delete(key); @@ -229,6 +240,7 @@ export function createDurableInboundReceiveJournal< }; } +/** Adapts the shared channel ingress queue to the durable receive journal API. */ export function createDurableInboundReceiveJournalFromQueue< TPayload, TMetadata = unknown, diff --git a/src/channels/message/ingress-queue.ts b/src/channels/message/ingress-queue.ts index bfcc94087c6..0b39c7fb8fc 100644 --- a/src/channels/message/ingress-queue.ts +++ b/src/channels/message/ingress-queue.ts @@ -15,6 +15,7 @@ import { runOpenClawStateWriteTransaction, } from "../../state/openclaw-state-db.js"; +/** Pending or retryable inbound channel event stored in the durable ingress queue. */ export type ChannelIngressQueueRecord = { id: string; channelId: string; @@ -30,6 +31,7 @@ export type ChannelIngressQueueRecord = { lastError?: string; }; +/** Pending ingress event currently claimed by a worker. */ export type ChannelIngressQueueClaim = ChannelIngressQueueRecord< TPayload, TMetadata @@ -41,6 +43,7 @@ export type ChannelIngressQueueClaim = ChannelIng }; }; +/** Minimal claim reference used to guard completion/release/failure with a claim token. */ export type ChannelIngressQueueClaimRef = { id: string; claim: { @@ -48,6 +51,7 @@ export type ChannelIngressQueueClaimRef = { }; }; +/** Completed ingress event tombstone retained for duplicate detection. */ export type ChannelIngressQueueCompletedRecord = { id: string; channelId: string; @@ -57,6 +61,7 @@ export type ChannelIngressQueueCompletedRecord = { metadata?: TCompletedMetadata; }; +/** Failed ingress event tombstone retained for duplicate detection and diagnostics. */ export type ChannelIngressQueueFailedRecord = { id: string; channelId: string; @@ -67,6 +72,7 @@ export type ChannelIngressQueueFailedRecord = { message?: string; }; +/** Retention options for pending, completed, and failed ingress queue rows. */ export type ChannelIngressQueuePruneOptions = { pendingTtlMs?: number; completedTtlMs?: number; @@ -78,6 +84,7 @@ export type ChannelIngressQueuePruneOptions = { now?: number; }; +/** Result of enqueueing a possibly duplicate ingress event id. */ export type ChannelIngressQueueEnqueueResult = | { kind: "accepted"; @@ -105,6 +112,7 @@ export type ChannelIngressQueueEnqueueResult = { enqueue( id: string, @@ -157,6 +165,7 @@ export type ChannelIngressQueue; }; +/** Construction options for a channel/account-scoped ingress queue. */ export type CreateChannelIngressQueueOptions = { channelId: string; accountId?: string; @@ -303,9 +312,11 @@ function normalizedProtectedIds(ids: Iterable | undefined): string[] { } function queueNameForParts(channelId: string, accountId: string): string { + // JSON tuple encoding keeps channel/account scopes unambiguous even when ids contain separators. return JSON.stringify([channelId, accountId]); } +/** Creates a durable channel/account-scoped ingress queue backed by the OpenClaw state database. */ export function createChannelIngressQueue< TPayload, TMetadata = unknown, diff --git a/src/channels/message/live.ts b/src/channels/message/live.ts index 8ad0859c4e2..2a555f52a77 100644 --- a/src/channels/message/live.ts +++ b/src/channels/message/live.ts @@ -1,6 +1,7 @@ import type { LiveMessageState, MessageReceipt, RenderedMessageBatch } from "./types.js"; export type { LiveMessagePhase, LiveMessageState } from "./types.js"; +/** Mutable draft preview handle used before a live message is finalized or discarded. */ export type LivePreviewFinalizerDraft = { flush: () => Promise; id: () => TId | undefined; @@ -9,17 +10,20 @@ export type LivePreviewFinalizerDraft = { clear: () => Promise; }; +/** Outcome kind returned after attempting to finalize or fall back from a live preview. */ export type LivePreviewFinalizerResultKind = | "normal-delivered" | "normal-skipped" | "preview-finalized" | "preview-retained"; +/** Result of a live preview finalization attempt plus the latest live state. */ export type LivePreviewFinalizerResult = { kind: LivePreviewFinalizerResultKind; liveState?: LiveMessageState; }; +/** Adapter contract for channels that can edit a draft preview into the final message. */ export type FinalizableLivePreviewAdapter = { draft?: LivePreviewFinalizerDraft; buildFinalEdit: (payload: TPayload) => TEdit | undefined; @@ -43,12 +47,14 @@ export type FinalizableLivePreviewAdapter = { logPreviewEditFailure?: (error: unknown) => void; }; +/** Defines a finalizable live-preview adapter while preserving its generic payload/id/edit types. */ export function defineFinalizableLivePreviewAdapter( adapter: FinalizableLivePreviewAdapter, ): FinalizableLivePreviewAdapter { return adapter; } +/** Creates the initial live-message state, optionally seeded with an existing preview receipt. */ export function createLiveMessageState(params?: { receipt?: MessageReceipt; lastRendered?: RenderedMessageBatch; @@ -62,6 +68,7 @@ export function createLiveMessageState(params?: { }; } +/** Marks a live message as finalized and disables further in-place preview edits. */ export function markLiveMessageFinalized( state: LiveMessageState, receipt: MessageReceipt, @@ -74,6 +81,7 @@ export function markLiveMessageFinalized( }; } +/** Creates a receipt for a draft/preview platform message. */ export function createPreviewMessageReceipt(params: { id: unknown; threadId?: string; @@ -101,6 +109,7 @@ export function createPreviewMessageReceipt(params: { }; } +/** Finalizes a live preview in place when possible, otherwise falls back to normal delivery. */ export async function deliverFinalizableLivePreview(params: { kind: "tool" | "block" | "final"; payload: TPayload; @@ -153,6 +162,7 @@ export async function deliverFinalizableLivePreview(params editSucceeded = true; } catch (err) { params.logPreviewEditFailure?.(err); + // Ambiguous preview edit failures can keep the preview as the visible final state. const decision = (await params.handlePreviewEditError?.({ error: err, @@ -214,6 +224,7 @@ export async function deliverFinalizableLivePreview(params return { kind: delivered ? "normal-delivered" : "normal-skipped", liveState }; } +/** Runs live-preview finalization through an optional adapter, falling back to normal delivery. */ export async function deliverWithFinalizableLivePreviewAdapter(params: { kind: "tool" | "block" | "final"; payload: TPayload; @@ -265,6 +276,7 @@ export async function deliverWithFinalizableLivePreviewAdapter( state: LiveMessageState, rendered: RenderedMessageBatch, @@ -276,6 +288,7 @@ export function markLiveMessagePreviewUpdated( }; } +/** Marks a live message cancelled and prevents later in-place finalization. */ export function markLiveMessageCancelled( state: LiveMessageState, ): LiveMessageState { diff --git a/src/channels/message/outbound-bridge.ts b/src/channels/message/outbound-bridge.ts index d169adbe516..dfc6ac19889 100644 --- a/src/channels/message/outbound-bridge.ts +++ b/src/channels/message/outbound-bridge.ts @@ -19,11 +19,13 @@ const defaultManualReceiveAdapter = { supportedAckPolicies: ["manual"], } as const satisfies ChannelMessageReceiveAdapterShape; +/** Send result accepted from legacy outbound bridge methods before receipt normalization. */ export type ChannelMessageOutboundBridgeResult = MessageReceiptSourceResult & { receipt?: MessageReceipt; messageId?: string; }; +/** Legacy outbound adapter shape bridged into the channel message adapter contract. */ export type ChannelMessageOutboundBridgeAdapter = { deliveryCapabilities?: { durableFinal?: DurableFinalDeliveryRequirementMap; @@ -42,6 +44,7 @@ export type ChannelMessageOutboundBridgeAdapter = { ) => Promise; }; +/** Options for building a message adapter from legacy outbound send functions. */ export type CreateChannelMessageAdapterFromOutboundParams = { id?: string; outbound: ChannelMessageOutboundBridgeAdapter; @@ -117,6 +120,7 @@ function resolvePayloadReceiptKind( return "unknown"; } +/** Converts legacy outbound send methods into a typed channel message adapter. */ export function createChannelMessageAdapterFromOutbound( params: CreateChannelMessageAdapterFromOutboundParams, ): ChannelMessageAdapterShape { diff --git a/src/channels/message/receipt.ts b/src/channels/message/receipt.ts index 657bbe9269f..e1fd23bfe2b 100644 --- a/src/channels/message/receipt.ts +++ b/src/channels/message/receipt.ts @@ -37,6 +37,7 @@ function appendUnique(values: string[], value: string | undefined): void { } } +/** Builds one normalized receipt from platform send results or nested adapter receipts. */ export function createMessageReceiptFromOutboundResults(params: { results: readonly MessageReceiptInputResult[]; kind?: MessageReceiptPartKind; @@ -46,6 +47,7 @@ export function createMessageReceiptFromOutboundResults(params: { }): MessageReceipt { const parts = params.results.flatMap((result, resultIndex) => { if (hasNestedReceiptData(result.receipt)) { + // Preserve adapter-supplied receipt parts first; only fill missing thread/reply metadata. return result.receipt.parts.length > 0 ? result.receipt.parts.map((part, partIndex) => ({ ...part, @@ -108,10 +110,12 @@ export function createMessageReceiptFromOutboundResults(params: { }; } +/** Lists unique platform message ids in receipt order. */ export function listMessageReceiptPlatformIds(receipt: MessageReceipt): string[] { return normalizeUniqueStringEntries(receipt.platformMessageIds); } +/** Resolves the explicit primary platform id, falling back to the first unique receipt id. */ export function resolveMessageReceiptPrimaryId(receipt: MessageReceipt): string | undefined { const primary = receipt.primaryPlatformMessageId?.trim(); if (primary) { diff --git a/src/channels/message/receive.ts b/src/channels/message/receive.ts index 2bf4849bce2..d6677f56924 100644 --- a/src/channels/message/receive.ts +++ b/src/channels/message/receive.ts @@ -1,11 +1,15 @@ import type { ChannelMessageReceiveAckPolicy } from "./types.js"; +/** Public alias for channel receive acknowledgement policy names. */ export type MessageAckPolicy = ChannelMessageReceiveAckPolicy; +/** Processing stage where a durable inbound message may be acknowledged. */ export type MessageAckStage = "receive_record" | "agent_dispatch" | "durable_send" | "manual"; +/** Current acknowledgement state for one inbound message context. */ export type MessageAckState = "pending" | "acked" | "nacked"; +/** Mutable receive context passed through durable inbound message processing. */ export type MessageReceiveContext = { id: string; channel: string; @@ -24,6 +28,7 @@ export type MessageReceiveContext = { const neverAbortedSignal = new AbortController().signal; +/** Returns whether an ack policy should acknowledge at the supplied processing stage. */ export function shouldAckMessageAfterStage( policy: MessageAckPolicy, stage: MessageAckStage, @@ -45,6 +50,7 @@ function normalizeAckErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** Creates a receive context with idempotent ack and explicit nack state transitions. */ export function createMessageReceiveContext(params: { id: string; channel: string; @@ -67,6 +73,7 @@ export function createMessageReceiveContext(params: { signal: params.signal ?? neverAbortedSignal, shouldAckAfter: (stage) => shouldAckMessageAfterStage(ctx.ackPolicy, stage), ack: async () => { + // Ack callbacks must be idempotent because receive pipelines may revisit completed stages. if (ctx.ackState === "acked") { return; } diff --git a/src/channels/message/rendered-batch.ts b/src/channels/message/rendered-batch.ts index 18416077b9c..844bbb17aa0 100644 --- a/src/channels/message/rendered-batch.ts +++ b/src/channels/message/rendered-batch.ts @@ -51,6 +51,7 @@ function createRenderedMessageBatchPlanItem( }; } +/** Summarizes rendered reply payloads so delivery can choose adapter paths and recovery metadata. */ export function createRenderedMessageBatchPlan( payloads: readonly ReplyPayload[], ): RenderedMessageBatchPlan { @@ -83,6 +84,7 @@ export function createRenderedMessageBatchPlan( ); } +/** Pairs reply payloads with their render plan for durable send and live-preview flows. */ export function createRenderedMessageBatch( payloads: ReplyPayload[], ): RenderedMessageBatch { diff --git a/src/channels/message/reply-pipeline.ts b/src/channels/message/reply-pipeline.ts index 047b2af56c9..a6c60efd9ff 100644 --- a/src/channels/message/reply-pipeline.ts +++ b/src/channels/message/reply-pipeline.ts @@ -25,6 +25,7 @@ export type { CreateTypingCallbacksParams, TypingCallbacks }; export { createReplyPrefixContext, createReplyPrefixOptions, createTypingCallbacks }; export type { SourceReplyDeliveryMode }; +/** Resolves whether a channel reply should use source delivery, message tools, or direct sending. */ export function resolveChannelSourceReplyDeliveryMode(params: { cfg: OpenClawConfig; ctx: SourceReplyDeliveryModeContext; @@ -34,11 +35,13 @@ export function resolveChannelSourceReplyDeliveryMode(params: { return resolveSourceReplyDeliveryMode(params); } +/** Reply pipeline options shared by core channel turns and plugin SDK callers. */ export type ChannelReplyPipeline = ReplyPrefixOptions & { typingCallbacks?: TypingCallbacks; transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; }; +/** Parameters for building a channel reply pipeline with prefix, typing, and payload transforms. */ export type CreateChannelReplyPipelineParams = { cfg: Parameters[0]["cfg"]; agentId: string; @@ -49,6 +52,7 @@ export type CreateChannelReplyPipelineParams = { transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; }; +/** Builds the reply pipeline used by channel turns and plugin SDK reply helpers. */ export function createChannelReplyPipeline( params: CreateChannelReplyPipelineParams, ): ChannelReplyPipeline { @@ -58,6 +62,7 @@ export function createChannelReplyPipeline( let plugin: ReturnType | undefined; let pluginTransformResolved = false; const resolvePluginTransform = () => { + // Load the channel plugin lazily so reply-pipeline construction stays cheap for hot turn paths. if (pluginTransformResolved) { return plugin?.messaging?.transformReplyPayload; } diff --git a/src/channels/message/state.ts b/src/channels/message/state.ts index da247b3ed64..8bc46c965a8 100644 --- a/src/channels/message/state.ts +++ b/src/channels/message/state.ts @@ -1,5 +1,6 @@ import type { DurableMessageSendIntent, MessageReceipt } from "./types.js"; +/** Durable send state stored for recovery and operator-visible delivery status. */ export type DurableMessageSendState = | "pending" | "sent" @@ -7,6 +8,7 @@ export type DurableMessageSendState = | "failed" | "unknown_after_send"; +/** Recovery record for one durable outbound message intent. */ export type DurableMessageStateRecord = { intent: DurableMessageSendIntent; state: DurableMessageSendState; @@ -15,6 +17,7 @@ export type DurableMessageStateRecord = { errorMessage?: string; }; +/** Creates a durable message recovery record from intent, receipt, and optional error state. */ export function createDurableMessageStateRecord(params: { intent: DurableMessageSendIntent; state?: DurableMessageSendState; @@ -31,6 +34,7 @@ export function createDurableMessageStateRecord(params: { }; } +/** Classifies recovery state from persisted intent/receipt facts after a send interruption. */ export function classifyDurableSendRecoveryState(params: { hasIntent: boolean; hasReceipt: boolean; diff --git a/src/channels/message/types.ts b/src/channels/message/types.ts index 2e7e6176ba3..91bd556994b 100644 --- a/src/channels/message/types.ts +++ b/src/channels/message/types.ts @@ -5,8 +5,10 @@ import type { OutboundSendDeps } from "../../infra/outbound/send-deps.js"; import type { OutboundMediaAccess } from "../../media/load-options.js"; import type { PollInput } from "../../polls.js"; +/** Delivery durability requested by core when a channel sends agent output. */ export type MessageDurabilityPolicy = "required" | "best_effort" | "disabled"; +/** Capability names a channel must advertise before core can rely on durable final delivery. */ export const durableFinalDeliveryCapabilities = [ "text", "media", @@ -23,12 +25,15 @@ export const durableFinalDeliveryCapabilities = [ "afterCommit", ] as const; +/** Durable final delivery capability key understood by message-channel adapters. */ export type DurableFinalDeliveryCapability = (typeof durableFinalDeliveryCapabilities)[number]; +/** Capability map used by adapters to declare which final-send guarantees they support. */ export type DurableFinalDeliveryRequirementMap = Partial< Record >; +/** Minimal payload facts used to derive required durable-delivery capabilities. */ export type DurableFinalDeliveryPayloadShape = { text?: string | null; replyToId?: string | null; @@ -36,6 +41,7 @@ export type DurableFinalDeliveryPayloadShape = { mediaUrls?: readonly (string | null | undefined)[] | null; }; +/** Raw platform result shape normalized into a message receipt. */ export type MessageReceiptSourceResult = { channel?: string; messageId?: string; @@ -49,6 +55,7 @@ export type MessageReceiptSourceResult = { meta?: Record; }; +/** Logical part kind for multi-part rendered messages. */ export type MessageReceiptPartKind = | "text" | "media" @@ -58,6 +65,7 @@ export type MessageReceiptPartKind = | "preview" | "unknown"; +/** One platform message produced by a logical outbound send. */ export type MessageReceiptPart = { platformMessageId: string; kind: MessageReceiptPartKind; @@ -67,6 +75,7 @@ export type MessageReceiptPart = { raw?: MessageReceiptSourceResult; }; +/** Normalized receipt for all platform messages that make up a logical send. */ export type MessageReceipt = { primaryPlatformMessageId?: string; platformMessageIds: string[]; @@ -79,6 +88,7 @@ export type MessageReceipt = { raw?: readonly MessageReceiptSourceResult[]; }; +/** Render-plan item category used before adapter-specific send execution. */ export type RenderedMessageBatchPlanKind = | "text" | "media" @@ -88,6 +98,7 @@ export type RenderedMessageBatchPlanKind = | "channelData" | "empty"; +/** Render plan for a single reply payload after text/media/presentation splitting. */ export type RenderedMessageBatchPlanItem = { index: number; kinds: readonly RenderedMessageBatchPlanKind[]; @@ -99,6 +110,7 @@ export type RenderedMessageBatchPlanItem = { hasChannelData?: boolean; }; +/** Aggregate render plan for a batch of reply payloads. */ export type RenderedMessageBatchPlan = { payloadCount: number; textCount: number; @@ -110,13 +122,16 @@ export type RenderedMessageBatchPlan = { items: readonly RenderedMessageBatchPlanItem[]; }; +/** Rendered payload batch paired with the plan core uses for send routing and recovery. */ export type RenderedMessageBatch = { payloads: TPayload[]; plan: RenderedMessageBatchPlan; }; +/** Lifecycle phase for a live preview or streaming message send. */ export type LiveMessagePhase = "idle" | "previewing" | "finalizing" | "finalized" | "cancelled"; +/** Mutable state snapshot for live preview/finalization flows. */ export type LiveMessageState = { phase: LiveMessagePhase; canFinalizeInPlace: boolean; @@ -124,6 +139,7 @@ export type LiveMessageState = { lastRendered?: RenderedMessageBatch; }; +/** Durable send context passed through render, preview, send, edit, commit, and failure steps. */ export type MessageSendContext = { id: string; channel: string; @@ -144,6 +160,7 @@ export type MessageSendContext = { fail(error: unknown): Promise; }; +/** Common text-send context shared by text, media, payload, and poll adapter calls. */ export type ChannelMessageSendTextContext = { cfg: TConfig; to: string; @@ -159,6 +176,7 @@ export type ChannelMessageSendTextContext = { gatewayClientScopes?: readonly string[]; }; +/** Media send context with validated access hooks and media presentation hints. */ export type ChannelMessageSendMediaContext = ChannelMessageSendTextContext & { mediaUrl: string; @@ -170,6 +188,7 @@ export type ChannelMessageSendMediaContext = forceDocument?: boolean; }; +/** Rich reply payload send context used when adapters can consume structured payloads. */ export type ChannelMessageSendPayloadContext = ChannelMessageSendTextContext & { payload: ReplyPayload; @@ -182,6 +201,7 @@ export type ChannelMessageSendPayloadContext = forceDocument?: boolean; }; +/** Poll send context; thread ids stay string-like because poll APIs do not accept numeric ids. */ export type ChannelMessageSendPollContext = Omit< ChannelMessageSendTextContext, "text" | "threadId" @@ -191,19 +211,23 @@ export type ChannelMessageSendPollContext = Omit< isAnonymous?: boolean; }; +/** Adapter send result normalized to a receipt plus optional legacy message id. */ export type ChannelMessageSendResult = { receipt: MessageReceipt; messageId?: string; }; +/** Discriminator for lifecycle hooks around a concrete adapter send attempt. */ export type ChannelMessageSendAttemptKind = "text" | "media" | "payload" | "poll"; +/** Send-attempt context tagged with the adapter method core is about to call. */ export type ChannelMessageSendAttemptContext = | (ChannelMessageSendTextContext & { kind: "text" }) | (ChannelMessageSendMediaContext & { kind: "media" }) | (ChannelMessageSendPayloadContext & { kind: "payload" }) | (ChannelMessageSendPollContext & { kind: "poll" }); +/** Lifecycle context emitted after an adapter send succeeds but before commit finishes. */ export type ChannelMessageSendSuccessContext< TConfig = OpenClawConfig, TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, @@ -212,17 +236,20 @@ export type ChannelMessageSendSuccessContext< attemptToken?: unknown; }; +/** Lifecycle context emitted after an adapter send throws or rejects. */ export type ChannelMessageSendFailureContext = ChannelMessageSendAttemptContext & { error: unknown; attemptToken?: unknown; }; +/** Lifecycle context emitted when a successful send is being durably committed. */ export type ChannelMessageSendCommitContext< TConfig = OpenClawConfig, TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, > = ChannelMessageSendSuccessContext; +/** Durable queue context used to reconcile a send whose platform state is unknown. */ export type ChannelMessageUnknownSendContext = { cfg: TConfig; queueId: string; @@ -240,6 +267,7 @@ export type ChannelMessageUnknownSendContext = { silent?: boolean; }; +/** Adapter verdict for whether an unknown queued send reached the platform. */ export type ChannelMessageUnknownSendReconciliationResult = | { status: "sent"; @@ -255,6 +283,7 @@ export type ChannelMessageUnknownSendReconciliationResult = retryable?: boolean; }; +/** Optional hooks around adapter send attempts, platform success/failure, and commit. */ export type ChannelMessageSendLifecycleAdapter< TConfig = OpenClawConfig, TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, @@ -269,6 +298,7 @@ export type ChannelMessageSendLifecycleAdapter< ) => Promise | void; }; +/** Adapter methods a message channel can implement for outbound text/media/payload/poll sends. */ export type ChannelMessageSendAdapter< TConfig = OpenClawConfig, TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, @@ -280,6 +310,7 @@ export type ChannelMessageSendAdapter< lifecycle?: ChannelMessageSendLifecycleAdapter; }; +/** Durable final-delivery extension for queue reconciliation and capability declaration. */ export type ChannelMessageDurableFinalAdapter = { capabilities?: DurableFinalDeliveryRequirementMap; reconcileUnknownSend?: ( @@ -290,6 +321,7 @@ export type ChannelMessageDurableFinalAdapter = { | null; }; +/** Live-message feature key declared by adapters that support preview or streaming behavior. */ export type ChannelMessageLiveCapability = | "draftPreview" | "previewFinalization" @@ -297,6 +329,7 @@ export type ChannelMessageLiveCapability = | "nativeStreaming" | "quietFinalization"; +/** Canonical ordered list of live-message feature keys. */ export const channelMessageLiveCapabilities = [ "draftPreview", "previewFinalization", @@ -305,6 +338,7 @@ export const channelMessageLiveCapabilities = [ "quietFinalization", ] as const satisfies readonly ChannelMessageLiveCapability[]; +/** Capability keys for turning a preview into a final platform message. */ export const livePreviewFinalizerCapabilities = [ "finalEdit", "normalFallback", @@ -313,27 +347,33 @@ export const livePreviewFinalizerCapabilities = [ "retainOnAmbiguousFailure", ] as const; +/** Finalizer capability key understood by live-message adapters. */ export type LivePreviewFinalizerCapability = (typeof livePreviewFinalizerCapabilities)[number]; +/** Capability map for preview finalization behavior. */ export type LivePreviewFinalizerCapabilityMap = Partial< Record >; +/** Adapter shape for finalizing live previews. */ export type ChannelMessageLiveFinalizerAdapterShape = { capabilities?: LivePreviewFinalizerCapabilityMap; }; +/** Adapter shape for live preview and streaming message features. */ export type ChannelMessageLiveAdapterShape = { capabilities?: Partial>; finalizer?: ChannelMessageLiveFinalizerAdapterShape; }; +/** Receive acknowledgement timing policy for durable inbound message records. */ export type ChannelMessageReceiveAckPolicy = | "after_receive_record" | "after_agent_dispatch" | "after_durable_send" | "manual"; +/** Canonical ordered list of receive acknowledgement policies. */ export const channelMessageReceiveAckPolicies = [ "after_receive_record", "after_agent_dispatch", @@ -341,11 +381,13 @@ export const channelMessageReceiveAckPolicies = [ "manual", ] as const satisfies readonly ChannelMessageReceiveAckPolicy[]; +/** Adapter receive shape for default and supported inbound acknowledgement policies. */ export type ChannelMessageReceiveAdapterShape = { defaultAckPolicy?: ChannelMessageReceiveAckPolicy; supportedAckPolicies?: readonly ChannelMessageReceiveAckPolicy[]; }; +/** Full message adapter shape composed from send, durable-final, live, and receive facets. */ export type ChannelMessageAdapterShape< TConfig = OpenClawConfig, TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult, @@ -357,12 +399,15 @@ export type ChannelMessageAdapterShape< receive?: ChannelMessageReceiveAdapterShape; }; +/** Concrete message adapter type, preserving channel-specific adapter refinements. */ export type ChannelMessageAdapter< TAdapter extends ChannelMessageAdapterShape = ChannelMessageAdapterShape, > = TAdapter; +/** Back-compat alias for callers that derive extra durable final requirements. */ export type DurableFinalRequirementExtras = DurableFinalDeliveryRequirementMap; +/** Inputs used to derive durable final-delivery requirements for a planned send. */ export type DeriveDurableFinalDeliveryRequirementsParams = { payload: DurableFinalDeliveryPayloadShape; replyToId?: string | null; @@ -377,6 +422,7 @@ export type DeriveDurableFinalDeliveryRequirementsParams = { extraCapabilities?: DurableFinalRequirementExtras; }; +/** Stable intent record for a durable outbound message send. */ export type DurableMessageSendIntent = { id: string; channel: string; diff --git a/src/channels/turn/bot-loop-protection.ts b/src/channels/turn/bot-loop-protection.ts index bac6c69431d..c5826bea8bc 100644 --- a/src/channels/turn/bot-loop-protection.ts +++ b/src/channels/turn/bot-loop-protection.ts @@ -6,6 +6,7 @@ import { type PairLoopGuardSnapshotEntry, } from "../../plugin-sdk/pair-loop-guard-runtime.js"; +/** Facts used to detect repeated bot-to-bot channel reply loops. */ export type ChannelBotLoopProtectionFacts = { scopeId: string; conversationId: string; @@ -19,6 +20,7 @@ export type ChannelBotLoopProtectionFacts = { const channelBotPairLoopGuard = createPairLoopGuard({ pruneIntervalMs: 60_000 }); +/** Records a bot pair interaction and returns whether the loop guard should suppress it. */ export function recordChannelBotPairLoopAndCheckSuppression( params: ChannelBotLoopProtectionFacts, ): PairLoopGuardResult { @@ -36,10 +38,12 @@ export function recordChannelBotPairLoopAndCheckSuppression( }); } +/** Clears channel bot-loop state for isolated tests. */ export function clearChannelBotPairLoopGuardForTests(): void { channelBotPairLoopGuard.clear(); } +/** Lists tracked bot-loop pairs for isolated tests. */ export function listTrackedChannelBotPairsForTests(): PairLoopGuardSnapshotEntry[] { return channelBotPairLoopGuard.snapshot(); } diff --git a/src/channels/turn/delivery-result.ts b/src/channels/turn/delivery-result.ts index df3a4a27d23..e3827d0b75f 100644 --- a/src/channels/turn/delivery-result.ts +++ b/src/channels/turn/delivery-result.ts @@ -2,6 +2,7 @@ import { listMessageReceiptPlatformIds } from "../message/receipt.js"; import type { MessageReceipt } from "../message/types.js"; import type { ChannelDeliveryIntent, ChannelDeliveryResult } from "./types.js"; +/** Converts a normalized message receipt into the delivery result shape used by channel turns. */ export function createChannelDeliveryResultFromReceipt(params: { receipt: MessageReceipt; threadId?: string; diff --git a/src/channels/turn/dispatch-result.ts b/src/channels/turn/dispatch-result.ts index 4be1d37b117..d69c86109cd 100644 --- a/src/channels/turn/dispatch-result.ts +++ b/src/channels/turn/dispatch-result.ts @@ -1,5 +1,6 @@ import type { ReplyDispatchKind } from "../../auto-reply/reply/reply-dispatcher.types.js"; +/** Minimal dispatch result shape needed to count visible channel deliveries. */ export type ChannelTurnDispatchResultLike = | { queuedFinal?: boolean; @@ -8,18 +9,21 @@ export type ChannelTurnDispatchResultLike = | null | undefined; +/** Extra delivery signals observed outside the normal dispatch count payload. */ export type ChannelTurnVisibleDeliverySignals = { observedReplyDelivery?: boolean; fallbackDelivered?: boolean; deliverySummaryDelivered?: boolean; }; +/** Zero-filled reply dispatch count map used before merging optional provider counts. */ export const EMPTY_CHANNEL_TURN_DISPATCH_COUNTS: Record = { tool: 0, block: 0, final: 0, }; +/** Resolves dispatch counts with missing reply kinds filled as zero. */ export function resolveChannelTurnDispatchCounts( result: ChannelTurnDispatchResultLike, ): Record { @@ -29,11 +33,13 @@ export function resolveChannelTurnDispatchCounts( }; } +/** Returns whether a turn produced any visible reply delivery signal. */ export function hasVisibleChannelTurnDispatch( result: ChannelTurnDispatchResultLike, signals: ChannelTurnVisibleDeliverySignals = {}, ): boolean { const counts = resolveChannelTurnDispatchCounts(result); + // Non-count signals cover delivery paths that bypass the buffered reply dispatcher. return ( signals.observedReplyDelivery === true || signals.fallbackDelivered === true || @@ -45,6 +51,7 @@ export function hasVisibleChannelTurnDispatch( ); } +/** Returns whether a turn produced a final reply, fallback, summary, or queued final payload. */ export function hasFinalChannelTurnDispatch( result: ChannelTurnDispatchResultLike, signals: Pick< diff --git a/src/channels/turn/durable-delivery.ts b/src/channels/turn/durable-delivery.ts index 1096f9f7e22..abae9ff2628 100644 --- a/src/channels/turn/durable-delivery.ts +++ b/src/channels/turn/durable-delivery.ts @@ -16,6 +16,7 @@ import { sendDurableMessageBatch } from "../message/send.js"; import { createChannelDeliveryResultFromReceipt } from "./delivery-result.js"; import type { ChannelDeliveryInfo, ChannelDeliveryResult } from "./types.js"; +/** Options controlling durable final delivery for inbound channel replies. */ export type DurableInboundReplyDeliveryOptions = Pick< DeliverOutboundPayloadsParams, "deps" | "formatting" | "identity" | "mediaAccess" | "replyToMode" | "silent" | "threadId" @@ -25,6 +26,7 @@ export type DurableInboundReplyDeliveryOptions = Pick< requiredCapabilities?: DurableFinalDeliveryRequirements; }; +/** Full context required to deliver one inbound final reply through durable message sending. */ export type DurableInboundReplyDeliveryParams = DurableInboundReplyDeliveryOptions & { cfg: OpenClawConfig; channel: string; @@ -35,6 +37,7 @@ export type DurableInboundReplyDeliveryParams = DurableInboundReplyDeliveryOptio info: ChannelDeliveryInfo; }; +/** Outcome of attempting durable final delivery for an inbound reply payload. */ export type DurableInboundReplyDeliveryResult = | { status: "not_applicable"; reason: "non_final" } | { @@ -61,6 +64,7 @@ function resolveDeliveryTarget(params: DurableInboundReplyDeliveryParams): strin export function resolveDurableInboundReplyToId( params: Pick, ): string | null | undefined { + // Explicit null means "do not reply to a source message"; do not fall back to context ids. if (params.replyToId === null || params.payload.replyToId === null) { return null; } @@ -93,6 +97,7 @@ function toDeliveryIntent(intent: OutboundDeliveryIntent): ChannelDeliveryResult }; } +/** Narrows durable delivery results that handled the payload without caller fallback. */ export function isDurableInboundReplyDeliveryHandled( result: DurableInboundReplyDeliveryResult, ): result is Extract< @@ -102,6 +107,7 @@ export function isDurableInboundReplyDeliveryHandled( return result.status === "handled_visible" || result.status === "handled_no_send"; } +/** Throws failed durable delivery results, preserving visible-send metadata when applicable. */ export function throwIfDurableInboundReplyDeliveryFailed( result: DurableInboundReplyDeliveryResult, ): void { @@ -113,6 +119,7 @@ export function throwIfDurableInboundReplyDeliveryFailed( } function markDurableInboundReplyDeliveryErrorVisible(error: unknown): unknown { + // Partial durable sends must suppress duplicate fallback delivery while still surfacing failure. if (typeof error === "object" && error !== null && Object.isExtensible(error)) { Object.assign(error, { sentBeforeError: true, visibleReplySent: true }); return error; @@ -123,6 +130,7 @@ function markDurableInboundReplyDeliveryErrorVisible(error: unknown): unknown { return visibleError; } +/** Delivers final inbound replies through the durable message-send context when supported. */ export async function deliverInboundReplyWithMessageSendContext( params: DurableInboundReplyDeliveryParams, ): Promise { diff --git a/src/channels/turn/history-window.ts b/src/channels/turn/history-window.ts index d579b277d16..55a8c76d5f1 100644 --- a/src/channels/turn/history-window.ts +++ b/src/channels/turn/history-window.ts @@ -9,6 +9,7 @@ import type { HistoryEntry, HistoryMediaEntry } from "../../auto-reply/reply/his type MaybePromise = T | Promise; +/** Windowed channel history facade used by turn adapters to record and render recent context. */ export type ChannelHistoryWindow = { record: (params: { historyKey: string; entry?: T | null; limit: number }) => T[]; recordWithMedia: (params: { @@ -37,6 +38,7 @@ export type ChannelHistoryWindow = { clear: (params: { historyKey: string; limit: number }) => void; }; +/** Creates a bounded channel history window over a caller-owned history map. */ export function createChannelHistoryWindow(params: { historyMap: Map; }): ChannelHistoryWindow { diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts index 97a000b06cf..868ddafc6ac 100644 --- a/src/channels/turn/types.ts +++ b/src/channels/turn/types.ts @@ -23,18 +23,21 @@ import type { ChannelBotLoopProtectionFacts } from "./bot-loop-protection.js"; export type { InboundEventKind } from "../inbound-event/kind.js"; +/** Admission decision for an inbound channel event before agent dispatch. */ export type ChannelTurnAdmission = | { kind: "dispatch"; reason?: string } | { kind: "observeOnly"; reason: string } | { kind: "handled"; reason: string } | { kind: "drop"; reason: string; recordHistory?: boolean }; +/** Coarse event classification used to decide whether an event can start an agent turn. */ export type ChannelEventClass = { kind: "message" | "command" | "interaction" | "reaction" | "lifecycle" | "unknown"; canStartAgentTurn: boolean; requiresImmediateAck?: boolean; }; +/** Normalized inbound event text and raw payload after channel-specific ingestion. */ export type NormalizedTurnInput = { id: string; timestamp?: number; @@ -44,6 +47,7 @@ export type NormalizedTurnInput = { raw?: unknown; }; +/** Sender identity facts projected into channel access, routing, and prompt context. */ export type SenderFacts = { id?: string; name?: string; @@ -55,6 +59,7 @@ export type SenderFacts = { displayLabel?: string; }; +/** Conversation identity and threading facts for a channel turn. */ export type ConversationFacts = { kind: "direct" | "group" | "channel"; id: string; @@ -69,6 +74,7 @@ export type ConversationFacts = { }; }; +/** Session routing facts derived before dispatch. */ export type RouteFacts = { agentId: string; accountId?: string; @@ -81,6 +87,7 @@ export type RouteFacts = { createIfMissing?: boolean; }; +/** Reply target and source-delivery facts for a channel turn. */ export type ReplyPlanFacts = { to: string; originatingTo?: string; @@ -94,6 +101,7 @@ export type ReplyPlanFacts = { sourceReplyDeliveryMode?: "thread" | "reply" | "channel" | "direct" | "none"; }; +/** Allowlist projection used by access checks without exposing raw configured entries. */ export type ProjectedAllowlistAccessFacts = { configured: boolean; matched: boolean; @@ -110,6 +118,7 @@ export type ProjectedAllowlistAccessFacts = { }; }; +/** Event-level access projection for commands, reactions, buttons, and native events. */ export type ProjectedEventAccessFacts = { kind: | "message" @@ -127,6 +136,7 @@ export type ProjectedEventAccessFacts = { originSubjectMatched: boolean; }; +/** Access decisions for DMs, groups, commands, events, and mention gating. */ export type AccessFacts = { dm?: { decision: "allow" | "pairing" | "deny"; @@ -177,6 +187,7 @@ export type AccessFacts = { }; }; +/** Message text/history facts passed into templating and dispatch. */ export type MessageFacts = { inboundEventKind?: InboundEventKind; body?: string; @@ -189,6 +200,7 @@ export type MessageFacts = { inboundHistory?: HistoryEntry[]; }; +/** Parsed command facts for command-like channel turns. */ export type CommandFacts = { kind: CommandTurnKind; body?: string; @@ -196,6 +208,7 @@ export type CommandFacts = { authorized?: boolean; }; +/** Quoted, forwarded, thread, and untrusted context facts attached to an inbound turn. */ export type SupplementalContextFacts = { quote?: { id?: string; @@ -228,6 +241,7 @@ export type SupplementalContextFacts = { untrustedGroupSystemPrompt?: string; }; +/** Inbound media facts supplied to the agent context. */ export type InboundMediaFacts = { path?: string; url?: string; @@ -239,6 +253,7 @@ export type InboundMediaFacts = { type MaybePromise = T | Promise; +/** Adapter preflight output assembled before turn resolution. */ export type PreflightFacts = { admission?: ChannelTurnAdmission; command?: CommandFacts; @@ -252,16 +267,19 @@ export type PreflightFacts = { history?: ChannelTurnDroppedHistoryOptions; }; +/** Delivery metadata for one reply payload dispatch. */ export type ChannelDeliveryInfo = { kind: ReplyDispatchKind; }; +/** Durable delivery queue intent recorded when a reply is deferred. */ export type ChannelDeliveryIntent = { id: string; kind: "outbound_queue"; queuePolicy: OutboundDeliveryQueuePolicy; }; +/** Result returned after delivering one channel reply payload. */ export type ChannelDeliveryResult = { messageIds?: string[]; receipt?: MessageReceipt; @@ -271,6 +289,7 @@ export type ChannelDeliveryResult = { deliveryIntent?: ChannelDeliveryIntent; }; +/** Durable outbound delivery options available to channel turn delivery adapters. */ export type ChannelTurnDurableDeliveryOptions = Pick< DeliverOutboundPayloadsParams, "deps" | "formatting" | "identity" | "mediaAccess" | "replyToMode" | "silent" | "threadId" @@ -280,6 +299,7 @@ export type ChannelTurnDurableDeliveryOptions = Pick< requiredCapabilities?: DurableFinalDeliveryRequirements; }; +/** Delivery adapter used by channel turns to send reply payloads. */ export type ChannelEventDeliveryAdapter = { preparePayload?: ( payload: ReplyPayload, @@ -307,6 +327,7 @@ export type ChannelEventDeliveryAdapter = { onError?: (err: unknown, info: { kind: string }) => void; }; +/** Options for recording inbound session route state around a turn. */ export type ChannelTurnRecordOptions = { groupResolution?: GroupKeyResolution | null; createIfMissing?: boolean; @@ -315,6 +336,7 @@ export type ChannelTurnRecordOptions = { trackSessionMetaTask?: (task: Promise) => void; }; +/** Options for finalizing visible conversation history after dispatch. */ export type ChannelTurnHistoryFinalizeOptions = { isGroup?: boolean; historyKey?: string; @@ -322,6 +344,7 @@ export type ChannelTurnHistoryFinalizeOptions = { limit?: number; }; +/** Options for recording history when an inbound event is dropped before dispatch. */ export type ChannelTurnDroppedHistoryOptions = { key: string; limit: number; @@ -331,16 +354,19 @@ export type ChannelTurnDroppedHistoryOptions = { shouldRecord?: () => boolean; }; +/** Dispatcher options excluding delivery hooks owned by the channel turn adapter. */ export type ChannelTurnDispatcherOptions = Omit< ReplyDispatcherWithTypingOptions, "deliver" | "onError" >; +/** Reply pipeline options excluding cfg/agent/channel identity supplied by the turn. */ export type ChannelTurnReplyPipelineOptions = Omit< CreateChannelReplyPipelineParams, "cfg" | "agentId" | "channel" | "accountId" >; +/** Fully assembled channel turn ready to build the dispatch runner. */ export type AssembledChannelTurn = { cfg: OpenClawConfig; channel: string; @@ -364,6 +390,7 @@ export type AssembledChannelTurn = { messageId?: string; }; +/** Channel turn with dispatch runner already prepared. */ export type PreparedChannelTurn = { channel: string; accountId?: string; @@ -382,6 +409,7 @@ export type PreparedChannelTurn = { messageId?: string; }; +/** Resolved turn shape returned by adapters before final run/dispatch handling. */ export type ChannelTurnResolved = | (AssembledChannelTurn & { admission?: Extract; @@ -390,6 +418,7 @@ export type ChannelTurnResolved = admission?: Extract; }); +/** Ordered lifecycle stage names emitted to channel turn log hooks. */ export type ChannelTurnStage = | "ingest" | "classify" @@ -401,6 +430,7 @@ export type ChannelTurnStage = | "dispatch" | "finalize"; +/** Structured channel turn log event. */ export type ChannelTurnLogEvent = { stage: ChannelTurnStage; event: "start" | "done" | "drop" | "handled" | "error"; @@ -413,6 +443,7 @@ export type ChannelTurnLogEvent = { error?: unknown; }; +/** Final result for a channel turn, dispatched or admitted without dispatch. */ export type ChannelTurnResult = | DispatchedChannelTurnResult | { @@ -422,6 +453,7 @@ export type ChannelTurnResult = routeSessionKey?: string; }; +/** Successful dispatch result for a channel turn. */ export type DispatchedChannelTurnResult = { admission: Extract; dispatched: true; @@ -430,6 +462,7 @@ export type DispatchedChannelTurnResult = { ingest: (raw: TRaw) => Promise | NormalizedTurnInput | null; classify?: (input: NormalizedTurnInput) => Promise | ChannelEventClass; @@ -450,6 +483,7 @@ export type ChannelTurnAdapter onFinalize?: (result: ChannelTurnResult) => Promise | void; }; +/** Parameters for running one raw channel event through the turn kernel. */ export type RunChannelTurnParams = { channel: string; accountId?: string; diff --git a/src/cli/command-path-matches.ts b/src/cli/command-path-matches.ts index e50abf5cb6e..58ac88f1d99 100644 --- a/src/cli/command-path-matches.ts +++ b/src/cli/command-path-matches.ts @@ -23,6 +23,7 @@ function normalizeCommandPathMatchRule(rule: CommandPathMatchRule): NormalizedCo return { pattern: rule.pattern, exact: rule.exact ?? false }; } +/** Matches a command path prefix, or the full path when `exact` is requested. */ export function matchesCommandPath( commandPath: string[], pattern: readonly string[], @@ -34,6 +35,7 @@ export function matchesCommandPath( return !params?.exact || commandPath.length === pattern.length; } +/** Applies the shared command-path rule shape used by startup and help policies. */ export function matchesCommandPathRule(commandPath: string[], rule: CommandPathMatchRule): boolean { const normalizedRule = normalizeCommandPathMatchRule(rule); return matchesCommandPath(commandPath, normalizedRule.pattern, { @@ -41,6 +43,7 @@ export function matchesCommandPathRule(commandPath: string[], rule: CommandPathM }); } +/** Returns whether any configured command-path rule matches the parsed command path. */ export function matchesAnyCommandPath( commandPath: string[], rules: readonly CommandPathMatchRule[], diff --git a/src/cli/completion-runtime.ts b/src/cli/completion-runtime.ts index 05ecf31f845..1182f9dcb52 100644 --- a/src/cli/completion-runtime.ts +++ b/src/cli/completion-runtime.ts @@ -12,6 +12,7 @@ export const COMPLETION_SHELLS = ["zsh", "bash", "powershell", "fish"] as const; export type CompletionShell = (typeof COMPLETION_SHELLS)[number]; export const COMPLETION_SKIP_PLUGIN_COMMANDS_ENV = "OPENCLAW_COMPLETION_SKIP_PLUGIN_COMMANDS"; +/** Narrows an arbitrary shell label to a completion shell supported by installer logic. */ export function isCompletionShell(value: string): value is CompletionShell { return COMPLETION_SHELLS.includes(value as CompletionShell); } @@ -27,6 +28,7 @@ function resolveShellBasename( return normalizeLowercaseStringOrEmpty(basename.replace(/\.(?:exe|cmd|bat)$/i, "")); } +/** Resolves the active shell from environment paths, defaulting to zsh for unknown shells. */ export function resolveShellFromEnv(env: NodeJS.ProcessEnv = process.env): CompletionShell { const shellPath = normalizeOptionalString(env.SHELL) ?? ""; const shellName = shellPath ? resolveShellBasename(shellPath) : ""; @@ -58,6 +60,7 @@ function resolveCompletionCacheDir(env: NodeJS.ProcessEnv = process.env): string return path.join(stateDir, "completions"); } +/** Returns the per-shell cached completion script path for a sanitized CLI binary name. */ export function resolveCompletionCachePath(shell: CompletionShell, binName: string): string { const basename = sanitizeCompletionBasename(binName); const extension = @@ -78,6 +81,7 @@ function escapePowerShellSingleQuotedString(value: string): string { return value.replace(/'/g, "''"); } +/** Formats the profile line that sources the cached completion script for a shell. */ export function formatCompletionSourceLine( shell: CompletionShell, _binName: string, @@ -92,6 +96,7 @@ export function formatCompletionSourceLine( return `[ -f "${cachePath}" ] && source "${cachePath}"`; } +/** Formats the command users can run to reload the shell profile after installation. */ export function formatCompletionReloadCommand(shell: CompletionShell, profilePath: string): string { if (shell === "powershell") { return `. '${escapePowerShellSingleQuotedString(profilePath)}'`; @@ -151,6 +156,7 @@ function updateCompletionProfile( return { next, changed: next !== content, hadExisting }; } +/** Resolves the shell startup profile path that should contain the OpenClaw completion block. */ export function resolveCompletionProfilePath( shell: CompletionShell, options: { @@ -186,6 +192,7 @@ export function resolveCompletionProfilePath( return path.join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); } +/** Returns whether a shell profile already contains an OpenClaw completion block or source line. */ export async function isCompletionInstalled( shell: CompletionShell, binName = "openclaw", diff --git a/src/cli/json-output-mode.ts b/src/cli/json-output-mode.ts index f98bac8b728..9a4c5920265 100644 --- a/src/cli/json-output-mode.ts +++ b/src/cli/json-output-mode.ts @@ -1,5 +1,6 @@ import { loggingState } from "../logging/state.js"; +/** Detects CLI JSON mode before Commander parses options, stopping at the argv sentinel. */ export function hasJsonOutputFlag(argv: readonly string[]): boolean { for (const arg of argv) { if (arg === "--") { @@ -12,6 +13,7 @@ export function hasJsonOutputFlag(argv: readonly string[]): boolean { return false; } +/** Keeps structured JSON stdout clean by routing incidental console logs to stderr. */ export async function withConsoleLogsRoutedToStderrForJson( argv: readonly string[], run: () => Promise, @@ -24,6 +26,7 @@ export async function withConsoleLogsRoutedToStderrForJson( try { return await run(); } finally { + // Restore the process-wide logging switch so nested/serial CLI calls keep their own output mode. loggingState.forceConsoleToStderr = previousForceStderr; } } diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 14fbf8255fa..6aa6e159ba9 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -3,6 +3,7 @@ import readline from "node:readline/promises"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { isVerbose, isYes } from "../globals.js"; +/** Signals that an interactive prompt lost stdin before a complete answer arrived. */ export class PromptInputClosedError extends Error { constructor() { super("Prompt input closed before an answer was received."); @@ -25,6 +26,7 @@ function questionUntilClose(rl: ReadlineInterface, question: string): Promise finish(() => reject(new PromptInputClosedError())); + // readline.question does not reject on interface close, so race it with the close event. rl.once("close", onClose); void rl.question(question).then( (answer) => finish(() => resolve(answer)), @@ -33,11 +35,11 @@ function questionUntilClose(rl: ReadlineInterface, question: string): Promise { - // Simple Y/N prompt honoring global --yes and verbosity flags. if (isVerbose() && isYes()) { return true; - } // redundant guard when both flags set + } if (isYes()) { return true; } diff --git a/src/cli/respawn-policy.ts b/src/cli/respawn-policy.ts index ef3e77351d2..848bfbf316f 100644 --- a/src/cli/respawn-policy.ts +++ b/src/cli/respawn-policy.ts @@ -37,9 +37,12 @@ function isForegroundGatewayRunArgv(argv: string[]): boolean { if (!positionals) { return false; } + // Foreground gateway owns the terminal/process environment itself; respawning would + // add an extra parent process around the long-lived server. return positionals.length === 0 || (positionals.length === 1 && positionals[0] === "run"); } +/** Returns whether CLI startup should avoid the general respawn wrapper for this argv. */ export function shouldSkipRespawnForArgv(argv: string[]): boolean { const invocation = resolveCliArgvInvocation(argv); return ( @@ -49,6 +52,7 @@ export function shouldSkipRespawnForArgv(argv: string[]): boolean { ); } +/** Returns whether startup-environment respawn should be skipped without suppressing TUI respawn policy. */ export function shouldSkipStartupEnvironmentRespawnForArgv(argv: string[]): boolean { const invocation = resolveCliArgvInvocation(argv); return ( diff --git a/src/cron/active-jobs.ts b/src/cron/active-jobs.ts index c75220700e4..d489bb74a55 100644 --- a/src/cron/active-jobs.ts +++ b/src/cron/active-jobs.ts @@ -7,11 +7,14 @@ type CronActiveJobState = { const CRON_ACTIVE_JOB_STATE_KEY = Symbol.for("openclaw.cron.activeJobs"); function getCronActiveJobState(): CronActiveJobState { + // Cron runs can cross module reload boundaries in tests and dev watch; keep + // the in-flight job set process-global so duplicate-run guards share state. return resolveGlobalSingleton(CRON_ACTIVE_JOB_STATE_KEY, () => ({ activeJobIds: new Set(), })); } +/** Marks a cron job id as currently executing for duplicate-run suppression. */ export function markCronJobActive(jobId: string) { if (!jobId) { return; @@ -19,6 +22,7 @@ export function markCronJobActive(jobId: string) { getCronActiveJobState().activeJobIds.add(jobId); } +/** Clears the active marker when a cron run exits or is abandoned. */ export function clearCronJobActive(jobId: string) { if (!jobId) { return; @@ -26,6 +30,7 @@ export function clearCronJobActive(jobId: string) { getCronActiveJobState().activeJobIds.delete(jobId); } +/** Returns whether the given cron job id is currently executing in this process. */ export function isCronJobActive(jobId: string) { if (!jobId) { return false; @@ -33,10 +38,12 @@ export function isCronJobActive(jobId: string) { return getCronActiveJobState().activeJobIds.has(jobId); } +/** Returns whether any cron run is active in this process. */ export function hasActiveCronJobs() { return getCronActiveJobState().activeJobIds.size > 0; } +/** Clears process-global cron active-job state between tests. */ export function resetCronActiveJobsForTests() { getCronActiveJobState().activeJobIds.clear(); } diff --git a/src/cron/delivery-context.ts b/src/cron/delivery-context.ts index 5474fa8b0be..64c21d20c68 100644 --- a/src/cron/delivery-context.ts +++ b/src/cron/delivery-context.ts @@ -6,6 +6,7 @@ import { } from "../utils/delivery-context.shared.js"; import type { CronDelivery, CronMessageChannel } from "./types.js"; +/** Converts an active delivery context into cron announce delivery config. */ export function cronDeliveryFromContext(context?: DeliveryContext): CronDelivery | null { const normalized = normalizeDeliveryContext(context); if (!normalized?.to) { @@ -27,6 +28,7 @@ export function cronDeliveryFromContext(context?: DeliveryContext): CronDelivery return delivery; } +/** Recovers delivery context from a stored session key captured when the cron job was created. */ export function resolveCronStoredDeliveryContext(params: { cfg: OpenClawConfig; sessionKey?: string; @@ -37,11 +39,13 @@ export function resolveCronStoredDeliveryContext(params: { } const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey, { cfg: params.cfg }); if (deliveryContext && threadId) { + // Parsed session-key thread ids are canonical; replace any stale thread value in stored context. return { ...deliveryContext, threadId }; } return deliveryContext; } +/** Resolves initial cron delivery, preferring the live context before falling back to session storage. */ export function resolveCronCreationDelivery(params: { cfg: OpenClawConfig; currentDeliveryContext?: DeliveryContext; diff --git a/src/cron/delivery-field-schemas.ts b/src/cron/delivery-field-schemas.ts index ee7bb8906fa..8259e4f9254 100644 --- a/src/cron/delivery-field-schemas.ts +++ b/src/cron/delivery-field-schemas.ts @@ -10,21 +10,25 @@ const DeliveryModeFieldSchema = z .preprocess(trimLowercaseStringPreprocess, z.enum(["deliver", "announce", "none", "webhook"])) .transform((value) => (value === "deliver" ? "announce" : value)); +/** Accepts non-empty string fields after trimming and lowercasing user-provided delivery input. */ export const LowercaseNonEmptyStringFieldSchema = z.preprocess( trimLowercaseStringPreprocess, z.string().min(1), ); +/** Accepts non-empty string fields after trimming delivery input without changing case. */ export const TrimmedNonEmptyStringFieldSchema = z.preprocess( trimStringPreprocess, z.string().min(1), ); +/** Accepts delivery thread identifiers as either trimmed strings or finite numeric ids. */ export const DeliveryThreadIdFieldSchema = z.union([ TrimmedNonEmptyStringFieldSchema, z.number().finite(), ]); +/** Accepts non-negative finite timeout seconds from cron delivery payloads. */ export const TimeoutSecondsFieldSchema = z.number().finite().nonnegative(); type ParsedDeliveryInput = { @@ -35,6 +39,7 @@ type ParsedDeliveryInput = { accountId?: string; }; +/** Parses optional cron delivery fields while dropping invalid values instead of throwing. */ export function parseDeliveryInput(input: Record): ParsedDeliveryInput { return { mode: parseOptionalField(DeliveryModeFieldSchema, input.mode), @@ -45,6 +50,7 @@ export function parseDeliveryInput(input: Record): ParsedDelive }; } +/** Returns a parsed field value only when the supplied schema accepts it. */ export function parseOptionalField(schema: ZodType, value: unknown): T | undefined { const parsed = schema.safeParse(value); return parsed.success ? parsed.data : undefined; diff --git a/src/cron/delivery-plan.ts b/src/cron/delivery-plan.ts index 035eab6156e..cc41f38ad10 100644 --- a/src/cron/delivery-plan.ts +++ b/src/cron/delivery-plan.ts @@ -8,6 +8,7 @@ import type { CronFailureDestinationConfig } from "../config/types.cron.js"; import { resolveTargetPrefixedChannel } from "../infra/outbound/channel-target-prefix.js"; import type { CronDelivery, CronDeliveryMode, CronJob, CronMessageChannel } from "./types.js"; +/** Normalized routing plan for a cron job's primary delivery behavior. */ export type CronDeliveryPlan = { mode: CronDeliveryMode; channel?: CronMessageChannel; @@ -19,6 +20,7 @@ export type CronDeliveryPlan = { requested: boolean; }; +/** Returns whether a delivery plan names a concrete channel, recipient, thread, or account. */ export function hasExplicitCronDeliveryTarget(plan: CronDeliveryPlan): boolean { return Boolean( (plan.channel && plan.channel !== "last") || plan.to || plan.threadId != null || plan.accountId, @@ -47,6 +49,7 @@ function resolveAnnounceChannel(params: { ); } +/** Resolves primary delivery config into the runtime mode/channel/target plan. */ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { const delivery = job.delivery; const hasDelivery = delivery && typeof delivery === "object"; @@ -110,6 +113,7 @@ export function resolveCronDeliveryPlan(job: CronJob): CronDeliveryPlan { }; } +/** Normalized destination for notifying about cron execution failures. */ export type CronFailureDeliveryPlan = { mode: "announce" | "webhook"; channel?: CronMessageChannel; @@ -117,6 +121,7 @@ export type CronFailureDeliveryPlan = { accountId?: string; }; +/** Job-level failure destination override fields before global defaults are merged. */ export type CronFailureDestinationInput = { channel?: CronMessageChannel; to?: string; @@ -132,6 +137,7 @@ function normalizeFailureMode(value: unknown): "announce" | "webhook" | undefine return undefined; } +/** Resolves job-level failure notification routing layered over global defaults. */ export function resolveFailureDestination( job: CronJob, globalConfig?: CronFailureDestinationConfig, @@ -175,6 +181,7 @@ export function resolveFailureDestination( if (jobMode !== undefined) { const globalMode = globalConfig?.mode ?? "announce"; if (!jobToExplicitValue && globalMode !== jobMode) { + // Do not carry an inherited target across modes; an announce chat is not a webhook URL. to = undefined; } mode = jobMode; diff --git a/src/cron/delivery-preview.ts b/src/cron/delivery-preview.ts index 09dab6593a1..f7303667752 100644 --- a/src/cron/delivery-preview.ts +++ b/src/cron/delivery-preview.ts @@ -34,6 +34,7 @@ function formatDeliveryDetail(params: { return params.resolved ? "explicit" : (params.error ?? "unresolved"); } +/** Builds the user-visible cron delivery preview for one job without sending anything. */ export async function resolveCronDeliveryPreview(params: { cfg: OpenClawConfig; defaultAgentId?: string; @@ -65,6 +66,8 @@ export async function resolveCronDeliveryPreview(params: { { dryRun: true }, ); if (!resolved.ok) { + // Preview mirrors runtime fail-closed behavior for "last" delivery so the + // UI can show unresolved routes before the cron job actually runs. return { label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`, detail: @@ -88,6 +91,7 @@ export async function resolveCronDeliveryPreview(params: { }; } +/** Builds cron delivery previews keyed by job id. */ export async function resolveCronDeliveryPreviews(params: { cfg: OpenClawConfig; defaultAgentId?: string; diff --git a/src/cron/delivery.ts b/src/cron/delivery.ts index 2164004cc32..b0a0cc8ec15 100644 --- a/src/cron/delivery.ts +++ b/src/cron/delivery.ts @@ -31,6 +31,7 @@ export { const FAILURE_NOTIFICATION_TIMEOUT_MS = 30_000; const cronDeliveryLogger = getChildLogger({ subsystem: "cron-delivery" }); +/** Channel target metadata used for cron announcements and failure notifications. */ export type CronAnnounceTarget = { channel?: string; to?: string; @@ -112,6 +113,7 @@ async function deliverCronAnnouncePayload(params: { } } +/** Sends a cron announce payload and throws if target resolution or delivery fails. */ export async function sendCronAnnouncePayloadStrict(params: { deps: CliDeps; cfg: OpenClawConfig; @@ -134,6 +136,7 @@ export async function sendCronAnnouncePayloadStrict(params: { }); } +/** Sends a best-effort cron failure notification, logging resolution/send failures. */ export async function sendFailureNotificationAnnounce( deps: CliDeps, cfg: OpenClawConfig, @@ -145,6 +148,7 @@ export async function sendFailureNotificationAnnounce( const delivery = await resolveCronAnnounceDelivery({ cfg, agentId, jobId, target }); if (!delivery.ok) { + // Failure alerts must not mask the original cron run failure. cronDeliveryLogger.warn( { error: delivery.error.message }, "cron: failed to resolve failure destination target", @@ -154,6 +158,8 @@ export async function sendFailureNotificationAnnounce( const abortController = new AbortController(); const timeout = setTimeout(() => { + // Failure notifications are secondary; timeout prevents a stuck channel send + // from extending an already-failed cron run. abortController.abort(); }, FAILURE_NOTIFICATION_TIMEOUT_MS); diff --git a/src/cron/heartbeat-policy.ts b/src/cron/heartbeat-policy.ts index 8c3a5080a9a..4b298fa0fe6 100644 --- a/src/cron/heartbeat-policy.ts +++ b/src/cron/heartbeat-policy.ts @@ -10,6 +10,7 @@ type HeartbeatDeliveryPayload = { channelData?: unknown; }; +/** Returns whether delivery output contains only heartbeat acknowledgement text. */ export function shouldSkipHeartbeatOnlyDelivery( payloads: HeartbeatDeliveryPayload[], ackMaxChars: number, @@ -32,6 +33,7 @@ export function shouldSkipHeartbeatOnlyDelivery( }); } +/** Returns whether an undelivered cron main-summary system event should be queued. */ export function shouldEnqueueCronMainSummary(params: { summaryText: string | undefined; deliveryRequested: boolean; diff --git a/src/cron/isolated-agent/channel-output-policy.ts b/src/cron/isolated-agent/channel-output-policy.ts index 3cd3372642a..ad79ad9ffef 100644 --- a/src/cron/isolated-agent/channel-output-policy.ts +++ b/src/cron/isolated-agent/channel-output-policy.ts @@ -11,6 +11,7 @@ async function loadChannelPluginRuntime() { return await channelPluginRuntimeLoader.load(); } +/** Resolves channel-specific cron output preferences from loaded channel plugins. */ export async function resolveCronChannelOutputPolicy(channel: string | undefined): Promise<{ preferFinalAssistantVisibleText: boolean; }> { @@ -25,6 +26,7 @@ export async function resolveCronChannelOutputPolicy(channel: string | undefined }; } +/** Resolves the provider-specific current-thread target for a delivery address. */ export async function resolveCurrentChannelTarget(params: { channel?: string; to?: string; diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 9bdc131470e..ec1b25560b8 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -86,10 +86,12 @@ function normalizeSilentReplyText(text: string | undefined): NormalizedSilentRep return { text: next, strippedTrailingSilentToken }; } +/** Returns whether cron delivery should tolerate per-payload send failures. */ export function resolveCronDeliveryBestEffort(job: CronJob): boolean { return job.delivery?.bestEffort === true; } +/** Successful delivery-target resolution consumed by announce/direct delivery dispatch. */ export type SuccessfulDeliveryTarget = Extract; type DispatchCronDeliveryParams = { @@ -124,6 +126,7 @@ type DispatchCronDeliveryParams = { ) => RunCronAgentTurnResult; }; +/** Mutable delivery-dispatch accumulator returned to the isolated cron runner. */ export type DispatchCronDeliveryState = { result?: RunCronAgentTurnResult; delivered: boolean; @@ -241,6 +244,7 @@ async function logCronDeliveryError(message: string): Promise { logError(message); } +/** Deletes or retires ephemeral direct-delivery cron sessions for delete-after-run jobs. */ export async function cleanupDirectCronSession(params: { job: CronJob; agentSessionKey: string; @@ -680,10 +684,12 @@ async function appendDirectCronDeliveryTranscriptMirror(params: { } } +/** Clears the direct-delivery idempotency cache for deterministic tests. */ export function resetCompletedDirectCronDeliveriesForTests() { COMPLETED_DIRECT_CRON_DELIVERIES.clear(); } +/** Returns the direct-delivery idempotency cache size for tests. */ export function getCompletedDirectCronDeliveriesCountForTests(): number { return COMPLETED_DIRECT_CRON_DELIVERIES.size; } @@ -749,6 +755,7 @@ async function retryTransientDirectCronDelivery(params: { return await params.run(); } +/** Dispatches cron run output through verified message-tool or direct delivery paths. */ export async function dispatchCronDelivery( params: DispatchCronDeliveryParams, ): Promise { diff --git a/src/cron/isolated-agent/delivery-target.runtime.ts b/src/cron/isolated-agent/delivery-target.runtime.ts index 473616bd8ec..d77bc0539d7 100644 --- a/src/cron/isolated-agent/delivery-target.runtime.ts +++ b/src/cron/isolated-agent/delivery-target.runtime.ts @@ -13,6 +13,7 @@ export { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-l export { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; export { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js"; +/** Resolves a cron delivery target through channel plugins with bootstrap allowed. */ export async function resolveChannelTargetForDelivery(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -40,6 +41,7 @@ export async function resolveChannelTargetForDelivery(params: { } } +/** Resolves the outbound session route used for cron delivery threading and mirrors. */ export async function resolveOutboundSessionRouteForDelivery(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -58,6 +60,7 @@ export async function resolveOutboundSessionRouteForDelivery(params: { return await resolveOutboundSessionRoute(params); } +/** Returns whether a channel can canonicalize outbound cron delivery sessions. */ export function channelCanResolveOutboundSessionRoute(params: { cfg: OpenClawConfig; channel: ChannelId; diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 68e389feb0c..90dc2aa24c9 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -19,6 +19,7 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { resolveCronStoredDeliveryContext } from "../delivery-context.js"; import { resolveCronAgentSessionKey } from "./session-key.js"; +/** Result of resolving a cron job delivery request into a sendable outbound channel target. */ export type DeliveryTargetResolution = | { ok: true; @@ -108,6 +109,8 @@ function shouldCarrySessionThread(params: { params.resolved.to === params.resolved.lastTo ); } + // Explicit targets may reuse a stored thread only when both targets resolve + // to the same channel peer; otherwise cron could reply into a stale thread. return routesSharePeer(params.route, params.lastRoute); } @@ -127,6 +130,7 @@ function shouldStripResolvedTargetProviderPrefix(target: ResolvedMessagingTarget return target.resolutionSource === "normalized"; } +/** Resolves cron delivery config into a concrete channel target and optional thread/account. */ export async function resolveDeliveryTarget( cfg: OpenClawConfig, agentId: string, @@ -275,6 +279,8 @@ export async function resolveDeliveryTarget( effectiveAllowFrom = allowFromOverride; if (toCandidate && allowFromOverride.length > 0) { + // Implicit delivery must stay within channel allow-from policy; if the + // remembered target is outside that set, fall back to the first allowed peer. const currentTargetResolution = await resolveOutboundTargetWithRuntime({ channel, to: toCandidate, diff --git a/src/cron/isolated-agent/helpers.ts b/src/cron/isolated-agent/helpers.ts index bfec369f33d..c249fd5934e 100644 --- a/src/cron/isolated-agent/helpers.ts +++ b/src/cron/isolated-agent/helpers.ts @@ -11,6 +11,7 @@ type DeliveryPayload = Pick< "text" | "mediaUrl" | "mediaUrls" | "presentation" | "interactive" | "channelData" | "isError" >; +/** Normalized cron run payload state used for summaries, delivery, and failure classification. */ export type CronPayloadOutcome = { summary?: string; outputText?: string; @@ -77,6 +78,7 @@ function formatCronRunLevelError(error: unknown): string | undefined { return "cron isolated run failed"; } +/** Picks a bounded cron run summary from plain text output. */ export function pickSummaryFromOutput(text: string | undefined) { const clean = (text ?? "").trim(); if (!clean) { @@ -86,6 +88,7 @@ export function pickSummaryFromOutput(text: string | undefined) { return clean.length > limit ? `${truncateUtf16Safe(clean, limit)}…` : clean; } +/** Picks the last non-error payload text suitable for cron run summaries. */ export function pickSummaryFromPayloads( payloads: Array<{ text?: string | undefined; isError?: boolean }>, ) { @@ -110,6 +113,7 @@ export function pickSummaryFromPayloads( return undefined; } +/** Picks the last non-empty payload text while ignoring terminal error payloads first. */ export function pickLastNonEmptyTextFromPayloads( payloads: Array<{ text?: string | undefined; isError?: boolean }>, ) { @@ -154,6 +158,7 @@ function payloadHasStructuredDeliveryContent(payload: DeliveryPayload | null | u ); } +/** Picks the last payload with deliverable outbound content, preferring non-error payloads. */ export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) { for (let i = payloads.length - 1; i >= 0; i--) { if (payloads[i]?.isError) { @@ -171,6 +176,7 @@ export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) { return undefined; } +/** Selects deliverable cron payloads while preserving multi-payload successful responses. */ export function pickDeliverablePayloads(payloads: DeliveryPayload[]): DeliveryPayload[] { const successfulDeliverablePayloads = payloads.filter( (payload) => payload != null && payload.isError !== true && isDeliverablePayload(payload), @@ -190,6 +196,7 @@ export function isHeartbeatOnlyResponse(payloads: DeliveryPayload[], ackMaxChars return shouldSkipHeartbeatOnlyDelivery(payloads, ackMaxChars); } +/** Resolves the non-negative heartbeat ack length used for heartbeat-only filtering. */ export function resolveHeartbeatAckMaxChars(agentCfg?: { heartbeat?: { ackMaxChars?: number } }) { const raw = agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS; return Math.max(0, raw); @@ -218,6 +225,7 @@ function isSuccessfulCronPayload(payload: DeliveryPayload | undefined): boolean ); } +/** Resolves summary, output text, delivery payloads, and fatal-error state from cron run output. */ export function resolveCronPayloadOutcome(params: { payloads: DeliveryPayload[]; runLevelError?: unknown; @@ -259,6 +267,8 @@ export function resolveCronPayloadOutcome(params: { normalizedFinalAssistantVisibleText !== undefined || hasSuccessfulPayloadAfterLastError || hasSuccessfulPayloadBeforeLastError; + // Some tools emit warning/error payloads before a final answer. Treat those + // as non-terminal only when later visible output proves the run recovered. const hasNonTerminalToolErrorWarning = !params.runLevelError && params.failureSignal?.fatalForCron !== true && @@ -287,8 +297,12 @@ export function resolveCronPayloadOutcome(params: { !hasPendingPresentationWarning && !hasNonTerminalToolErrorWarning && !hasRecoveredToolWarning; + // Fatal structured errors own the final delivery payload unless later output + // proves recovery; otherwise cron would announce stale partial success text. // Keep structured/media announce payloads intact. Only collapse purely textual // cron announce output to the final assistant-visible answer. + // A final assistant answer can replace textual warning payloads, but never + // structured/media payloads that carry the actual delivery content. const shouldUseFinalAssistantVisibleText = params.preferFinalAssistantVisibleText === true && normalizedFinalAssistantVisibleText !== undefined && diff --git a/src/cron/isolated-agent/model-preflight.runtime.ts b/src/cron/isolated-agent/model-preflight.runtime.ts index 2df986bcdab..d166f959664 100644 --- a/src/cron/isolated-agent/model-preflight.runtime.ts +++ b/src/cron/isolated-agent/model-preflight.runtime.ts @@ -10,6 +10,7 @@ const PREFLIGHT_TIMEOUT_MS = 2_500; type PreflightApi = "ollama" | "openai-completions"; +/** Local provider reachability result used to skip cron runs before runner startup. */ export type CronModelProviderPreflightResult = | { status: "available" } | { @@ -174,6 +175,7 @@ async function probeLocalProviderEndpoint(params: { } } +/** Checks local model-provider reachability before a scheduled cron run starts. */ export async function preflightCronModelProvider(params: { cfg: OpenClawConfig; provider: string; @@ -194,6 +196,8 @@ export async function preflightCronModelProvider(params: { const cacheKey = `${api}\0${baseUrl}`; const cached = preflightCache.get(cacheKey); if (cached && nowMs - cached.checkedAtMs < PREFLIGHT_CACHE_TTL_MS) { + // Cache by endpoint, not model: this probe only verifies local server + // reachability, while model availability is handled by the runner. if (cached.result.status === "available") { return { status: "available" }; } @@ -224,6 +228,7 @@ export async function preflightCronModelProvider(params: { }); } +/** Clears the local-provider preflight cache for deterministic tests. */ export function resetCronModelProviderPreflightCacheForTest(): void { preflightCache.clear(); } diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index 88c058b0cc6..db4e3d120ba 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -20,6 +20,7 @@ type CronSessionModelOverrides = { type CronModelSelectionSource = "default" | "subagent" | "agent" | "hook" | "payload" | "session"; +/** Inputs used to resolve the model for one isolated cron run. */ export type ResolveCronModelSelectionParams = { cfg: OpenClawConfig; cfgWithAgentDefaults: OpenClawConfig; @@ -30,6 +31,7 @@ export type ResolveCronModelSelectionParams = { agentId?: string; }; +/** Resolved provider/model pair plus the precedence source that selected it. */ export type ResolveCronModelSelectionResult = | { ok: true; @@ -63,6 +65,7 @@ function formatCronPayloadModelRejection(params: { return `cron payload.model '${modelOverride}' rejected: ${error}`; } +/** Resolves the effective model for an isolated cron run across defaults, agents, hooks, payload, and session state. */ export async function resolveCronModelSelection( params: ResolveCronModelSelectionParams, ): Promise { @@ -92,6 +95,8 @@ export async function resolveCronModelSelection( const subagentModelSource: CronModelSelectionSource = subagentModelConfigSelection?.source === "agent" ? "agent" : "subagent"; if (subagentModelRaw) { + // Subagent/agent model config is advisory here: invalid refs fall back to + // defaults so an agent config typo does not prevent unrelated cron runs. const resolvedSubagent = resolveAllowedModelRef({ cfg: params.cfgWithAgentDefaults, catalog: await loadCatalogOnce(), @@ -157,6 +162,8 @@ export async function resolveCronModelSelection( if (!modelOverride && !hooksGmailModelApplied) { const sessionModelOverride = params.sessionEntry.modelOverride?.trim(); if (sessionModelOverride) { + // Stored session overrides are lowest precedence so explicit cron payload + // and hook-specific models can intentionally move a run away from history. const sessionProviderOverride = params.sessionEntry.providerOverride?.trim() || resolvedDefault.provider; const resolvedSessionOverride = resolveAllowedModelRef({ diff --git a/src/cron/isolated-agent/run-config.ts b/src/cron/isolated-agent/run-config.ts index 71771f1b1e1..07721dbfa5d 100644 --- a/src/cron/isolated-agent/run-config.ts +++ b/src/cron/isolated-agent/run-config.ts @@ -32,6 +32,7 @@ function mergeCronAgentModelOverride(params: { return nextDefaults; } +/** Builds the agent defaults snapshot used by isolated cron runs. */ export function buildCronAgentDefaultsConfig(params: { defaults?: AgentDefaultsConfig; agentConfigOverride?: ResolvedAgentConfig; diff --git a/src/cron/isolated-agent/run-execution.runtime.ts b/src/cron/isolated-agent/run-execution.runtime.ts index 07d1b846d4a..6132916270c 100644 --- a/src/cron/isolated-agent/run-execution.runtime.ts +++ b/src/cron/isolated-agent/run-execution.runtime.ts @@ -22,6 +22,7 @@ async function loadCronExecutionCliRuntime() { return await cronExecutionCliRuntimeLoader.load(); } +/** Lazily resolves CLI session ids without loading the cron CLI runner at module import time. */ export async function getCliSessionId( ...args: Parameters ): Promise> { @@ -29,6 +30,7 @@ export async function getCliSessionId( return runtime.getCliSessionId(...args); } +/** Lazily runs the CLI-backed agent path used by isolated cron execution. */ export async function runCliAgent( ...args: Parameters ): ReturnType { diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 72ca866d704..1b2710a866a 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -84,6 +84,7 @@ function resolveIsolatedCronPromptCacheKey(params: { return `openclaw-cron-${digest}`; } +/** Detects single-line cron prompts that look like shell commands or command invocations. */ export function isCommandStyleCronMessage(message: string): boolean { const trimmed = message.trim(); if (!trimmed || trimmed.includes("\n")) { @@ -104,6 +105,7 @@ function resolveCronBootstrapContextMode( return isCommandStyleCronMessage(payload?.message ?? "") ? "lightweight" : undefined; } +/** Result envelope returned after an isolated cron prompt completes. */ export type CronExecutionResult = { runResult: CronPromptRunResult; fallbackProvider: string; @@ -113,6 +115,7 @@ export type CronExecutionResult = { liveSelection: CronLiveSelection; }; +/** Creates the model-fallback executor for one isolated cron prompt run. */ export function createCronPromptExecutor(params: { cfg: OpenClawConfig; cfgWithAgentDefaults: OpenClawConfig; @@ -212,6 +215,8 @@ export function createCronPromptExecutor(params: { }) ?? providerOverride; const bootstrapPromptWarningSignature = bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; + // CLI providers can resume provider-native sessions; embedded providers + // use OpenClaw's transcript/session file plus prompt-cache affinity. if (isCliProvider(executionProvider, params.cfgWithAgentDefaults)) { const cliSessionId = params.cronSession.isNewSession ? undefined @@ -349,6 +354,7 @@ export function createCronPromptExecutor(params: { }; } +/** Executes an isolated cron prompt, including live model-switch and interim-ack retries. */ export async function executeCronRun(params: { cfg: OpenClawConfig; cfgWithAgentDefaults: OpenClawConfig; @@ -457,6 +463,8 @@ export async function executeCronRun(params: { liveSelection: params.liveSelection, }); try { + // Persist the switched model before retrying so later delivery/session + // metadata agrees with the model that actually handled the run. await params.persistSessionEntry(); } catch (persistErr) { logWarn( @@ -510,6 +518,8 @@ export async function executeCronRun(params: { } if (shouldRetryInterimAck && !hasFreshDescendants && !hasActiveDescendants) { + // Retry a bare acknowledgement only when no descendant subagent was + // spawned; otherwise delivery waits for the subagent follow-up path. const continuationPrompt = [ "Your previous response was only an acknowledgement and did not complete this cron task.", "Complete the original task now.", diff --git a/src/cron/isolated-agent/run-fallback-policy.ts b/src/cron/isolated-agent/run-fallback-policy.ts index b360d7e2c54..8d8bbee7b07 100644 --- a/src/cron/isolated-agent/run-fallback-policy.ts +++ b/src/cron/isolated-agent/run-fallback-policy.ts @@ -7,6 +7,7 @@ import { resolveSubagentModelFallbacksOverride, } from "./run-execution.runtime.js"; +/** Resolves cron model fallbacks, giving explicit payload fallbacks precedence over subagent/default policy. */ export function resolveCronFallbacksOverride(params: { cfg: OpenClawConfig; job: CronJob; @@ -21,6 +22,8 @@ export function resolveCronFallbacksOverride(params: { return payloadFallbacks; } if (params.useSubagentFallbacks === true && !hasCronPayloadModelOverride) { + // A payload model override owns its full candidate chain; otherwise the + // selected subagent can contribute its configured fallback policy. const subagentFallbacksOverride = resolveSubagentModelFallbacksOverride( params.cfg, params.agentId, @@ -37,6 +40,7 @@ export function resolveCronFallbacksOverride(params: { }); } +/** Builds the ordered model candidates used by cron preflight checks. */ export function resolveCronPreflightCandidates(params: { cfg: OpenClawConfig; job: CronJob; diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index 9e89f0a105b..5be798a82c6 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -7,11 +7,14 @@ import type { resolveCronSession } from "./session.js"; type MutableSessionStore = Record; +/** Mutable cron session entry updated by an isolated run before persistence. */ export type MutableCronSessionEntry = SessionEntry; +/** Resolved cron session plus its mutable backing store and active entry. */ export type MutableCronSession = ReturnType & { store: MutableSessionStore; sessionEntry: MutableCronSessionEntry; }; +/** Live provider/model/auth-profile selection reported by the running session. */ export type CronLiveSelection = LiveSessionModelSelection; type UpdateSessionStore = ( @@ -19,6 +22,7 @@ type UpdateSessionStore = ( update: (store: MutableSessionStore) => void, ) => Promise; +/** Persists the currently selected mutable cron session entry to the session store. */ export type PersistCronSessionEntry = () => Promise; function cronTranscriptExists(entry: SessionEntry): boolean { @@ -33,6 +37,8 @@ function normalizeSessionField(value: string | undefined): string | undefined { function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry { const next = { ...entry } as Partial; + // If the transcript never materialized, do not persist stale resume handles + // that would make the next cron run believe a resumable CLI session exists. delete next.sessionId; delete next.sessionFile; delete next.sessionStartedAt; @@ -43,6 +49,7 @@ function toNonResumableCronSessionEntry(entry: SessionEntry): SessionEntry { return next as SessionEntry; } +/** Creates the persistence callback that stores cron session metadata after a run. */ export function createPersistCronSessionEntry(params: { isFastTestEnv: boolean; cronSession: MutableCronSession; @@ -66,6 +73,7 @@ export function createPersistCronSessionEntry(params: { }; } +/** Adopts the session id/file produced by a run and preserves usage-family lineage. */ export function adoptCronRunSessionMetadata(params: { entry: MutableCronSessionEntry; sessionKey: string; @@ -103,6 +111,7 @@ export function adoptCronRunSessionMetadata(params: { return changed; } +/** Persists a changed skills snapshot onto the cron session entry outside fast tests. */ export async function persistCronSkillsSnapshotIfChanged(params: { isFastTestEnv: boolean; cronSession: MutableCronSession; @@ -124,6 +133,7 @@ export async function persistCronSkillsSnapshotIfChanged(params: { await params.persistSessionEntry(); } +/** Records the selected provider/model before a cron run starts. */ export function markCronSessionPreRun(params: { entry: MutableCronSessionEntry; provider: string; @@ -134,6 +144,7 @@ export function markCronSessionPreRun(params: { params.entry.systemSent = true; } +/** Syncs live model/auth-profile changes from a running cron session back to storage. */ export function syncCronSessionLiveSelection(params: { entry: MutableCronSessionEntry; liveSelection: CronLiveSelection; @@ -144,6 +155,8 @@ export function syncCronSessionLiveSelection(params: { params.entry.authProfileOverride = params.liveSelection.authProfileId; params.entry.authProfileOverrideSource = params.liveSelection.authProfileIdSource; if (params.liveSelection.authProfileIdSource === "auto") { + // Auto-selected profiles are tied to the compaction generation that + // resolved them; manual overrides should survive later compactions. params.entry.authProfileOverrideCompactionCount = params.entry.compactionCount ?? 0; } else { delete params.entry.authProfileOverrideCompactionCount; diff --git a/src/cron/isolated-agent/run-timeout.ts b/src/cron/isolated-agent/run-timeout.ts index 37233882d4e..0b177b00995 100644 --- a/src/cron/isolated-agent/run-timeout.ts +++ b/src/cron/isolated-agent/run-timeout.ts @@ -1,5 +1,6 @@ import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-core/number-coercion"; +/** Converts explicit cron payload timeoutSeconds into a timer-safe millisecond override signal. */ export function resolveCronRunTimeoutOverrideMs(timeoutSeconds: unknown): number | undefined { return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds); } diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index efed2b3f530..3a9dfa235e9 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1203,6 +1203,7 @@ async function disposeCronRunContext(params: { (params.cronSession as { store?: unknown }).store = undefined; } +/** Runs one isolated cron agent turn, including setup, execution, delivery, and persistence. */ export async function runCronIsolatedAgentTurn(params: { cfg: OpenClawConfig; deps: CliDeps; diff --git a/src/cron/isolated-agent/run.types.ts b/src/cron/isolated-agent/run.types.ts index 5d22d2266d5..1b157ec2d2d 100644 --- a/src/cron/isolated-agent/run.types.ts +++ b/src/cron/isolated-agent/run.types.ts @@ -1,5 +1,6 @@ import type { CronDeliveryTrace, CronRunOutcome, CronRunTelemetry } from "../types.js"; +/** Final isolated cron turn result merged into service state and run logs. */ export type RunCronAgentTurnResult = { /** Last non-empty agent text output (not truncated). */ outputText?: string; diff --git a/src/cron/isolated-agent/session-key.ts b/src/cron/isolated-agent/session-key.ts index 8effc3eaa45..e7341513c39 100644 --- a/src/cron/isolated-agent/session-key.ts +++ b/src/cron/isolated-agent/session-key.ts @@ -2,6 +2,7 @@ import { canonicalizeMainSessionAlias } from "../../config/sessions/main-session import type { SessionScope } from "../../config/sessions/types.js"; import { toAgentStoreSessionKey } from "../../routing/session-key.js"; +/** Resolves a cron session key into the canonical agent-scoped session-store key. */ export function resolveCronAgentSessionKey(params: { sessionKey: string; agentId: string; diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index 2def651bf11..817c2e2b54e 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -103,6 +103,7 @@ function sanitizeFreshCronSessionEntry( return next; } +/** Resolves or rolls over the cron session entry for one isolated-agent run. */ export function resolveCronSession(params: { cfg: OpenClawConfig; sessionKey: string; @@ -118,14 +119,13 @@ export function resolveCronSession(params: { const store = params.store ?? loadSessionStore(storePath); const entry = store[params.sessionKey]; - // Check if we can reuse an existing session let sessionId: string; let isNewSession: boolean; let systemSent: boolean; if (!params.forceNew && entry?.sessionId) { - // Evaluate freshness using the configured reset policy - // Cron/webhook sessions use "direct" reset type (1:1 conversation style) + // Cron/webhook sessions follow the direct reset policy so scheduled turns + // roll over like 1:1 conversations rather than long-lived group contexts. const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType: "direct", @@ -142,18 +142,15 @@ export function resolveCronSession(params: { }); if (freshness.fresh) { - // Reuse existing session sessionId = entry.sessionId; isNewSession = false; systemSent = entry.systemSent ?? false; } else { - // Session expired, create new sessionId = crypto.randomUUID(); isNewSession = true; systemSent = false; } } else { - // No existing session or forced new sessionId = crypto.randomUUID(); isNewSession = true; systemSent = false; @@ -172,9 +169,9 @@ export function resolveCronSession(params: { : undefined; const sessionEntry: SessionEntry = { - // Preserve existing per-session overrides even when rolling to a new sessionId. + // Fresh cron sessions keep user preference/auth overrides but drop resume + // handles and auto-fallback model overrides that belong to the old run. ...baseEntry, - // Always update these core fields sessionId, updatedAt: params.nowMs, sessionStartedAt: isNewSession diff --git a/src/cron/isolated-agent/subagent-followup-hints.ts b/src/cron/isolated-agent/subagent-followup-hints.ts index 7db1e3c2494..50d2c10d7d4 100644 --- a/src/cron/isolated-agent/subagent-followup-hints.ts +++ b/src/cron/isolated-agent/subagent-followup-hints.ts @@ -30,6 +30,7 @@ function normalizeHintText(value: string): string { return normalizeLowercaseStringOrEmpty(value).replace(/\s+/g, " "); } +/** Detects short cron replies that probably announce work continuing elsewhere. */ export function isLikelyInterimCronMessage(value: string): boolean { const normalized = normalizeHintText(value); if (!normalized) { @@ -42,6 +43,7 @@ export function isLikelyInterimCronMessage(value: string): boolean { return words <= 45 && INTERIM_CRON_HINTS.some((hint) => normalized.includes(hint)); } +/** Detects cron replies that explicitly promise a subagent follow-up message. */ export function expectsSubagentFollowup(value: string): boolean { const normalized = normalizeHintText(value); return Boolean(normalized && SUBAGENT_FOLLOWUP_HINTS.some((hint) => normalized.includes(hint))); diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index 28822a44e16..84d4a0ed933 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -12,6 +12,7 @@ function resolveCronSubagentTimings() { }; } +/** Reads completed descendant subagent replies when the orchestrator only emitted interim text. */ export async function readDescendantSubagentFallbackReply(params: { sessionKey: string; runStartedAt: number; diff --git a/src/cron/normalize-job-identity.ts b/src/cron/normalize-job-identity.ts index 689892c228d..1171b26be26 100644 --- a/src/cron/normalize-job-identity.ts +++ b/src/cron/normalize-job-identity.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Normalizes mutable cron job rows from old `jobId` storage into the canonical `id` field. */ export function normalizeCronJobIdentityFields(raw: Record): { mutated: boolean; legacyJobIdIssue: boolean; diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 1bcbcec2689..3c4e0e49f52 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -27,7 +27,7 @@ type UnknownRecord = Record; type NormalizeOptions = { applyDefaults?: boolean; - /** Session context for resolving "current" sessionTarget or auto-binding when not specified */ + /** Session context used to resolve "current" sessionTarget during create-time defaulting. */ sessionContext?: { sessionKey?: string }; }; @@ -93,6 +93,8 @@ function coerceSchedule(schedule: UnknownRecord) { } if (next.kind === "at") { + // Keep each schedule variant canonical so persisted jobs do not carry stale + // fields from a previous kind after CLI/API normalization. delete next.everyMs; delete next.anchorMs; delete next.expr; @@ -331,7 +333,8 @@ function normalizeSessionTarget(raw: unknown) { if (lower === "main" || lower === "isolated" || lower === "current") { return lower; } - // Support custom session IDs with "session:" prefix + // Custom session targets must still pass the same session-id safety gate used + // by runtime session resolution. if (lower.startsWith("session:")) { return `session:${assertSafeCronSessionTargetId(trimmed.slice(8))}`; } @@ -349,6 +352,7 @@ function normalizeWakeMode(raw: unknown) { return undefined; } +/** Normalizes raw cron job input without deciding whether create-time defaults apply. */ export function normalizeCronJobInput( raw: unknown, options: NormalizeOptions = DEFAULT_OPTIONS, @@ -456,10 +460,8 @@ export function normalizeCronJobInput( } if (!next.sessionTarget && isRecord(next.payload)) { const kind = typeof next.payload.kind === "string" ? next.payload.kind : ""; - // Keep default behavior unchanged for backward compatibility: - // - systemEvent defaults to "main" - // - agentTurn defaults to "isolated" (NOT "current", to avoid token accumulation) - // Users must explicitly specify "current" or "session:xxx" for custom session binding + // Keep create-time defaults explicit: system events join main, while agent + // turns isolate by default to avoid unbounded token accumulation. if (kind === "systemEvent") { next.sessionTarget = "main"; } else if (kind === "agentTurn") { @@ -500,7 +502,8 @@ export function normalizeCronJobInput( const payload = isRecord(next.payload) ? next.payload : null; const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : ""; const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : ""; - // Support "isolated", custom session IDs (session:xxx), and resolved "current" as isolated-like targets + // Resolved "current" and custom session ids still use isolated-agent + // delivery semantics, so they get the same default announce behavior. const isIsolatedAgentTurn = sessionTarget === "isolated" || sessionTarget === "current" || @@ -515,6 +518,7 @@ export function normalizeCronJobInput( return next; } +/** Normalizes a raw cron create request and applies create-time defaults. */ export function normalizeCronJobCreate( raw: unknown, options?: Omit, @@ -525,6 +529,7 @@ export function normalizeCronJobCreate( }) as CronJobCreate | null; } +/** Normalizes a raw cron patch request without filling omitted fields. */ export function normalizeCronJobPatch( raw: unknown, options?: NormalizeOptions, diff --git a/src/cron/parse.ts b/src/cron/parse.ts index 4d493476e9d..92d42eabd0c 100644 --- a/src/cron/parse.ts +++ b/src/cron/parse.ts @@ -17,6 +17,7 @@ function normalizeUtcIso(raw: string) { return raw; } +/** Parses absolute cron timestamps from epoch milliseconds or ISO-like strings normalized to UTC. */ export function parseAbsoluteTimeMs(input: string): number | null { const raw = input.trim(); if (!raw) { diff --git a/src/cron/persisted-shape.ts b/src/cron/persisted-shape.ts index ebcd558798b..2c5de94aac0 100644 --- a/src/cron/persisted-shape.ts +++ b/src/cron/persisted-shape.ts @@ -1,5 +1,6 @@ import { parseAbsoluteTimeMs } from "./parse.js"; +/** Structural rejection code for persisted cron jobs that cannot be loaded safely. */ export type InvalidPersistedCronJobReason = | "missing-id" | "missing-schedule" @@ -7,6 +8,7 @@ export type InvalidPersistedCronJobReason = | "missing-payload" | "invalid-payload"; +/** Returns the first structural reason a persisted cron job cannot be loaded safely. */ export function getInvalidPersistedCronJobReason( candidate: Record, ): InvalidPersistedCronJobReason | null { @@ -19,6 +21,8 @@ export function getInvalidPersistedCronJobReason( return "missing-schedule"; } if (typeof schedule === "string") { + // Legacy shorthand schedules are normalized later by the full cron parser; + // this guard only rejects shapes that cannot be persisted or quarantined. return null; } if (typeof schedule !== "object") { diff --git a/src/cron/retry-hint.ts b/src/cron/retry-hint.ts index baafc40d8fe..fc9b60a42c8 100644 --- a/src/cron/retry-hint.ts +++ b/src/cron/retry-hint.ts @@ -1,5 +1,6 @@ import type { CronRetryOn } from "../config/types.cron.js"; +/** Cron retry classifier output consumed by scheduler retry policy. */ export type CronRetryHint = { retryable: boolean; category?: CronRetryOn; @@ -16,6 +17,7 @@ const TRANSIENT_PATTERNS: Record = { server_error: /\b5\d{2}\b/, }; +/** Classifies cron execution errors against the configured retryable transient categories. */ export function resolveCronExecutionRetryHint( error: string | undefined, retryOn?: CronRetryOn[], @@ -27,6 +29,7 @@ export function resolveCronExecutionRetryHint( const keys = retryOn?.length ? retryOn : (Object.keys(TRANSIENT_PATTERNS) as CronRetryOn[]); const classified = classifiedReason ?? undefined; if (classified && keys.includes(classified as CronRetryOn)) { + // Structured provider classifications win over brittle message regexes when allowed. return { retryable: true, category: classified as CronRetryOn }; } for (const key of keys) { diff --git a/src/cron/run-diagnostics.ts b/src/cron/run-diagnostics.ts index cf7882758ee..e697a3633d1 100644 --- a/src/cron/run-diagnostics.ts +++ b/src/cron/run-diagnostics.ts @@ -67,6 +67,8 @@ function tailText(value: string, maxChars: number): string { if (value.length <= maxChars) { return value; } + // Exec output often ends with the actionable failure; keep the tail when + // bounding diagnostic text for run logs and control surfaces. return value.slice(value.length - maxChars); } @@ -96,6 +98,7 @@ function trimSummary(value: string | undefined): string | undefined { return `${normalized.slice(0, MAX_SUMMARY_CHARS - 1)}…`; } +/** Returns the operator-facing summary for persisted cron diagnostics. */ export function summarizeCronRunDiagnostics( diagnostics: CronRunDiagnostics | undefined, ): string | undefined { @@ -105,6 +108,7 @@ export function summarizeCronRunDiagnostics( return trimSummary(diagnostics.summary ?? diagnostics.entries[0]?.message); } +/** Normalizes untrusted cron diagnostic payloads into bounded, redacted entries. */ export function normalizeCronRunDiagnostics( value: unknown, opts?: { nowMs?: () => number }, @@ -141,6 +145,8 @@ export function normalizeCronRunDiagnostics( ...(entry.truncated === true || normalized.truncated ? { truncated: true } : {}), }); if (entries.length > MAX_ENTRIES) { + // Keep the latest diagnostics because late tool/exec failures usually + // explain the final cron result better than setup noise. entries.shift(); } } @@ -155,6 +161,7 @@ export function normalizeCronRunDiagnostics( return { ...(summary ? { summary } : {}), entries }; } +/** Merges cron diagnostics while choosing the highest-severity latest summary. */ export function mergeCronRunDiagnostics( ...values: Array ): CronRunDiagnostics | undefined { @@ -174,6 +181,8 @@ export function mergeCronRunDiagnostics( const severity = entryCandidate?.severity === "error" ? 2 : entryCandidate?.severity === "warn" ? 1 : 0; const order = entries.length + normalized.entries.length; + // Summary text is operator-facing; prefer severe diagnostics, then the + // newest diagnostic at the same severity so retries surface current cause. if ( !summaryCandidate || severity > summaryCandidate.severity || @@ -190,6 +199,7 @@ export function mergeCronRunDiagnostics( }); } +/** Converts an arbitrary thrown cron error into a redacted diagnostic entry. */ export function createCronRunDiagnosticsFromError( source: CronRunDiagnosticSource, error: unknown, @@ -219,6 +229,7 @@ export function createCronRunDiagnosticsFromError( ); } +/** Extracts failed exec details from tool metadata into cron diagnostics. */ export function createCronRunDiagnosticsFromExecDetails( details: unknown, opts?: { @@ -260,6 +271,7 @@ export function createCronRunDiagnosticsFromExecDetails( ); } +/** Extracts tool-call failure diagnostics from an agent reply payload. */ export function createCronRunDiagnosticsFromToolPayload( payload: unknown, opts?: { nowMs?: () => number; finalStatus?: "ok" | "error" | "skipped" }, @@ -289,6 +301,7 @@ export function createCronRunDiagnosticsFromToolPayload( return mergeCronRunDiagnostics(detailsDiagnostics, textDiagnostics); } +/** Extracts cron run diagnostics from agent result payloads and metadata. */ export function createCronRunDiagnosticsFromAgentResult( result: unknown, opts?: { nowMs?: () => number; finalStatus?: "ok" | "error" | "skipped" }, diff --git a/src/cron/run-id.ts b/src/cron/run-id.ts index 4306c6d5a41..edf78a9a780 100644 --- a/src/cron/run-id.ts +++ b/src/cron/run-id.ts @@ -1,3 +1,4 @@ +/** Builds the stable diagnostic/session execution id for a single cron run. */ export function createCronExecutionId(jobId: string, startedAt: number): string { return `cron:${jobId}:${startedAt}`; } diff --git a/src/cron/run-log-jsonl.ts b/src/cron/run-log-jsonl.ts index afda3824b54..3e4c4c59a75 100644 --- a/src/cron/run-log-jsonl.ts +++ b/src/cron/run-log-jsonl.ts @@ -1,6 +1,7 @@ import type { CronRunLogEntry } from "./run-log-types.js"; import { parseCronRunLogEntryObject } from "./run-log/entry-codec.js"; +/** Parses legacy cron run-log JSONL, skipping malformed or non-matching rows. */ export function parseCronRunLogEntriesFromJsonl( raw: string, opts?: { jobId?: string }, diff --git a/src/cron/run-log-types.ts b/src/cron/run-log-types.ts index 91555985ba0..0623bb4d51c 100644 --- a/src/cron/run-log-types.ts +++ b/src/cron/run-log-types.ts @@ -8,6 +8,7 @@ import type { CronRunTelemetry, } from "./types.js"; +/** Append-only run-log record for a completed cron job execution. */ export type CronRunLogEntry = { ts: number; jobId: string; diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index c66f7641356..f2546da4f67 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -71,15 +71,19 @@ function assertSafeCronRunLogJobId(jobId: string): string { return trimmed; } +/** Returns whether an error came from cron run-log job id validation. */ export function isInvalidCronRunLogJobIdError(err: unknown): boolean { return err instanceof Error && err.message === INVALID_CRON_RUN_LOG_JOB_ID_MESSAGE; } const writesByTarget = new Map>(); +/** Legacy byte cap kept for config parsing compatibility with older file-backed run logs. */ export const DEFAULT_CRON_RUN_LOG_MAX_BYTES = 2_000_000; +/** Default SQLite row retention per cron job when no explicit keepLines value is configured. */ export const DEFAULT_CRON_RUN_LOG_KEEP_LINES = 2_000; +/** Resolves configured run-log pruning limits while preserving legacy maxBytes parsing. */ export function resolveCronRunLogPruneOptions(cfg?: CronConfig["runLog"]): { maxBytes: number; keepLines: number; @@ -106,6 +110,7 @@ export function resolveCronRunLogPruneOptions(cfg?: CronConfig["runLog"]): { return { maxBytes, keepLines }; } +/** Exposes the in-process async write queue size for run-log concurrency tests. */ export function getPendingCronRunLogWriteCountForTests() { return writesByTarget.size; } @@ -126,6 +131,7 @@ async function drainPendingWrite(storePath: string, jobId?: string): Promise undefined) .then(async () => { @@ -159,6 +166,7 @@ export async function appendCronRunLog(params: { } } +/** Reads recent run-log entries in chronological order after draining pending async writes. */ export async function readCronRunLogEntries(params: { storePath: string; jobId?: string; @@ -177,6 +185,7 @@ export async function readCronRunLogEntries(params: { return page.entries.toReversed(); } +/** Reads recent run-log entries synchronously for startup/task reconciliation paths. */ export function readCronRunLogEntriesSync(params: { storePath: string; jobId?: string; @@ -281,6 +290,7 @@ function filterRunLogEntries( }); } +/** Reads a bounded, filterable run-log page for CLI and UI list views. */ export async function readCronRunLogEntriesPage( opts: ReadCronRunLogPageOptions & { storePath: string; jobNameById?: Record }, ): Promise { @@ -395,6 +405,7 @@ export async function readCronRunLogEntriesPage( }; } +/** Reads a run-log page across all jobs for a specific cron store. */ export async function readCronRunLogEntriesPageAll( opts: ReadCronRunLogAllPageOptions, ): Promise { diff --git a/src/cron/run-log/entry-codec.ts b/src/cron/run-log/entry-codec.ts index 3c4bccecd6b..f8459cc775b 100644 --- a/src/cron/run-log/entry-codec.ts +++ b/src/cron/run-log/entry-codec.ts @@ -28,6 +28,7 @@ function normalizeCronRunLogErrorReason(value: unknown): FailoverReason | undefi : undefined; } +/** Parses a persisted cron run-log entry object and drops invalid or wrong-job rows. */ export function parseCronRunLogEntryObject( obj: unknown, opts?: { jobId?: string }, diff --git a/src/cron/run-log/sqlite-store.ts b/src/cron/run-log/sqlite-store.ts index 19491ecc2de..770bbbd3e97 100644 --- a/src/cron/run-log/sqlite-store.ts +++ b/src/cron/run-log/sqlite-store.ts @@ -74,6 +74,7 @@ function bindCronRunLogRow(params: { }; } +/** Rehydrates a cron run-log row, preferring indexed SQLite columns over JSON payload values. */ export function parseStoredRunLogEntry(row: CronRunLogRow): CronRunLogEntry | null { let rawEntry: unknown; try { @@ -106,6 +107,7 @@ export function parseStoredRunLogEntry(row: CronRunLogRow): CronRunLogEntry | nu }; } +/** Reads run-log rows for one store, optionally scoped to one job, in chronological order. */ export function readCronRunLogRows( db: DatabaseSync, storeKey: string, @@ -136,6 +138,8 @@ function applyRunLogFilters( next = next.where((eb) => eb.or( params.deliveryStatuses!.map((status) => + // Older rows stored an omitted delivery status as SQL NULL; keep + // not-requested filters compatible with both representations. status === "not-requested" ? eb.or([eb("delivery_status", "is", null), eb("delivery_status", "=", status)]) : eb("delivery_status", "=", status), @@ -150,6 +154,7 @@ function applyRunLogFilters( return next; } +/** Counts run-log rows after applying the same filters used by paged reads. */ export function countCronRunLogRows(params: { db: DatabaseSync; storeKey: string; @@ -170,6 +175,7 @@ export function countCronRunLogRows(params: { return normalizeNumber(row?.count ?? null) ?? 0; } +/** Reads a sorted, filtered page of cron run-log rows. */ export function readCronRunLogRowsPage(params: { db: DatabaseSync; storeKey: string; @@ -205,6 +211,7 @@ function nextCronRunLogSeq(db: DatabaseSync, storeKey: string, jobId: string): n return (normalizeNumber(row?.seq ?? null) ?? 0) + 1; } +/** Appends a cron run-log entry with a per-job monotonic sequence number. */ export function insertCronRunLogEntry( db: DatabaseSync, storeKey: string, @@ -219,6 +226,7 @@ export function insertCronRunLogEntry( ); } +/** Prunes old cron run-log rows for one job, retaining the newest keepLines rows. */ export function pruneCronRunLogRows( db: DatabaseSync, storeKey: string, diff --git a/src/cron/schedule-identity.ts b/src/cron/schedule-identity.ts index bfa5ad52d8d..34166afe5b5 100644 --- a/src/cron/schedule-identity.ts +++ b/src/cron/schedule-identity.ts @@ -65,6 +65,7 @@ function resolveSchedulePayload( return undefined; } +/** Builds a stable scheduling identity for deciding whether stored timer state is still valid. */ export function tryCronScheduleIdentity(job: CronScheduleIdentityInput): string | undefined { const schedule = resolveSchedulePayload(job); if (!schedule) { @@ -77,6 +78,7 @@ export function tryCronScheduleIdentity(job: CronScheduleIdentityInput): string }); } +/** Compares two cron jobs by the normalized inputs that affect next-run computation. */ export function cronSchedulingInputsEqual( previous: CronScheduleIdentityInput, next: CronScheduleIdentityInput, diff --git a/src/cron/schedule-number.ts b/src/cron/schedule-number.ts index 3a71162a6f6..c0e51e209eb 100644 --- a/src/cron/schedule-number.ts +++ b/src/cron/schedule-number.ts @@ -1,5 +1,6 @@ import { parseStrictFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +/** Coerces schedule numeric fields without accepting partial or non-finite numbers. */ export function coerceFiniteScheduleNumber(value: unknown): number | undefined { return parseStrictFiniteNumber(value); } diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 651f2d382cb..be2ac52f72e 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -21,7 +21,7 @@ function resolveCachedCron(expr: string, timezone: string): Cron { const key = `${timezone}\u0000${expr}`; const cached = cronEvalCache.get(key); if (cached) { - // Move to end of Map iteration order for LRU eviction + // Move to the end of Map iteration order so the bounded cache behaves as LRU. cronEvalCache.delete(key); cronEvalCache.set(key, cached); return cached; @@ -48,6 +48,7 @@ function resolveCronFromSchedule(schedule: { tz?: string; expr?: unknown }): Cro return resolveCachedCron(expr, resolveCronTimezone(schedule.tz)); } +/** Computes the next scheduled run timestamp after now for at/every/cron schedules. */ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined { if (schedule.kind === "at") { const atMs = parseAbsoluteTimeMs(schedule.at); @@ -114,6 +115,7 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe return nextMs; } +/** Computes the previous cron-expression run timestamp before now. */ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined { if (schedule.kind !== "cron") { return undefined; @@ -137,18 +139,22 @@ export function computePreviousRunAtMs(schedule: CronSchedule, nowMs: number): n return previousMs; } +/** Clears the Croner expression cache for deterministic tests. */ export function clearCronScheduleCacheForTest(): void { cronEvalCache.clear(); } +/** Returns the Croner expression cache size for tests. */ export function getCronScheduleCacheSizeForTest(): number { return cronEvalCache.size; } +/** Returns the Croner expression cache capacity for tests. */ export function getCronScheduleCacheMaxForTest(): number { return CRON_EVAL_CACHE_MAX; } +/** Returns whether an expression/timezone pair is present in the Croner cache for tests. */ export function hasCronInCacheForTest(expr: string, tz: string): boolean { return cronEvalCache.has(`${tz}\u0000${expr}`); } diff --git a/src/cron/service-contract.ts b/src/cron/service-contract.ts index 3d579f73f50..de96499c713 100644 --- a/src/cron/service-contract.ts +++ b/src/cron/service-contract.ts @@ -15,8 +15,10 @@ import type { CronJob } from "./types.js"; type CronWakeResult = { ok: true } | { ok: false; reason?: "unwakeable-session-key" }; +/** Result shape for direct/queued cron runs, including invalid persisted specs. */ export type CronServiceRunResult = CronRunResult | { ok: true; ran: false; reason: "invalid-spec" }; +/** Public cron service facade used by gateway, plugin SDK, and tests. */ export interface CronServiceContract { start(): Promise; stop(): void; diff --git a/src/cron/service.ts b/src/cron/service.ts index 539ab81426c..23704b3fc8e 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -10,8 +10,10 @@ import type { CronJob, CronJobCreate, CronJobPatch } from "./types.js"; export type { CronEvent, CronServiceDeps } from "./service/state.js"; +/** Public cron service facade that owns mutable scheduler state and delegates to locked ops. */ export class CronService implements CronServiceContract { private readonly state; + constructor(deps: CronServiceDeps) { this.state = createCronServiceState(deps); } @@ -55,6 +57,8 @@ export class CronService implements CronServiceContract { async enqueueRun(id: string, mode?: "due" | "force"): Promise { const result = await ops.enqueueRun(this.state, id, mode); if (result.ok && "runnable" in result) { + // ops.enqueueRun resolves runnable dispositions before crossing the + // public facade; leaking one would expose an internal scheduler detail. throw new Error("cron enqueueRun returned unresolved runnable disposition"); } return result; diff --git a/src/cron/service/agent-watchdog.ts b/src/cron/service/agent-watchdog.ts index d902225b8b2..f996a494485 100644 --- a/src/cron/service/agent-watchdog.ts +++ b/src/cron/service/agent-watchdog.ts @@ -25,6 +25,8 @@ type CronAgentWatchdogState = type CronAgentPhaseWatchdogStage = "pre_execution" | "execution"; +// Phase ordering is not strictly monotonic during fallback attempts, so each +// emitted phase is mapped to the watchdog bucket that should keep timing it. const CRON_AGENT_PHASE_WATCHDOG_STAGE = { runner_entered: "pre_execution", workspace: "pre_execution", @@ -42,6 +44,7 @@ const CRON_AGENT_PHASE_WATCHDOG_STAGE = { model_call_started: "execution", } as const satisfies Record; +/** Handle for feeding isolated-agent progress into cron timeout watchdogs. */ export type CronAgentWatchdog = { start: () => void; noteRunnerStarted: (info?: CronAgentExecutionStarted) => void; @@ -50,6 +53,7 @@ export type CronAgentWatchdog = { dispose: () => void; }; +/** Tracks isolated-agent setup/execution progress and fires the correct cron timeout reason. */ export function createCronAgentWatchdog(params: { deferUntilRunner: boolean; jobTimeoutMs: number; @@ -112,6 +116,8 @@ export function createCronAgentWatchdog(params: { previousPhase === "before_agent_reply" && stage === "pre_execution" ) { + // Model fallback can move from an execution phase back into setup-like + // phases; restart the pre-execution watchdog so fallback stalls are seen. state = "waiting_for_execution"; startPreExecutionTimeout(); return; @@ -164,6 +170,7 @@ export function createCronAgentWatchdog(params: { }; } +/** Runs timeout cleanup with a guard so stuck cleanup cannot block the cron lane. */ export async function cleanupTimedOutCronAgentRun( state: CronServiceState, job: CronJob, diff --git a/src/cron/service/execution-errors.ts b/src/cron/service/execution-errors.ts index 419f9ceb7f7..0fef8253cd8 100644 --- a/src/cron/service/execution-errors.ts +++ b/src/cron/service/execution-errors.ts @@ -5,6 +5,7 @@ function formatCronAgentExecutionPhase(execution?: CronAgentExecutionStarted): s return formatEmbeddedAgentExecutionPhase(execution?.phase); } +/** Formats the generic cron execution timeout message with last-known phase context when available. */ export function timeoutErrorMessage(execution?: CronAgentExecutionStarted): string { const phase = formatCronAgentExecutionPhase(execution); if (!phase) { @@ -13,6 +14,7 @@ export function timeoutErrorMessage(execution?: CronAgentExecutionStarted): stri return `cron: job execution timed out (last phase: ${phase})`; } +/** Formats timeout text for runs that stalled before the isolated runner started. */ export function setupTimeoutErrorMessage(execution?: CronAgentExecutionStarted): string { const phase = formatCronAgentExecutionPhase(execution); if (!phase) { @@ -21,6 +23,7 @@ export function setupTimeoutErrorMessage(execution?: CronAgentExecutionStarted): return `cron: isolated agent setup timed out before runner start (last phase: ${phase})`; } +/** Formats timeout text for runs that stalled after setup but before execution start. */ export function preExecutionTimeoutErrorMessage(execution?: CronAgentExecutionStarted): string { const phase = formatCronAgentExecutionPhase(execution); if (!phase) { @@ -29,6 +32,7 @@ export function preExecutionTimeoutErrorMessage(execution?: CronAgentExecutionSt return `cron: isolated agent run stalled before execution start (last phase: ${phase})`; } +/** Extracts a human timeout/abort reason, falling back to the canonical cron timeout text. */ export function abortErrorMessage(signal?: AbortSignal): string { const reason = signal?.reason; if (typeof reason === "string" && reason.trim()) { @@ -44,6 +48,7 @@ function isAbortError(err: unknown): boolean { return err.name === "AbortError" || err.message === timeoutErrorMessage(); } +/** Normalizes thrown cron run failures into stable log/run-log text. */ export function normalizeCronRunErrorText(err: unknown): string { if (isAbortError(err)) { return timeoutErrorMessage(); diff --git a/src/cron/service/failure-alerts.ts b/src/cron/service/failure-alerts.ts index 1550e7cdb68..7615859a13e 100644 --- a/src/cron/service/failure-alerts.ts +++ b/src/cron/service/failure-alerts.ts @@ -16,6 +16,7 @@ type ResolvedFailureAlert = { includeSkipped: boolean; }; +/** Returns the last failure-notification delivery trace persisted on a cron job. */ export function failureNotificationDeliveryFromJobState( job: CronJob, ): CronFailureNotificationDelivery | undefined { @@ -59,6 +60,7 @@ function clampNonNegativeInt(value: unknown, fallback: number): number { return floored >= 0 ? floored : fallback; } +/** Resolves effective failure-alert policy from job config, delivery defaults, and global cron config. */ export function resolveFailureAlert( state: CronServiceState, job: CronJob, @@ -113,6 +115,8 @@ function emitFailureAlert( params.status === "error" && typeof params.error === "string" ? (resolveFailoverReasonFromError(params.error, params.provider) ?? undefined) : undefined; + // Keep alert bodies compact because they may route through chat channels + // with notification previews and provider-specific message limits. const statusVerb = params.status === "skipped" ? "skipped" : "failed"; const detailLabel = params.status === "skipped" ? "Skip reason" : "Last error"; const text = [ @@ -150,6 +154,7 @@ function emitFailureAlert( } } +/** Emits a failure alert when threshold, best-effort, and cooldown policy allow it. */ export function maybeEmitFailureAlert( state: CronServiceState, params: { @@ -170,6 +175,8 @@ export function maybeEmitFailureAlert( } const now = state.deps.nowMs(); const lastAlert = params.job.state.lastFailureAlertAtMs; + // Cooldown is stored on job state so process restarts and service reloads do + // not spam operators with repeated alerts for the same failing job. const inCooldown = typeof lastAlert === "number" && now - lastAlert < Math.max(0, params.alertConfig.cooldownMs); if (inCooldown) { diff --git a/src/cron/service/initial-delivery.ts b/src/cron/service/initial-delivery.ts index 9dc2eb908d5..b934b3aa87f 100644 --- a/src/cron/service/initial-delivery.ts +++ b/src/cron/service/initial-delivery.ts @@ -1,5 +1,6 @@ import type { CronDelivery, CronJobCreate } from "../types.js"; +/** Resolves default cron delivery for new jobs when callers omit explicit delivery config. */ export function resolveInitialCronDelivery(input: CronJobCreate): CronDelivery | undefined { if (input.delivery) { return input.delivery; diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 2f7b16e3a05..d58bd9e7fa3 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -38,6 +38,8 @@ import type { CronServiceState } from "./state.js"; const STUCK_RUN_MS = 2 * 60 * 60 * 1000; const STAGGER_OFFSET_CACHE_MAX = 4096; const staggerOffsetCache = new Map(); + +/** Default retry delays applied after consecutive cron execution errors. */ export const DEFAULT_ERROR_BACKOFF_SCHEDULE_MS = [ 30_000, 60_000, @@ -50,14 +52,17 @@ function isFiniteTimestamp(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } +/** Returns whether a stored next-run timestamp is finite and schedulable. */ export function hasScheduledNextRunAtMs(value: unknown): value is number { return isFiniteTimestamp(value) && value > 0; } +/** Resolves the newest persisted cron run status while older state is still readable. */ export function resolveJobLastRunStatus(job: Pick) { return job.state.lastRunStatus ?? job.state.lastStatus; } +/** Resolves the retry backoff delay for a one-based consecutive error count. */ export function errorBackoffMs( consecutiveErrors: number, scheduleMs = DEFAULT_ERROR_BACKOFF_SCHEDULE_MS, @@ -66,6 +71,7 @@ export function errorBackoffMs( return scheduleMs[Math.max(0, idx)] ?? DEFAULT_ERROR_BACKOFF_SCHEDULE_MS[0]; } +/** Returns the earliest retry timestamp after a failed cron run and its runtime duration. */ export function resolveJobErrorBackoffUntilMs( job: CronJob, scheduleMs = DEFAULT_ERROR_BACKOFF_SCHEDULE_MS, @@ -98,6 +104,8 @@ function resolveStableCronOffsetMs(jobId: string, staggerMs: number) { const digest = crypto.createHash("sha256").update(jobId).digest(); const offset = digest.readUInt32BE(0) % staggerMs; if (staggerOffsetCache.size >= STAGGER_OFFSET_CACHE_MAX) { + // The offset is deterministic, so the cache can evict oldest entries + // without changing scheduling semantics for future lookups. const first = staggerOffsetCache.keys().next(); if (!first.done) { staggerOffsetCache.delete(first.value); @@ -260,6 +268,7 @@ function resolveEveryAnchorMs(params: { return 0; } +/** Validates that session target and payload kind form a supported cron job shape. */ export function assertSupportedJobSpec(job: Pick) { if (typeof job.sessionTarget !== "string") { throw new Error( @@ -366,6 +375,7 @@ function assertFailureDestinationSupport(job: Pick j.id === id); if (!job) { @@ -374,10 +384,12 @@ export function findJobOrThrow(state: CronServiceState, id: string) { return job; } +/** Returns the effective enabled flag, defaulting missing values to enabled. */ export function isJobEnabled(job: Pick): boolean { return job.enabled ?? true; } +/** Computes the next run timestamp for enabled jobs across every/at/cron schedules. */ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | undefined { if (!isJobEnabled(job)) { return undefined; @@ -423,6 +435,7 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und return isFiniteTimestamp(next) ? next : undefined; } +/** Computes the previous effective cron timestamp, including per-job staggering. */ export function computeJobPreviousRunAtMs(job: CronJob, nowMs: number): number | undefined { if (!isJobEnabled(job) || job.schedule.kind !== "cron") { return undefined; @@ -434,6 +447,7 @@ export function computeJobPreviousRunAtMs(job: CronJob, nowMs: number): number | /** Maximum consecutive schedule errors before auto-disabling a job. */ const MAX_SCHEDULE_ERRORS = 3; +/** Records a schedule-computation failure and auto-disables after repeated errors. */ export function recordScheduleComputeError(params: { state: CronServiceState; job: CronJob; @@ -597,6 +611,7 @@ function recomputeJobNextRunAtMs(params: { state: CronServiceState; job: CronJob return changed; } +/** Recomputes missing, due, or repairable next-run timestamps for all schedulable jobs. */ export function recomputeNextRuns(state: CronServiceState): boolean { return walkSchedulableJobs(state, ({ job, nowMs: now }) => { let changed = false; @@ -672,6 +687,7 @@ export function recomputeNextRunsForMaintenance( ); } +/** Returns the next enabled wake timestamp from the in-memory cron store. */ export function nextWakeAtMs(state: CronServiceState) { const jobs = state.store?.jobs ?? []; const enabled = jobs.filter((j) => j.enabled && hasScheduledNextRunAtMs(j.state.nextRunAtMs)); @@ -688,6 +704,7 @@ export function nextWakeAtMs(state: CronServiceState) { }, first); } +/** Creates a normalized cron job row from public add input and computes its initial schedule. */ export function createJob(state: CronServiceState, input: CronJobCreate): CronJob { const now = state.deps.nowMs(); const id = crypto.randomUUID(); @@ -747,6 +764,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo return job; } +/** Applies a public cron patch in-place, preserving omitted nested fields and validating the result. */ export function applyJobPatch( job: CronJob, patch: CronJobPatch, @@ -770,6 +788,8 @@ export function applyJobPatch( if (explicitStaggerMs !== undefined) { job.schedule = { ...patch.schedule, staggerMs: explicitStaggerMs }; } else if (job.schedule.kind === "cron") { + // Preserve an existing explicit stagger when editing only the cron + // expression; otherwise a patch could silently change fire timing. job.schedule = { ...patch.schedule, staggerMs: job.schedule.staggerMs }; } else { const defaultStaggerMs = resolveDefaultCronStaggerMs(patch.schedule.expr); @@ -916,6 +936,8 @@ function mergeCronDelivery( const previousMode = next.mode; next.mode = (patch.mode as string) === "deliver" ? "announce" : patch.mode; if (previousMode !== next.mode && (previousMode === "webhook" || next.mode === "webhook")) { + // `to` has different meaning for channel targets and webhook URLs; clear + // it when crossing that boundary so stale destinations do not leak. next.to = undefined; } if (next.mode === "webhook") { @@ -1041,6 +1063,7 @@ function mergeCronFailureAlert( return next; } +/** Returns whether a cron job should execute at `nowMs`, honoring force mode and active runs. */ export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) { if (!job.state) { job.state = {}; @@ -1058,6 +1081,7 @@ export function isJobDue(job: CronJob, nowMs: number, opts: { forced: boolean }) ); } +/** Returns main-session queue text for system-event jobs, or undefined when empty/unsupported. */ export function resolveJobPayloadTextForMain(job: CronJob): string | undefined { if (job.payload.kind !== "systemEvent") { return undefined; diff --git a/src/cron/service/list-page-types.ts b/src/cron/service/list-page-types.ts index cf3bc568d1c..455d2ed3c67 100644 --- a/src/cron/service/list-page-types.ts +++ b/src/cron/service/list-page-types.ts @@ -1,11 +1,21 @@ import type { CronJob, CronRunStatus } from "../types.js"; +/** Enabled-state filter accepted by paginated cron listing. */ export type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; + +/** Schedule-kind filter accepted by paginated cron listing. */ export type CronJobsScheduleKindFilter = "all" | "at" | "every" | "cron"; + +/** Last-run status filter, including jobs that have not produced a status yet. */ export type CronJobsLastRunStatusFilter = "all" | CronRunStatus | "unknown"; + +/** Stable sort keys supported by paginated cron listing. */ export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; + +/** Sort direction for paginated cron listing. */ export type CronSortDir = "asc" | "desc"; +/** Input contract for filtered, sorted, offset-based cron job pages. */ export type CronListPageOptions = { includeDisabled?: boolean; limit?: number; @@ -19,6 +29,7 @@ export type CronListPageOptions = { agentId?: string; }; +/** Offset-page result returned by cron listPage callers. */ export type CronListPageResult = { jobs: TJobs; total: number; diff --git a/src/cron/service/locked.ts b/src/cron/service/locked.ts index b73096678c8..c16ca5824ac 100644 --- a/src/cron/service/locked.ts +++ b/src/cron/service/locked.ts @@ -8,12 +8,14 @@ const resolveChain = (promise: Promise) => () => undefined, ); +/** Serializes cron operations per store path while preserving state-local operation ordering. */ export async function locked(state: CronServiceState, fn: () => Promise): Promise { const storePath = state.deps.storePath; const storeOp = storeLocks.get(storePath) ?? Promise.resolve(); const next = Promise.all([resolveChain(state.op), resolveChain(storeOp)]).then(fn); - // Keep the chain alive even when the operation fails. + // Store locks are process-local; keep the chain alive after failures so the + // next operation for this store still waits for the failed one to settle. const keepAlive = resolveChain(next); state.op = keepAlive; storeLocks.set(storePath, keepAlive); diff --git a/src/cron/service/normalize.ts b/src/cron/service/normalize.ts index 9a0c1855cba..34d596757f9 100644 --- a/src/cron/service/normalize.ts +++ b/src/cron/service/normalize.ts @@ -3,6 +3,7 @@ import { normalizeAgentId } from "../../routing/session-key.js"; import { truncateUtf16Safe } from "../../utils.js"; import type { CronPayload } from "../types.js"; +/** Normalizes a required cron job name and throws the public validation error when absent. */ export function normalizeRequiredName(raw: unknown) { if (typeof raw !== "string") { throw new Error("cron job name is required"); @@ -21,6 +22,7 @@ function truncateText(input: string, maxLen: number) { return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}…`; } +/** Normalizes optional cron agent ids through the canonical session-key agent id rules. */ export function normalizeOptionalAgentId(raw: unknown) { const trimmed = normalizeOptionalString(raw); if (!trimmed) { @@ -29,6 +31,7 @@ export function normalizeOptionalAgentId(raw: unknown) { return normalizeAgentId(trimmed); } +/** Infers a compact cron job name from payload text first, then schedule shape. */ export function inferCronJobName(job: { schedule?: { kind?: unknown; everyMs?: unknown; expr?: unknown }; payload?: { kind?: unknown; text?: unknown; message?: unknown }; @@ -45,6 +48,8 @@ export function inferCronJobName(job: { .map((l) => l.trim()) .find(Boolean) ?? ""; if (firstLine) { + // Names appear in CLI lists and alerts; keep them single-line and UTF-16 + // safe so emoji/surrogate pairs are not split by truncation. return truncateText(firstLine, 60); } @@ -61,6 +66,7 @@ export function inferCronJobName(job: { return "Cron job"; } +/** Extracts the executable text from cron payload variants for main-session queueing. */ export function normalizePayloadToSystemText(payload: CronPayload) { if (payload.kind === "systemEvent") { return typeof payload.text === "string" ? payload.text.trim() : ""; diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 60c773309e2..dd63fa9bb8a 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -163,6 +163,7 @@ async function ensureLoadedForRead(state: CronServiceState) { } } +/** Starts the cron service, recovers interrupted runs, catches up missed jobs, and arms the timer. */ export async function start(state: CronServiceState) { if (!state.deps.cronEnabled) { state.deps.log.info({ enabled: false }, "cron: disabled"); @@ -238,10 +239,12 @@ export async function start(state: CronServiceState) { }); } +/** Stops the cron service timer without mutating persisted job state. */ export function stop(state: CronServiceState) { stopTimer(state); } +/** Returns cron service status after a read-only maintenance pass. */ export async function status(state: CronServiceState) { return await locked(state, async () => { await ensureLoadedForRead(state); @@ -254,6 +257,7 @@ export async function status(state: CronServiceState) { }); } +/** Lists cron jobs sorted by next run time, excluding disabled jobs unless requested. */ export async function list(state: CronServiceState, opts?: { includeDisabled?: boolean }) { return await locked(state, async () => { await ensureLoadedForRead(state); @@ -263,6 +267,7 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b }); } +/** Reads one cron job by id without advancing due schedules. */ export async function readJob(state: CronServiceState, id: string) { return await locked(state, async () => { await ensureLoadedForRead(state); @@ -346,6 +351,7 @@ function resolveEffectiveJobAgentId(job: CronJob, defaultAgentId: string | undef ); } +/** Lists a filtered, sorted, bounded page of cron jobs for CLI/RPC callers. */ export async function listPage(state: CronServiceState, opts?: CronListPageOptions) { return await locked(state, async () => { await ensureLoadedForRead(state); @@ -402,6 +408,7 @@ export async function listPage(state: CronServiceState, opts?: CronListPageOptio }); } +/** Adds a cron job, recomputes scheduler state, persists, and re-arms the timer. */ export async function add(state: CronServiceState, input: CronJobCreate) { return await locked(state, async () => { warnIfDisabled(state, "add"); @@ -437,6 +444,7 @@ export async function add(state: CronServiceState, input: CronJobCreate) { }); } +/** Updates a cron job patch in-place, recomputes affected schedule state, and persists it. */ export async function update(state: CronServiceState, id: string, patch: CronJobPatch) { return await locked(state, async () => { warnIfDisabled(state, "update"); @@ -499,6 +507,7 @@ export async function update(state: CronServiceState, id: string, patch: CronJob }); } +/** Removes a cron job by id and re-arms the timer when the in-memory store changes. */ export async function remove(state: CronServiceState, id: string) { return await locked(state, async () => { warnIfDisabled(state, "remove"); @@ -722,6 +731,8 @@ async function inspectManualRunDisposition( id: string, mode?: "due" | "force", ): Promise { + // Queue callers need a cheap eligibility check before entering the command + // lane; the real reservation happens later under lock in prepareManualRun. const result = await inspectManualRunPreflight(state, id, mode); if (!result.ok) { return result; @@ -768,6 +779,8 @@ async function prepareManualRun( startedAt: preflight.now, }); markCronJobActive(job.id); + // Execute against a snapshot so later reload/merge can preserve delivery + // target writeback from disk without mutating the running object. const executionJob = structuredClone(job); return { ok: true, @@ -886,6 +899,7 @@ async function finishPreparedManualRun( } } +/** Runs a cron job manually, reserving it under lock before executing outside the lock. */ export async function run( state: CronServiceState, id: string, @@ -900,6 +914,7 @@ export async function run( return { ok: true, ran: true } as const; } +/** Queues a manual cron run behind the cron command lane and returns an immediate run id. */ export async function enqueueRun(state: CronServiceState, id: string, mode?: "due" | "force") { const disposition = await inspectManualRunDisposition(state, id, mode); if (!disposition.ok || !("runnable" in disposition && disposition.runnable)) { @@ -937,6 +952,7 @@ export async function enqueueRun(state: CronServiceState, id: string, mode?: "du return { ok: true, enqueued: true, runId } as const; } +/** Enqueues manual wake text through the cron wake API. */ export function wakeNow( state: CronServiceState, opts: { mode: CronWakeMode; text: string; sessionKey?: string }, diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index b6fb8a84779..705ea83a991 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -19,6 +19,7 @@ import type { CronStoreFile, } from "../types.js"; +/** Event payload emitted for cron lifecycle changes and completed runs. */ export type CronEvent = { jobId: string; action: "added" | "updated" | "removed" | "started" | "finished"; @@ -41,6 +42,7 @@ export type CronEvent = { nextRunAtMs?: number; } & CronRunTelemetry; +/** Logger contract consumed by cron service internals. */ export type Logger = { debug: (obj: unknown, msg?: string) => void; info: (obj: unknown, msg?: string) => void; @@ -48,6 +50,7 @@ export type Logger = { error: (obj: unknown, msg?: string) => void; }; +/** Dependency injection surface for the cron service runtime. */ export type CronServiceDeps = { nowMs?: () => number; log: Logger; @@ -147,15 +150,18 @@ export type CronServiceDeps = { onEvent?: (evt: CronEvent) => void; }; +/** Cron deps after optional defaults have been made concrete. */ export type CronServiceDepsInternal = Omit & { nowMs: () => number; }; +/** Mutable cron service state shared across store, job, timer, and ops helpers. */ export type CronServiceState = { deps: CronServiceDepsInternal; store: CronStoreFile | null; timer: NodeJS.Timeout | null; running: boolean; + /** Serializes mutating service operations so store writes and timers stay ordered. */ op: Promise; warnedDisabled: boolean; /** @@ -168,6 +174,7 @@ export type CronServiceState = { storeLoadedAtMs: number | null; }; +/** Creates mutable cron service state with a concrete clock dependency. */ export function createCronServiceState(deps: CronServiceDeps): CronServiceState { return { deps: { ...deps, nowMs: deps.nowMs ?? (() => Date.now()) }, @@ -183,9 +190,13 @@ export function createCronServiceState(deps: CronServiceDeps): CronServiceState }; } +/** Direct-run mode: respect due time or force execution. */ export type CronRunMode = "due" | "force"; + +/** Main-session wake strategy used after enqueuing cron text. */ export type CronWakeMode = "now" | "next-heartbeat"; +/** Lightweight service status returned to gateway/control surfaces. */ export type CronStatusSummary = { enabled: boolean; storePath: string; @@ -193,6 +204,7 @@ export type CronStatusSummary = { nextWakeAtMs: number | null; }; +/** Result shape for immediate or queued cron run requests. */ export type CronRunResult = | { ok: true; ran: true } | { ok: true; enqueued: true; runId: string } @@ -200,11 +212,17 @@ export type CronRunResult = | { ok: true; ran: false; reason: "already-running" } | { ok: false }; +/** Remove result that distinguishes missing jobs from failed removal. */ export type CronRemoveResult = { ok: true; removed: boolean } | { ok: false; removed: false }; +/** Created cron job returned by service mutation calls. */ export type CronAddResult = CronJob; +/** Updated cron job returned by service mutation calls. */ export type CronUpdateResult = CronJob; +/** Chronological job list returned by service read calls. */ export type CronListResult = CronJob[]; +/** Normalized create input accepted by the cron service. */ export type CronAddInput = CronJobCreate; +/** Normalized patch input accepted by cron service updates. */ export type CronUpdateInput = CronJobPatch; diff --git a/src/cron/service/store.ts b/src/cron/service/store.ts index ae8fe2be920..d7d8a9cbed3 100644 --- a/src/cron/service/store.ts +++ b/src/cron/service/store.ts @@ -81,6 +81,7 @@ async function flushPendingQuarantine( } } +/** Loads and normalizes the cron store, quarantining invalid persisted rows before runtime use. */ export async function ensureLoaded( state: CronServiceState, opts?: { @@ -109,6 +110,8 @@ export async function ensureLoaded( const raw = decodedRaw; const sourceIndex = loaded.configJobIndexes[index] ?? index; const runtimeEntry = loaded.configJobRuntimeEntries[index]; + // Accept old `jobId` rows at the raw boundary only; the in-memory store + // uses canonical `id` before validation and scheduling. normalizeCronJobIdentityFields(raw); let normalized: Record | null; try { @@ -136,6 +139,8 @@ export async function ensureLoaded( }; const runtimeState = runtimeEntry?.state ?? raw.state; if (runtimeState && typeof runtimeState === "object" && !Array.isArray(runtimeState)) { + // Preserve runtime state with the quarantined config so doctor can + // repair shape without losing last/next run information. quarantineEntry.state = structuredClone(runtimeState as Record); } const updatedAtMs = runtimeEntry?.updatedAtMs ?? raw.updatedAtMs; @@ -189,6 +194,7 @@ export async function ensureLoaded( } } +/** Emits the cron-disabled warning once per service state. */ export function warnIfDisabled(state: CronServiceState, action: string) { if (state.deps.cronEnabled) { return; @@ -203,6 +209,7 @@ export function warnIfDisabled(state: CronServiceState, action: string) { ); } +/** Persists the in-memory cron store, flushing pending quarantine records first. */ export async function persist(state: CronServiceState, opts?: { stateOnly?: boolean }) { if (!state.store) { return; diff --git a/src/cron/service/task-ledger.ts b/src/cron/service/task-ledger.ts index 24e415a35b1..6397a9c3c7f 100644 --- a/src/cron/service/task-ledger.ts +++ b/src/cron/service/task-ledger.ts @@ -1 +1,2 @@ +/** Progress summary shown while a detached task ledger row represents an active cron run. */ export const CRON_TASK_RUNNING_PROGRESS_SUMMARY = "Running cron job."; diff --git a/src/cron/service/task-runs.ts b/src/cron/service/task-runs.ts index f81c1e5aad0..23e03ab9ffa 100644 --- a/src/cron/service/task-runs.ts +++ b/src/cron/service/task-runs.ts @@ -16,6 +16,7 @@ import { normalizeCronRunErrorText, timeoutErrorMessage } from "./execution-erro import type { CronServiceState } from "./state.js"; import { CRON_TASK_RUNNING_PROGRESS_SUMMARY } from "./task-ledger.js"; +/** Converts cron ids into bounded session-key path segments with a fallback for empty input. */ export function normalizeCronLaneSegment(value: string | undefined, fallback: string): string { const normalized = normalizeOptionalLowercaseString(value) ?.replace(/[^a-z0-9_-]+/g, "-") @@ -24,6 +25,7 @@ export function normalizeCronLaneSegment(value: string | undefined, fallback: st return normalized || fallback; } +/** Builds the main-session child key used to isolate one cron run's task transcript. */ export function resolveMainSessionCronRunSessionKey(job: CronJob, startedAt: number): string { const explicitAgentId = job.agentId?.trim(); const agentId = normalizeAgentId(explicitAgentId || resolveAgentIdFromSessionKey(job.sessionKey)); @@ -42,6 +44,8 @@ function resolveCronTaskChildSessionKey(params: { } const explicitSessionKey = params.job.sessionKey?.trim(); if (explicitSessionKey) { + // Explicit session bindings must win over generated cron session keys so + // task drill-down opens the same transcript the cron run actually used. return explicitSessionKey; } if (params.job.sessionTarget !== "isolated") { @@ -53,6 +57,7 @@ function resolveCronTaskChildSessionKey(params: { }); } +/** Creates a best-effort detached task ledger row for a cron run. */ export function tryCreateCronTaskRun(params: { state: CronServiceState; job: CronJob; @@ -93,6 +98,7 @@ export function tryCreateCronTaskRun(params: { } } +/** Completes or fails the detached task ledger row for a cron run when one exists. */ export function tryFinishCronTaskRun( state: CronServiceState, result: { diff --git a/src/cron/service/timeout-policy.ts b/src/cron/service/timeout-policy.ts index b7a155ea0b9..323e534a148 100644 --- a/src/cron/service/timeout-policy.ts +++ b/src/cron/service/timeout-policy.ts @@ -14,6 +14,7 @@ export const DEFAULT_JOB_TIMEOUT_MS = 10 * 60_000; // 10 minutes */ export const AGENT_TURN_SAFETY_TIMEOUT_MS = 60 * 60_000; // 60 minutes +/** Resolves the wall-clock timeout for a cron job, including explicit agent-turn overrides. */ export function resolveCronJobTimeoutMs(job: CronJob): number | undefined { const configuredTimeoutMs = job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number" diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index f7283f534cf..28dc6a0b219 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -116,6 +116,7 @@ type StartupCatchupPlan = { deferredJobs: StartupDeferredJob[]; }; +/** Executes cron job core logic with the configured wall-clock timeout and watchdog cleanup. */ export async function executeJobCoreWithTimeout( state: CronServiceState, job: CronJob, @@ -133,6 +134,8 @@ export async function executeJobCoreWithTimeout( resolveTimeout = resolve; }); + // Detached agent runs report setup phases separately; defer the wall-clock + // timeout until the runner starts so cold setup gets a clearer failure reason. const deferTimeoutUntilExecutionStart = job.sessionTarget !== "main" && job.payload.kind === "agentTurn"; const triggerTimeout = (reason: string) => { @@ -372,11 +375,7 @@ function resolveDeliveryState(params: { return { status: "unknown", failureNotification: { status: "not-requested" } }; } -/** - * Apply the result of a job execution to the job's state. - * Handles consecutive error tracking, exponential backoff, one-shot disable, - * and nextRunAtMs computation. Returns `true` if the job should be deleted. - */ +/** Applies run outcome state, delivery state, backoff/next-run scheduling, and delete-after-run policy. */ export function applyJobResult( state: CronServiceState, job: CronJob, @@ -704,6 +703,7 @@ function applyOutcomeToStoredJob(state: CronServiceState, result: TimedCronRunOu } } +/** Arms the cron timer for the next wake or a maintenance recheck. */ export function armTimer(state: CronServiceState) { if (state.timer) { clearTimeout(state.timer); @@ -773,6 +773,7 @@ function armRunningRecheckTimer(state: CronServiceState) { }, MAX_TIMER_DELAY_MS); } +/** Handles one cron timer tick: load due jobs, reserve them, execute, persist, and re-arm. */ export async function onTimer(state: CronServiceState) { if (state.running) { // Re-arm the timer so the scheduler keeps ticking even when a job is @@ -1100,6 +1101,7 @@ function deferPendingBackoffMissedCronSlots( return changed; } +/** Runs or defers missed startup jobs using restart catch-up limits. */ export async function runMissedJobs( state: CronServiceState, opts?: { skipJobIds?: ReadonlySet; deferAgentTurnJobs?: boolean }, @@ -1158,6 +1160,8 @@ async function planStartupCatchup( state.deps.startupDeferredMissedAgentJobDelayMs ?? DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS, ); + // Agent-turn startup catch-up is deferred by default so gateway/channel + // startup is not blocked by model/tool bootstrap work. const deferred: StartupDeferredJob[] = [ ...deferredOverflow.map((job) => ({ jobId: job.id })), ...deferredAgentJobs.map((job) => ({ jobId: job.id, delayMs: deferredAgentDelayMs })), @@ -1309,6 +1313,7 @@ async function applyStartupCatchupOutcomes( }); } +/** Executes a cron job without mutating persisted job state. */ export async function executeJobCore( state: CronServiceState, job: CronJob, @@ -1560,10 +1565,7 @@ async function executeDetachedCronJob( }; } -/** - * Execute a job. This version is used by the `run` command and other - * places that need the full execution with state updates. - */ +/** Executes a cron job and applies the resulting state transitions in memory. */ export async function executeJob( state: CronServiceState, job: CronJob, @@ -1646,6 +1648,7 @@ function emitJobFinished( }); } +/** Clears the currently armed cron timer. */ export function stopTimer(state: CronServiceState) { if (state.timer) { clearTimeout(state.timer); @@ -1653,6 +1656,7 @@ export function stopTimer(state: CronServiceState) { state.timer = null; } +/** Dispatches a cron event to the optional subscriber without letting subscriber errors escape. */ export function emit(state: CronServiceState, evt: CronEvent) { try { state.deps.onEvent?.(evt); diff --git a/src/cron/service/wake.ts b/src/cron/service/wake.ts index 54b27205098..53c3d27939a 100644 --- a/src/cron/service/wake.ts +++ b/src/cron/service/wake.ts @@ -1,6 +1,7 @@ import { isSubagentSessionKey } from "../../routing/session-key.js"; import type { CronServiceState } from "./state.js"; +/** Enqueues a manual cron wake event and optionally pokes the targeted heartbeat loop. */ export function wake( state: CronServiceState, opts: { mode: "now" | "next-heartbeat"; text: string; sessionKey?: string }, diff --git a/src/cron/session-reaper.ts b/src/cron/session-reaper.ts index 8de393931bd..ceb347fc131 100644 --- a/src/cron/session-reaper.ts +++ b/src/cron/session-reaper.ts @@ -1,11 +1,3 @@ -/** - * Cron session reaper — prunes completed isolated cron run sessions - * from the session store after a configurable retention period. - * - * Pattern: sessions keyed as `...:cron::run:` are ephemeral - * run records. The base session (`...:cron:`) is kept as-is. - */ - import { parseDurationMs } from "../cli/parse-duration.js"; import { loadSessionStore } from "../config/sessions/store-load.js"; import { archiveRemovedSessionTranscripts, updateSessionStore } from "../config/sessions/store.js"; @@ -21,6 +13,7 @@ const MIN_SWEEP_INTERVAL_MS = 5 * 60_000; // 5 minutes const lastSweepAtMsByStore = new Map(); +/** Resolves cron run-session retention; `false` disables pruning, bad strings fall back safely. */ export function resolveRetentionMs(cronConfig?: CronConfig): number | null { if (cronConfig?.sessionRetention === false) { return null; // pruning disabled @@ -42,14 +35,10 @@ type ReaperResult = { }; /** - * Sweep the session store and prune expired cron run sessions. - * Designed to be called from the cron timer tick — self-throttles via - * MIN_SWEEP_INTERVAL_MS to avoid excessive I/O. + * Sweeps completed isolated cron run sessions while preserving base cron sessions. * - * Lock ordering: this function acquires the session-store file lock via - * `updateSessionStore`. It must be called OUTSIDE of the cron service's - * own `locked()` section to avoid lock-order inversions. The cron timer - * calls this after all `locked()` sections have been released. + * Must run outside the cron service `locked()` section because this acquires + * the session-store file lock; reversing that order can deadlock timer ticks. */ export async function sweepCronRunSessions(params: { cronConfig?: CronConfig; @@ -64,7 +53,8 @@ export async function sweepCronRunSessions(params: { const storePath = params.sessionStorePath; const lastSweepAtMs = lastSweepAtMsByStore.get(storePath) ?? 0; - // Throttle: don't sweep more often than every 5 minutes. + // Timer ticks can be frequent; throttle per store path to avoid repeated + // session-store I/O while preserving a force path for deterministic tests. if (!params.force && now - lastSweepAtMs < MIN_SWEEP_INTERVAL_MS) { return { swept: false, pruned: 0 }; } @@ -108,6 +98,8 @@ export async function sweepCronRunSessions(params: { if (prunedSessions.size > 0) { try { const store = loadSessionStore(storePath, { skipCache: true }); + // Archive only transcripts that no remaining session references; base + // cron sessions intentionally keep their transcript history. const referencedSessionIds = new Set( Object.values(store) .map((entry) => entry?.sessionId) @@ -143,7 +135,7 @@ export async function sweepCronRunSessions(params: { return { swept: true, pruned }; } -/** Reset the throttle timer (for tests). */ +/** Resets per-store reaper throttles between tests. */ export function resetReaperThrottle(): void { lastSweepAtMsByStore.clear(); } diff --git a/src/cron/session-target.ts b/src/cron/session-target.ts index 5e36cc1b24e..22db8847e9b 100644 --- a/src/cron/session-target.ts +++ b/src/cron/session-target.ts @@ -1,9 +1,11 @@ const INVALID_CRON_SESSION_TARGET_ID_ERROR = "invalid cron sessionTarget session id"; +/** Returns whether an error came from cron session target id validation. */ export function isInvalidCronSessionTargetIdError(error: unknown): boolean { return error instanceof Error && error.message === INVALID_CRON_SESSION_TARGET_ID_ERROR; } +/** Validates the opaque session id portion of a `session:` cron target. */ export function assertSafeCronSessionTargetId(sessionId: string): string { const trimmed = sessionId.trim(); if (!trimmed) { @@ -15,6 +17,7 @@ export function assertSafeCronSessionTargetId(sessionId: string): string { return trimmed; } +/** Extracts the persistent session key from a `session:` cron target, if present. */ export function resolveCronSessionTargetSessionKey( sessionTarget?: string | null, ): string | undefined { @@ -24,6 +27,7 @@ export function resolveCronSessionTargetSessionKey( return assertSafeCronSessionTargetId(sessionTarget.slice(8)); } +/** Resolves `current` at creation time so scheduled jobs do not depend on future active UI state. */ export function resolveCronCurrentSessionTarget(params: { sessionTarget?: string | null; sessionKey?: string | null; @@ -35,6 +39,7 @@ export function resolveCronCurrentSessionTarget(params: { return sessionKey ? `session:${assertSafeCronSessionTargetId(sessionKey)}` : "isolated"; } +/** Chooses the session key used for cron delivery, preferring explicit persistent targets. */ export function resolveCronDeliverySessionKey(job: { sessionTarget?: string | null; sessionKey?: string | null; @@ -48,6 +53,7 @@ export function resolveCronDeliverySessionKey(job: { : undefined; } +/** Returns the notification session key, falling back to a stable per-job failure session. */ export function resolveCronNotificationSessionKey(params: { jobId: string; sessionKey?: string | null; @@ -57,6 +63,7 @@ export function resolveCronNotificationSessionKey(params: { : `cron:${params.jobId}:failure`; } +/** Resolves the session key used to deliver failure notifications for a cron job. */ export function resolveCronFailureNotificationSessionKey(job: { id: string; sessionTarget?: string | null; diff --git a/src/cron/stagger.ts b/src/cron/stagger.ts index 1a174471723..00087e789f7 100644 --- a/src/cron/stagger.ts +++ b/src/cron/stagger.ts @@ -1,12 +1,14 @@ import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; import type { CronSchedule } from "./types.js"; +/** Default jitter window applied to recurring top-of-hour cron schedules. */ export const DEFAULT_TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1000; function parseCronFields(expr: string) { return expr.trim().split(/\s+/).filter(Boolean); } +/** Returns whether a cron expression fires recurring jobs exactly at the top of an hour. */ export function isRecurringTopOfHourCronExpr(expr: string) { const fields = parseCronFields(expr); if (fields.length === 5) { @@ -20,6 +22,7 @@ export function isRecurringTopOfHourCronExpr(expr: string) { return false; } +/** Normalizes explicit stagger values from config, preserving zero as "run exactly on schedule". */ export function normalizeCronStaggerMs(raw: unknown): number | undefined { const numeric = typeof raw === "number" @@ -33,10 +36,12 @@ export function normalizeCronStaggerMs(raw: unknown): number | undefined { return Math.max(0, Math.floor(numeric)); } +/** Returns the default anti-thundering-herd stagger for top-of-hour recurring schedules. */ export function resolveDefaultCronStaggerMs(expr: string): number | undefined { return isRecurringTopOfHourCronExpr(expr) ? DEFAULT_TOP_OF_HOUR_STAGGER_MS : undefined; } +/** Resolves the effective stagger for a cron schedule, preferring explicit values over defaults. */ export function resolveCronStaggerMs(schedule: Extract): number { const explicit = normalizeCronStaggerMs(schedule.staggerMs); if (explicit !== undefined) { diff --git a/src/cron/store.ts b/src/cron/store.ts index 92298ab1bf2..d43d4d93a35 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -39,6 +39,7 @@ function resolveDefaultCronStorePath(): string { return path.join(resolveDefaultCronDir(), "jobs.json"); } +/** Resolves the sidecar quarantine path used for invalid cron config rows. */ export function resolveCronQuarantinePath(storePath: string): string { if (storePath.endsWith(".json")) { return storePath.replace(/\.json$/, "-quarantine.json"); @@ -46,6 +47,7 @@ export function resolveCronQuarantinePath(storePath: string): string { return `${storePath}-quarantine.json`; } +/** Resolves the cron jobs store path, expanding home-relative user input. */ export function resolveCronJobsStorePath(storePath?: string) { if (storePath?.trim()) { const raw = storePath.trim(); @@ -57,6 +59,7 @@ export function resolveCronJobsStorePath(storePath?: string) { return resolveDefaultCronStorePath(); } +/** Loads cron jobs plus config/runtime sidecars from the SQLite-backed store. */ export async function loadCronJobsStoreWithConfigJobs(storePath: string): Promise { const resolvedStorePath = path.resolve(storePath); const storeKey = cronStoreKey(resolvedStorePath); @@ -74,10 +77,12 @@ export async function loadCronJobsStoreWithConfigJobs(storePath: string): Promis }; } +/** Loads only the persisted cron job store payload. */ export async function loadCronJobsStore(storePath: string): Promise { return (await loadCronJobsStoreWithConfigJobs(storePath)).store; } +/** Synchronously loads only the persisted cron job store payload. */ export function loadCronJobsStoreSync(storePath: string): CronStoreFile { const resolvedStorePath = path.resolve(storePath); const storeKey = cronStoreKey(resolvedStorePath); @@ -105,6 +110,7 @@ async function atomicWrite(filePath: string, content: string, dirMode = 0o700): }); } +/** Persists cron jobs, or only mutable runtime state when stateOnly is set. */ export async function saveCronJobsStore( storePath: string, store: CronStoreFile, @@ -125,18 +131,22 @@ export async function saveCronJobsStore( } // Public plugin SDK seam; core callers use the SQLite-backed cron-jobs names above. +/** Resolves the public plugin-SDK cron store path. */ export function resolveCronStorePath(storePath?: string) { return resolveCronJobsStorePath(storePath); } +/** Plugin-SDK alias for loading the cron store. */ export async function loadCronStore(storePath: string): Promise { return await loadCronJobsStore(storePath); } +/** Plugin-SDK alias for synchronously loading the cron store. */ export function loadCronStoreSync(storePath: string): CronStoreFile { return loadCronJobsStoreSync(storePath); } +/** Plugin-SDK alias for saving the cron store. */ export async function saveCronStore( storePath: string, store: CronStoreFile, @@ -145,6 +155,7 @@ export async function saveCronStore( await saveCronJobsStore(storePath, store, opts); } +/** Loads the cron quarantine sidecar, validating its persisted v1 shape. */ export async function loadCronQuarantineFile(pathLocal: string): Promise { try { const raw = await fs.promises.readFile(pathLocal, "utf-8"); @@ -212,6 +223,7 @@ function quarantineEntryKey(entry: QuarantinedCronConfigJob): string { }); } +/** Appends new invalid cron config rows to the quarantine sidecar without duplicating entries. */ export async function saveCronQuarantineFile(params: { storePath: string; entries: QuarantinedCronConfigJob[]; @@ -230,6 +242,8 @@ export async function saveCronQuarantineFile(params: { if (seen.has(key)) { continue; } + // Deduplicate by the original invalid row shape so repeated loads do not + // keep appending the same quarantined config job. seen.add(key); appended = true; nextJobs.push({ diff --git a/src/cron/store/delivery-codec.ts b/src/cron/store/delivery-codec.ts index d3d2bbb2f73..5fb93ae4606 100644 --- a/src/cron/store/delivery-codec.ts +++ b/src/cron/store/delivery-codec.ts @@ -2,6 +2,7 @@ import type { CronDelivery } from "../types.js"; import { booleanToInteger, integerToBoolean } from "./scalar-codec.js"; import type { CronJobInsert, CronJobRow } from "./schema.js"; +/** Maps cron delivery config into normalized SQLite columns. */ export function bindDeliveryColumns( delivery: CronDelivery | undefined, ): Pick< @@ -42,6 +43,7 @@ function cronDeliveryModeFromValue(value: unknown): CronDelivery["mode"] | undef return value === "none" || value === "announce" || value === "webhook" ? value : undefined; } +/** Reconstructs delivery config from split SQLite columns, preserving legacy partial rows. */ export function deliveryFromRow(row: CronJobRow): CronDelivery | undefined { const rowMode = cronDeliveryModeFromValue(row.delivery_mode); const hasDeliveryColumns = @@ -85,6 +87,8 @@ export function deliveryFromRow(row: CronJobRow): CronDelivery | undefined { if (!rowMode && !hasDeliveryColumns) { return undefined; } + // Old rows may have destination columns without a mode; announce matches the + // historical default for configured channel delivery. return { mode: rowMode ?? "announce", ...(row.delivery_channel ? { channel: row.delivery_channel as CronDelivery["channel"] } : {}), diff --git a/src/cron/store/failure-alert-codec.ts b/src/cron/store/failure-alert-codec.ts index b9c3ee83cdd..c829d0a74b2 100644 --- a/src/cron/store/failure-alert-codec.ts +++ b/src/cron/store/failure-alert-codec.ts @@ -2,6 +2,7 @@ import type { CronFailureAlert } from "../types.js"; import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js"; import type { CronJobInsert, CronJobRow } from "./schema.js"; +/** Maps cron failure-alert config into normalized SQLite columns. */ export function bindFailureAlertColumns( failureAlert: CronFailureAlert | false | undefined, ): Pick< @@ -39,6 +40,7 @@ export function bindFailureAlertColumns( }; } +/** Reconstructs failure-alert config, distinguishing disabled from omitted config. */ export function failureAlertFromRow(row: CronJobRow): CronFailureAlert | false | undefined { if (row.failure_alert_disabled === 1) { return false; diff --git a/src/cron/store/key.ts b/src/cron/store/key.ts index 7a6ba47ed58..26127cf0b59 100644 --- a/src/cron/store/key.ts +++ b/src/cron/store/key.ts @@ -1,5 +1,6 @@ import path from "node:path"; +/** Returns the canonical per-file SQLite partition key for cron store rows. */ export function cronStoreKey(storePath: string): string { return path.resolve(storePath); } diff --git a/src/cron/store/payload-codec.ts b/src/cron/store/payload-codec.ts index b1365fd34b7..873704c4f4a 100644 --- a/src/cron/store/payload-codec.ts +++ b/src/cron/store/payload-codec.ts @@ -14,6 +14,7 @@ function parseExternalContentSource(raw: string | null): "gmail" | "webhook" | u return parsed === "gmail" || parsed === "webhook" ? parsed : undefined; } +/** Maps cron payload variants into normalized SQLite columns. */ export function bindPayloadColumns( payload: CronPayload, ): Pick< @@ -57,6 +58,7 @@ export function bindPayloadColumns( }; } +/** Reconstructs cron payload variants from SQLite columns, returning null for invalid rows. */ export function payloadFromRow(row: CronJobRow): CronPayload | null { if (row.payload_kind === "systemEvent") { return row.payload_message == null ? null : { kind: "systemEvent", text: row.payload_message }; diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index 66e9ec8b201..b8b9d171d27 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -100,6 +100,8 @@ function normalizeCronJobForSqlite(job: CronStoreFile["jobs"][number]): CronJob return null; } if (!hadDeleteAfterRun) { + // Legacy rows omitted deleteAfterRun entirely; avoid writing the default + // back into job_json so config round-trips stay byte-light. delete normalized.deleteAfterRun; } const createdAtMs = @@ -122,6 +124,7 @@ function countUnpersistableCronJobs(store: CronStoreFile): number { return store.jobs.reduce((count, job) => count + (normalizeCronJobForSqlite(job) ? 0 : 1), 0); } +/** Fails before replacing SQLite rows when any config job cannot round-trip. */ export function assertCronStoreCanPersist(store: CronStoreFile): void { const invalidJobs = countUnpersistableCronJobs(store); if (invalidJobs > 0) { @@ -183,6 +186,7 @@ function rowToCronJob(row: CronJobRow): CronJob | null { }; } +/** Loads cron rows in config order with deterministic fallbacks for old rows. */ export function loadCronRows(db: DatabaseSync, storeKey: string): CronJobRow[] { return executeSqliteQuerySync( db, @@ -196,6 +200,7 @@ export function loadCronRows(db: DatabaseSync, storeKey: string): CronJobRow[] { ).rows; } +/** Replaces all persisted cron rows for one store key from the config store snapshot. */ export function replaceCronRows(db: DatabaseSync, storeKey: string, store: CronStoreFile): void { executeSqliteQuerySync( db, @@ -215,6 +220,7 @@ export function replaceCronRows(db: DatabaseSync, storeKey: string, store: CronS } } +/** Updates only mutable runtime columns without rewriting full job config JSON. */ export function updateCronRuntimeRows( db: DatabaseSync, storeKey: string, @@ -237,6 +243,7 @@ export function updateCronRuntimeRows( } } +/** Reconstructs loaded cron store data and config-runtime sidecars from SQLite rows. */ export function loadedCronStoreFromRows(rows: CronJobRow[]): LoadedCronStore { const parsedJobs = rows.map(rowToCronJob); const jobs = parsedJobs.filter((job): job is CronJob => job !== null); diff --git a/src/cron/store/scalar-codec.ts b/src/cron/store/scalar-codec.ts index 8a877fb7c4b..510c434a5c9 100644 --- a/src/cron/store/scalar-codec.ts +++ b/src/cron/store/scalar-codec.ts @@ -1,3 +1,4 @@ +/** Parses a JSON object column, returning the fallback for malformed or non-object values. */ export function parseJsonObject(raw: string, fallback: T): T { try { const parsed = JSON.parse(raw) as unknown; @@ -7,6 +8,7 @@ export function parseJsonObject(raw: string, fallback: T): T { } } +/** Parses a JSON column without shape validation, returning the fallback only on parse failure. */ export function parseJsonValue(raw: string, fallback: T): T { try { return JSON.parse(raw) as T; @@ -15,6 +17,7 @@ export function parseJsonValue(raw: string, fallback: T): T { } } +/** Normalizes SQLite number/bigint columns into JavaScript numbers. */ export function normalizeNumber(value: number | bigint | null): number | undefined { if (typeof value === "bigint") { return Number(value); @@ -22,19 +25,23 @@ export function normalizeNumber(value: number | bigint | null): number | undefin return typeof value === "number" ? value : undefined; } +/** Converts optional booleans into nullable SQLite integer flags. */ export function booleanToInteger(value: boolean | undefined): number | null { return typeof value === "boolean" ? (value ? 1 : 0) : null; } +/** Converts SQLite integer flags into booleans while preserving missing columns as undefined. */ export function integerToBoolean(value: number | bigint | null): boolean | undefined { const normalized = normalizeNumber(value); return normalized == null ? undefined : normalized !== 0; } +/** Serializes optional structured values for JSON columns. */ export function serializeJson(value: unknown): string | null { return value == null ? null : JSON.stringify(value); } +/** Parses a JSON string-array column and drops non-string entries from legacy data. */ export function parseJsonArray(raw: string | null): string[] | undefined { if (!raw) { return undefined; diff --git a/src/cron/store/schema.ts b/src/cron/store/schema.ts index 2eef6097ba3..41bde95f8af 100644 --- a/src/cron/store/schema.ts +++ b/src/cron/store/schema.ts @@ -6,9 +6,13 @@ import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-sta type CronJobsTable = OpenClawStateKyselyDatabase["cron_jobs"]; type CronStoreDatabase = Pick; +/** Read shape for rows in the cron_jobs SQLite table. */ export type CronJobRow = Selectable; + +/** Insert/update shape for rows in the cron_jobs SQLite table. */ export type CronJobInsert = Insertable; +/** Creates the Kysely facade scoped to cron_jobs for synchronous SQLite access. */ export function getCronStoreKysely(db: DatabaseSync) { return getNodeSqliteKysely(db); } diff --git a/src/cron/store/state-codec.ts b/src/cron/store/state-codec.ts index 70c154dada4..9ff56b08073 100644 --- a/src/cron/store/state-codec.ts +++ b/src/cron/store/state-codec.ts @@ -7,6 +7,7 @@ import { } from "./scalar-codec.js"; import type { CronJobInsert, CronJobRow } from "./schema.js"; +/** Maps mutable cron runtime state into normalized SQLite columns. */ export function bindStateColumns( state: CronJobState, ): Pick< @@ -42,8 +43,11 @@ export function bindStateColumns( }; } +/** Reconstructs cron runtime state from JSON plus split indexed columns. */ export function stateFromRow(row: CronJobRow): CronJobState { return { + // Keep unknown runtime fields from state_json while letting indexed columns + // win for fields that SQLite updates independently during hot-path writes. ...parseJsonObject(row.state_json, {}), ...(row.next_run_at_ms != null ? { nextRunAtMs: normalizeNumber(row.next_run_at_ms) } : {}), ...(row.running_at_ms != null ? { runningAtMs: normalizeNumber(row.running_at_ms) } : {}), diff --git a/src/cron/store/types.ts b/src/cron/store/types.ts index 5de7412d266..0b744cffa38 100644 --- a/src/cron/store/types.ts +++ b/src/cron/store/types.ts @@ -1,5 +1,6 @@ import type { CronStoreFile } from "../types.js"; +/** Invalid config-backed cron job captured for quarantine instead of runtime load. */ export type QuarantinedCronConfigJob = { sourceIndex: number; reason: string; @@ -10,17 +11,20 @@ export type QuarantinedCronConfigJob = { scheduleIdentity?: string; }; +/** Sidecar file that records config jobs skipped during cron store loading. */ export type CronQuarantineFile = { version: 1; jobs: Array; }; +/** Runtime state retained for config-sourced jobs that are not persisted as canonical jobs. */ export type CronConfigJobRuntimeEntry = { updatedAtMs?: number; scheduleIdentity?: string; state?: Record; }; +/** Combined cron store load result with canonical jobs and config-backed metadata. */ export type LoadedCronStore = { store: CronStoreFile; configJobs: Array>; diff --git a/src/cron/types-shared.ts b/src/cron/types-shared.ts index 68c7f0c97a3..133cc37e3be 100644 --- a/src/cron/types-shared.ts +++ b/src/cron/types-shared.ts @@ -1,3 +1,4 @@ +/** Shared persisted cron job envelope used by runtime and external config shapes. */ export type CronJobBase = { id: string; diff --git a/src/cron/types.ts b/src/cron/types.ts index 561c6247be1..8251937c07d 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -4,6 +4,7 @@ import type { ChannelId } from "../channels/plugins/types.public.js"; import type { HookExternalContentSource } from "../security/external-content.js"; import type { CronJobBase } from "./types-shared.js"; +/** Supported schedule forms persisted in cron job specs. */ export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } @@ -15,13 +16,19 @@ export type CronSchedule = staggerMs?: number; }; +/** Runtime target that decides whether a job joins main, isolated, or a named session. */ export type CronSessionTarget = "main" | "isolated" | "current" | `session:${string}`; + +/** Wake policy for main-session jobs waiting on heartbeat/user activity. */ export type CronWakeMode = "next-heartbeat" | "now"; +/** Messaging channel id accepted by cron delivery settings. */ export type CronMessageChannel = ChannelId; +/** Delivery mode for job completion output. */ export type CronDeliveryMode = "none" | "announce" | "webhook"; +/** Completion delivery configuration for cron job output. */ export type CronDelivery = { mode: CronDeliveryMode; channel?: CronMessageChannel; @@ -37,11 +44,13 @@ export type CronDelivery = { failureDestination?: CronFailureDestination; }; +/** Webhook completion destination used alongside chat delivery. */ export type CronCompletionDestination = { mode: "webhook"; to?: string; }; +/** Destination override for failed-run notifications. */ export type CronFailureDestination = { channel?: CronMessageChannel; to?: string; @@ -49,6 +58,7 @@ export type CronFailureDestination = { mode?: "announce" | "webhook"; }; +/** Partial failure-destination update shape; null clears individual override fields. */ export type CronFailureDestinationPatch = { channel?: CronMessageChannel | null; to?: string | null; @@ -56,6 +66,7 @@ export type CronFailureDestinationPatch = { mode?: "announce" | "webhook" | null; }; +/** Partial delivery update shape; null clears optional delivery destinations or fields. */ export type CronDeliveryPatch = Partial> & { channel?: CronMessageChannel | null; to?: string | null; @@ -65,9 +76,13 @@ export type CronDeliveryPatch = Partial> & { toolsAllow?: string[] | null; }; +/** Mutable runtime state persisted beside the immutable cron job spec. */ export type CronJobState = { nextRunAtMs?: number; runningAtMs?: number; @@ -256,6 +289,7 @@ export type CronJobState = { lastFailureNotificationDeliveryError?: string; }; +/** Fully persisted cron job with spec fields and mutable run state. */ export type CronJob = CronJobBase< CronSchedule, CronSessionTarget, @@ -267,15 +301,18 @@ export type CronJob = CronJobBase< state: CronJobState; }; +/** Versioned cron store file shape. */ export type CronStoreFile = { version: 1; jobs: CronJob[]; }; +/** Create input accepted by cron APIs before id/timestamps/state are assigned. */ export type CronJobCreate = Omit & { state?: Partial; }; +/** Patch input accepted by cron APIs without allowing immutable identity fields. */ export type CronJobPatch = Partial< Omit > & { diff --git a/src/cron/validate-timestamp.ts b/src/cron/validate-timestamp.ts index 838fbfb44b0..1cd00a1eb8b 100644 --- a/src/cron/validate-timestamp.ts +++ b/src/cron/validate-timestamp.ts @@ -21,10 +21,7 @@ type TimestampValidationSuccess = { type TimestampValidationResult = TimestampValidationSuccess | TimestampValidationError; /** - * Validates at timestamps in cron schedules. - * Rejects timestamps that are: - * - More than 1 minute in the past - * - More than 10 years in the future + * Validates one-shot cron timestamps with a small past grace window and far-future cap. */ export function validateScheduleTimestamp( schedule: CronSchedule, @@ -47,7 +44,8 @@ export function validateScheduleTimestamp( const referenceNowMs = asDateTimestampMs(nowMs) ?? asDateTimestampMs(Date.now()) ?? 0; const diffMs = atMs - referenceNowMs; - // Check if timestamp is in the past (allow 1 minute grace period) + // Allow a one-minute grace window so creation and validation races do not + // reject freshly submitted one-shot jobs. if (diffMs < -ONE_MINUTE_MS) { const nowDate = resolveTimestampMsToIsoString(referenceNowMs); const atDate = resolveTimestampMsToIsoString(atMs); @@ -58,7 +56,7 @@ export function validateScheduleTimestamp( }; } - // Check if timestamp is too far in the future + // Bound far-future one-shot jobs so mistyped years do not persist forever. if (diffMs > TEN_YEARS_MS) { const atDate = resolveTimestampMsToIsoString(atMs); const yearsAhead = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000)); diff --git a/src/cron/webhook-url.ts b/src/cron/webhook-url.ts index 7cd6c154173..032f49be61a 100644 --- a/src/cron/webhook-url.ts +++ b/src/cron/webhook-url.ts @@ -2,6 +2,7 @@ function isAllowedWebhookProtocol(protocol: string) { return protocol === "http:" || protocol === "https:"; } +/** Normalizes cron webhook URLs while rejecting empty, malformed, and non-HTTP(S) values. */ export function normalizeHttpWebhookUrl(value: unknown): string | null { if (typeof value !== "string") { return null; diff --git a/src/infra/outbound/abort.ts b/src/infra/outbound/abort.ts index 8d6b0e2cf4d..71ad7251816 100644 --- a/src/infra/outbound/abort.ts +++ b/src/infra/outbound/abort.ts @@ -1,7 +1,3 @@ -/** - * Utility for checking AbortSignal state and throwing a standard AbortError. - */ - /** * Throws an AbortError if the given signal has been aborted. * Use at async checkpoints to support cancellation. diff --git a/src/infra/outbound/account-scoped-conversation-bindings.ts b/src/infra/outbound/account-scoped-conversation-bindings.ts index 1aee7d065dc..8f36c79d0d4 100644 --- a/src/infra/outbound/account-scoped-conversation-bindings.ts +++ b/src/infra/outbound/account-scoped-conversation-bindings.ts @@ -13,6 +13,7 @@ import { type SessionBindingRecord, } from "./session-binding-service.js"; +/** In-memory binding record scoped to one channel account and conversation id. */ export type AccountScopedConversationBindingRecord = { accountId: string; conversationId: string; @@ -25,6 +26,7 @@ export type AccountScopedConversationBindingRecord = { accountId: string; getByConversationId: ( @@ -115,6 +117,7 @@ function toSessionBindingRecord(params: { }; } +/** Creates a channel/account binding manager and registers it as a session-binding adapter. */ export function createAccountScopedConversationBindingManager(params: { channel: string; cfg: OpenClawConfig; @@ -338,6 +341,7 @@ export function createAccountScopedConversationBindingManager BoundDeliveryRouterResult; }; @@ -55,6 +58,7 @@ function resolveBindingForRequester( return null; } +/** Creates a router that resolves task-completion delivery through active session bindings. */ export function createBoundDeliveryRouter( service: SessionBindingService = getSessionBindingService(), ): BoundDeliveryRouter { @@ -93,6 +97,8 @@ export function createBoundDeliveryRouter( reason: "single-active-binding", }; } + // Without requester context, multiple active bindings are ambiguous; + // fallback avoids leaking one session's completion into another chat. return { binding: null, mode: "fallback", diff --git a/src/infra/outbound/channel-bootstrap.runtime.ts b/src/infra/outbound/channel-bootstrap.runtime.ts index e0c65de91a6..57b5de285f7 100644 --- a/src/infra/outbound/channel-bootstrap.runtime.ts +++ b/src/infra/outbound/channel-bootstrap.runtime.ts @@ -11,6 +11,7 @@ import type { DeliverableMessageChannel } from "../../utils/message-channel.js"; const bootstrapAttempts = new Set(); +/** Clears the per-registry channel bootstrap retry guard for isolated tests. */ export function resetOutboundChannelBootstrapStateForTests(): void { bootstrapAttempts.clear(); } @@ -19,6 +20,7 @@ function channelEntryCanSend(entry: PluginChannelRegistration | undefined): bool return Boolean(entry?.plugin?.outbound?.sendText ?? entry?.plugin?.message?.send?.text); } +/** Loads runtime plugins on demand when a selected outbound channel has only a setup shell. */ export function bootstrapOutboundChannelPlugin(params: { channel: DeliverableMessageChannel; cfg?: OpenClawConfig; diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 90e38fb837c..cfc20974211 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -32,6 +32,7 @@ import { type ChannelTargetResolver = NonNullable; +/** Prompt-facing channel capabilities exposed to outbound/runtime callers. */ export type ChannelPromptRuntime = { messageToolHints?: ChannelAgentPromptAdapter["messageToolHints"]; messageToolCapabilities?: ChannelAgentPromptAdapter["messageToolCapabilities"]; @@ -39,6 +40,7 @@ export type ChannelPromptRuntime = { hasNativeApprovalPromptUi?: boolean; }; +/** Read-only channel runtime facade assembled from a channel plugin. */ export type OutboundChannelRuntime = { id: string; label: string; @@ -84,10 +86,12 @@ export type OutboundChannelRuntime = { blockStreamingCoalesceDefaults?: ChannelStreamingAdapter["blockStreamingCoalesceDefaults"]; }; +/** Resets outbound channel bootstrap/resolution state for isolated tests. */ export function resetOutboundChannelResolutionStateForTest(): void { resetOutboundChannelBootstrapStateForTests(); } +/** Normalizes a raw channel id and rejects non-deliverable/internal channels. */ export function normalizeDeliverableOutboundChannel( raw?: string | null, ): DeliverableMessageChannel | undefined { @@ -171,6 +175,7 @@ function toOutboundChannelRuntime(plugin: ChannelPlugin): OutboundChannelRuntime }; } +/** Resolves a deliverable outbound channel plugin, optionally bootstrapping it. */ export function resolveOutboundChannelPlugin(params: { channel: string; cfg?: OpenClawConfig; @@ -205,6 +210,7 @@ export function resolveOutboundChannelPlugin(params: { return resolveLoaded() ?? resolveDirectFromActiveRegistry(normalized) ?? resolve(); } +/** Resolves the message adapter for a deliverable outbound channel. */ export function resolveOutboundChannelMessageAdapter(params: { channel: string; cfg?: OpenClawConfig; @@ -213,6 +219,7 @@ export function resolveOutboundChannelMessageAdapter(params: { return resolveOutboundChannelPlugin(params)?.message; } +/** Resolves a channel plugin for read-only metadata paths. */ export function resolveOutboundChannelPluginForRead(params: { channel: string; cfg?: OpenClawConfig; @@ -242,6 +249,7 @@ export function resolveOutboundChannelPluginForRead(params: { return getChannelPlugin(channelId); } +/** Resolves the read-only outbound runtime facade for a channel. */ export function resolveOutboundChannelRuntime(params: { channel: string; cfg?: OpenClawConfig; @@ -250,6 +258,7 @@ export function resolveOutboundChannelRuntime(params: { return plugin ? toOutboundChannelRuntime(plugin) : undefined; } +/** Reads an already-loaded channel plugin without bootstrapping. */ export function resolveLoadedOutboundChannelPluginForRead(params: { channel: string; }): ChannelPlugin | undefined { diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index c06499e6bc7..60be574f23c 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -15,7 +15,9 @@ import { import { formatErrorMessage } from "../errors.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; +/** Deliverable message channel id that can be selected for message actions. */ export type MessageChannelId = DeliverableMessageChannel; +/** Source that explains how message channel selection chose its result. */ export type MessageChannelSelectionSource = | "explicit" | "tool-context-fallback" @@ -67,6 +69,7 @@ function resolveAvailableKnownChannel(params: { : undefined; } +/** Checks whether a channel has a non-disabled config entry. */ export function isConfiguredChannel(cfg: OpenClawConfig, channelId: string): boolean { const channels = cfg.channels; if (!channels || typeof channels !== "object" || Array.isArray(channels)) { @@ -202,6 +205,7 @@ async function isPluginConfigured(plugin: ChannelPlugin, cfg: OpenClawConfig): P return false; } +/** Lists deliverable channels with at least one enabled, configured account. */ export async function listConfiguredMessageChannels( cfg: OpenClawConfig, ): Promise { @@ -217,6 +221,7 @@ export async function listConfiguredMessageChannels( return channels; } +/** Resolves the message action channel from explicit input, context fallback, or config. */ export async function resolveMessageChannelSelection(params: { cfg: OpenClawConfig; channel?: string | null; diff --git a/src/infra/outbound/channel-target-prefix.ts b/src/infra/outbound/channel-target-prefix.ts index 7c720dcb9a7..84da59c03c5 100644 --- a/src/infra/outbound/channel-target-prefix.ts +++ b/src/infra/outbound/channel-target-prefix.ts @@ -12,6 +12,7 @@ const TARGET_KIND_PREFIXES = new Set([ "user", ]); +/** Removes a selected channel/provider prefix from an outbound target string. */ export function stripTargetProviderPrefix(raw: string, ...providers: string[]): string { const trimmed = raw.trim(); const lower = normalizeOptionalLowercaseString(trimmed) ?? ""; @@ -24,6 +25,7 @@ export function stripTargetProviderPrefix(raw: string, ...providers: string[]): return trimmed; } +/** Removes generic target-kind prefixes such as room:, thread:, or user:. */ export function stripTargetKindPrefix( raw: string, kinds: readonly string[] = ["channel", "conversation", "dm", "group", "room", "thread", "user"], @@ -35,6 +37,7 @@ export function stripTargetKindPrefix( return kindPattern ? raw.replace(new RegExp(`^(${kindPattern}):`, "i"), "").trim() : raw.trim(); } +/** Strips plugin topic suffixes while preserving ordinary colon-containing targets. */ export function stripTargetTopicSuffix( raw: string, options: { allowNumericShorthand?: boolean } = {}, @@ -47,6 +50,7 @@ export function stripTargetTopicSuffix( return trimmed.replace(/:topic:.*$/i, "").trim(); } +/** Parsed provider prefix and the channel that owns it. */ export type ChannelTargetProviderPrefix = { prefix: string; channel: string; @@ -86,10 +90,12 @@ function resolveChannelTargetProviderPrefix( return channel ? { prefix, channel } : undefined; } +/** Resolves the channel implied by a plugin-owned target prefix, if any. */ export function resolveTargetPrefixedChannel(raw?: string | null): string | undefined { return resolveChannelTargetProviderPrefix(raw)?.channel; } +/** Rejects targets whose plugin-owned prefix belongs to a different selected channel. */ export function validateTargetProviderPrefix(params: { channel: string; to?: string | null; diff --git a/src/infra/outbound/channel-target.ts b/src/infra/outbound/channel-target.ts index 93e7a7219a9..03205da4e57 100644 --- a/src/infra/outbound/channel-target.ts +++ b/src/infra/outbound/channel-target.ts @@ -4,14 +4,18 @@ import { } from "../../../packages/normalization-core/src/string-coerce.js"; import { MESSAGE_ACTION_TARGET_MODE } from "./message-action-spec.js"; +/** Shared non-empty string guard for message-action target params. */ export const hasNonEmptyString = sharedHasNonEmptyString; +/** Human-readable description for a single message-action destination. */ export const CHANNEL_TARGET_DESCRIPTION = "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack/Mattermost , or iMessage handle/chat_id"; +/** Human-readable description for repeated message-action destinations. */ export const CHANNEL_TARGETS_DESCRIPTION = "Recipient/channel targets (same format as --target); accepts ids or names when the directory is available."; +/** Maps canonical `target` into the legacy field required by the action implementation. */ export function applyTargetToParams(params: { action: string; args: Record; diff --git a/src/infra/outbound/conversation-id.ts b/src/infra/outbound/conversation-id.ts index e6294253723..75cd38ae977 100644 --- a/src/infra/outbound/conversation-id.ts +++ b/src/infra/outbound/conversation-id.ts @@ -13,6 +13,9 @@ function resolveExplicitConversationTargetId(target: string): string | undefined return undefined; } +/** + * Chooses the best conversation id from an explicit thread id or outbound targets. + */ export function resolveConversationIdFromTargets(params: { threadId?: string | number; targets: Array; @@ -32,6 +35,8 @@ export function resolveConversationIdFromTargets(params: { return explicitConversationId; } if (target.includes(":") && explicitConversationId === undefined) { + // Colon targets are usually provider-native ids. Only explicit target + // prefixes above are safe to collapse into a portable conversation id. continue; } const mentionMatch = target.match(/^<#(\d+)>$/); diff --git a/src/infra/outbound/current-conversation-bindings.ts b/src/infra/outbound/current-conversation-bindings.ts index 83d572cc307..5c72392bd3b 100644 --- a/src/infra/outbound/current-conversation-bindings.ts +++ b/src/infra/outbound/current-conversation-bindings.ts @@ -145,6 +145,7 @@ function resolveChannelSupportsCurrentConversationBinding(channel: string): bool return false; } +/** Reports generic current-conversation binding support for plugin-owned channels. */ export function getGenericCurrentConversationBindingCapabilities(params: { channel: string; accountId: string; @@ -161,6 +162,7 @@ export function getGenericCurrentConversationBindingCapabilities(params: { }; } +/** Stores or replaces the current-conversation binding for a normalized conversation ref. */ export async function bindGenericCurrentConversation( input: SessionBindingBindInput, ): Promise { @@ -209,12 +211,14 @@ export async function bindGenericCurrentConversation( return record; } +/** Resolves a current-conversation binding and prunes it if its TTL has expired. */ export function resolveGenericCurrentConversationBinding( ref: ConversationRef, ): SessionBindingRecord | null { return pruneExpiredBinding(buildConversationKey(ref)); } +/** Lists non-expired current-conversation bindings owned by one target session. */ export function listGenericCurrentConversationBindingsBySession( targetSessionKey: string, ): SessionBindingRecord[] { @@ -230,6 +234,7 @@ export function listGenericCurrentConversationBindingsBySession( return results; } +/** Persists last-activity metadata for an existing generic current-conversation binding. */ export function touchGenericCurrentConversationBinding(bindingId: string, at = Date.now()): void { loadBindingsIntoMemory(); if (!bindingId.startsWith(CURRENT_BINDINGS_ID_PREFIX)) { @@ -250,6 +255,7 @@ export function touchGenericCurrentConversationBinding(bindingId: string, at = D persistBindingsToDisk(); } +/** Removes generic current-conversation bindings by binding id or target session key. */ export async function unbindGenericCurrentConversationBindings( input: SessionBindingUnbindInput, ): Promise { diff --git a/src/infra/outbound/deliver-types.ts b/src/infra/outbound/deliver-types.ts index f6b2383fd9c..428942a10b5 100644 --- a/src/infra/outbound/deliver-types.ts +++ b/src/infra/outbound/deliver-types.ts @@ -1,6 +1,7 @@ import type { MessageReceipt } from "../../channels/message/types.js"; import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; +/** Successful channel send result normalized for core delivery accounting. */ export type OutboundDeliveryResult = { channel: Exclude; messageId: string; @@ -16,6 +17,7 @@ export type OutboundDeliveryResult = { meta?: Record; }; +/** Reason a payload was intentionally not sent after normalization or hooks. */ export type OutboundPayloadDeliverySuppressionReason = | "cancelled_by_message_sending_hook" | "cancelled_by_reply_payload_sending_hook" @@ -24,8 +26,10 @@ export type OutboundPayloadDeliverySuppressionReason = | "no_visible_payload" | "adapter_returned_no_identity"; +/** Delivery phase where a failure occurred. */ export type OutboundDeliveryFailureStage = "platform_send" | "queue" | "unknown"; +/** Per-payload delivery status emitted to callers and channel send summaries. */ export type OutboundPayloadDeliveryOutcome = | { index: number; @@ -49,6 +53,7 @@ export type OutboundPayloadDeliveryOutcome = stage: OutboundDeliveryFailureStage; }; +/** Error carrying partial delivery results when an outbound send fails mid-batch. */ export class OutboundDeliveryError extends Error { readonly results: OutboundDeliveryResult[]; readonly payloadOutcomes: OutboundPayloadDeliveryOutcome[]; @@ -73,6 +78,7 @@ export class OutboundDeliveryError extends Error { } } +/** Narrows unknown failures to outbound delivery errors with partial-send metadata. */ export function isOutboundDeliveryError(error: unknown): error is OutboundDeliveryError { return error instanceof OutboundDeliveryError; } diff --git a/src/infra/outbound/delivery-commit-hooks.ts b/src/infra/outbound/delivery-commit-hooks.ts index f39077ae833..166742fd063 100644 --- a/src/infra/outbound/delivery-commit-hooks.ts +++ b/src/infra/outbound/delivery-commit-hooks.ts @@ -2,6 +2,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { formatErrorMessage } from "../errors.js"; import type { OutboundDeliveryResult } from "./deliver-types.js"; +/** Callback attached to a delivery result and run after durable send commit. */ export type OutboundDeliveryCommitHook = () => Promise; const log = createSubsystemLogger("outbound/deliver"); @@ -10,6 +11,7 @@ const outboundDeliveryCommitHooks = new WeakMap< OutboundDeliveryCommitHook[] >(); +/** Attaches an after-commit hook without changing the delivery result shape. */ export function attachOutboundDeliveryCommitHook( result: T, hook?: OutboundDeliveryCommitHook, @@ -23,6 +25,7 @@ export function attachOutboundDeliveryCommitHook { @@ -31,6 +34,8 @@ export async function runOutboundDeliveryCommitHooks( try { await hook(); } catch (err) { + // Commit hooks are side effects after successful send; failures are + // logged but must not turn the already-committed delivery into failure. log.warn("Plugin message adapter after-commit hook failed.", { channel: result.channel, messageId: result.messageId, @@ -41,6 +46,7 @@ export async function runOutboundDeliveryCommitHooks( } } +/** Type guard for batched outbound delivery results crossing loose boundaries. */ export function isOutboundDeliveryResultArray(value: unknown): value is OutboundDeliveryResult[] { return Array.isArray(value); } diff --git a/src/infra/outbound/directory-cache.ts b/src/infra/outbound/directory-cache.ts index 14b2b1df520..3b24025327f 100644 --- a/src/infra/outbound/directory-cache.ts +++ b/src/infra/outbound/directory-cache.ts @@ -7,6 +7,9 @@ type CacheEntry = { fetchedAt: number; }; +/** + * Stable dimensions that partition channel-directory cache entries. + */ export type DirectoryCacheKey = { channel: ChannelId; accountId?: string | null; @@ -15,11 +18,17 @@ export type DirectoryCacheKey = { signature?: string | null; }; +/** + * Serializes channel-directory lookup dimensions into a cache key. + */ export function buildDirectoryCacheKey(key: DirectoryCacheKey): string { const signature = key.signature ?? "default"; return `${key.channel}:${key.accountId ?? "default"}:${key.kind}:${key.source}:${signature}`; } +/** + * Small TTL cache for channel directory lookups tied to a config object reference. + */ export class DirectoryCache { private readonly cache = new Map>(); private lastConfigRef: OpenClawConfig | null = null; @@ -31,6 +40,9 @@ export class DirectoryCache { this.maxSize = Math.max(1, resolveNonNegativeIntegerOption(maxSize, 2000)); } + /** + * Returns a cached value after applying config, TTL, and capacity invalidation. + */ get(key: string, cfg: OpenClawConfig): T | undefined { this.resetIfConfigChanged(cfg); this.pruneExpired(Date.now()); @@ -41,6 +53,9 @@ export class DirectoryCache { return entry.value; } + /** + * Stores a value and refreshes its recency for bounded-size eviction. + */ set(key: string, value: T, cfg: OpenClawConfig): void { this.resetIfConfigChanged(cfg); const now = Date.now(); @@ -53,6 +68,9 @@ export class DirectoryCache { this.evictToMaxSize(); } + /** + * Clears matching entries without disturbing unrelated cached lookups. + */ clearMatching(match: (key: string) => boolean): void { for (const key of this.cache.keys()) { if (match(key)) { @@ -61,6 +79,9 @@ export class DirectoryCache { } } + /** + * Drops all cached entries and optionally adopts the current config reference. + */ clear(cfg?: OpenClawConfig): void { this.cache.clear(); if (cfg) { @@ -69,6 +90,7 @@ export class DirectoryCache { } private resetIfConfigChanged(cfg: OpenClawConfig): void { + // Directory availability can change with config snapshots; ref changes must not leak stale entries. if (this.lastConfigRef && this.lastConfigRef !== cfg) { this.cache.clear(); } diff --git a/src/infra/outbound/envelope.ts b/src/infra/outbound/envelope.ts index 9cd9f84aba3..14a0608aa22 100644 --- a/src/infra/outbound/envelope.ts +++ b/src/infra/outbound/envelope.ts @@ -2,6 +2,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OutboundDeliveryJson } from "./format.js"; import { normalizeOutboundPayloadsForJson, type OutboundPayloadJson } from "./payloads.js"; +/** Structured result returned by outbound helpers when payloads/meta wrap delivery data. */ export type OutboundResultEnvelope = { payloads?: OutboundPayloadJson[]; meta?: unknown; @@ -19,6 +20,7 @@ const isOutboundPayloadJson = ( payload: ReplyPayload | OutboundPayloadJson, ): payload is OutboundPayloadJson => "mediaUrl" in payload; +/** Builds the outbound result envelope, flattening plain delivery-only results by default. */ export function buildOutboundResultEnvelope( params: BuildEnvelopeParams, ): OutboundResultEnvelope | OutboundDeliveryJson { diff --git a/src/infra/outbound/format.ts b/src/infra/outbound/format.ts index 8a6d8d5a55d..40b582afb88 100644 --- a/src/infra/outbound/format.ts +++ b/src/infra/outbound/format.ts @@ -4,6 +4,9 @@ import type { ChannelId } from "../../channels/plugins/types.public.js"; import { normalizeChatChannelId } from "../../channels/registry.js"; import type { OutboundDeliveryResult } from "./deliver.js"; +/** + * Machine-readable delivery result emitted by outbound send commands. + */ export type OutboundDeliveryJson = { channel: string; via: "direct" | "gateway"; @@ -35,6 +38,7 @@ const resolveChannelLabel = (channel: string) => { if (pluginLabel) { return pluginLabel; } + // Some legacy chat channels are not plugins; keep their human labels for CLI output. const normalized = normalizeChatChannelId(channel); if (normalized) { return getChatChannelMeta(normalized).label; @@ -42,6 +46,9 @@ const resolveChannelLabel = (channel: string) => { return channel; }; +/** + * Formats the human-readable direct delivery summary for CLI output. + */ export function formatOutboundDeliverySummary( channel: string, result?: OutboundDeliveryResult, @@ -68,6 +75,9 @@ export function formatOutboundDeliverySummary( return base; } +/** + * Builds the JSON delivery payload returned by direct or gateway sends. + */ export function buildOutboundDeliveryJson(params: { channel: string; to: string; @@ -110,6 +120,9 @@ export function buildOutboundDeliveryJson(params: { return payload; } +/** + * Formats the human-readable gateway delivery summary for CLI output. + */ export function formatGatewaySummary(params: { action?: string; channel?: string; diff --git a/src/infra/outbound/formatting.ts b/src/infra/outbound/formatting.ts index f64811b6c8c..ed348494294 100644 --- a/src/infra/outbound/formatting.ts +++ b/src/infra/outbound/formatting.ts @@ -1,6 +1,9 @@ import type { ChunkMode } from "../../auto-reply/chunk.js"; import type { MarkdownTableMode } from "../../config/types.js"; +/** + * Formatting and chunking hints carried through outbound delivery planning. + */ export type OutboundDeliveryFormattingOptions = { textLimit?: number; maxLinesPerMessage?: number; diff --git a/src/infra/outbound/identity-types.ts b/src/infra/outbound/identity-types.ts index 57c68a3629e..90f3e520f57 100644 --- a/src/infra/outbound/identity-types.ts +++ b/src/infra/outbound/identity-types.ts @@ -1,3 +1,4 @@ +/** Agent identity metadata that outbound channels can render with a message. */ export type OutboundIdentity = { name?: string; avatarUrl?: string; diff --git a/src/infra/outbound/identity.ts b/src/infra/outbound/identity.ts index b1cd9037dae..214617a148a 100644 --- a/src/infra/outbound/identity.ts +++ b/src/infra/outbound/identity.ts @@ -6,6 +6,7 @@ import type { OutboundIdentity } from "./identity-types.js"; export type { OutboundIdentity } from "./identity-types.js"; +/** Trims outbound identity fields and drops empty identity payloads. */ export function normalizeOutboundIdentity( identity?: OutboundIdentity | null, ): OutboundIdentity | undefined { @@ -22,6 +23,7 @@ export function normalizeOutboundIdentity( return { name, avatarUrl, emoji, theme }; } +/** Resolves an agent's configured identity into channel-safe outbound metadata. */ export function resolveAgentOutboundIdentity( cfg: OpenClawConfig, agentId: string, diff --git a/src/infra/outbound/message-action-normalization.ts b/src/infra/outbound/message-action-normalization.ts index 288890e3b7e..c12e7b7ffee 100644 --- a/src/infra/outbound/message-action-normalization.ts +++ b/src/infra/outbound/message-action-normalization.ts @@ -10,6 +10,7 @@ import { import { applyTargetToParams } from "./channel-target.js"; import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js"; +/** Normalizes message-action args before target validation and dispatch. */ export function normalizeMessageActionInput(params: { action: ChannelMessageActionName; args: Record; @@ -29,6 +30,7 @@ export function normalizeMessageActionInput(params: { (normalizeOptionalString(normalizedArgs.channelId) ?? "").length > 0; if (explicitTarget && hasLegacyTargetFields) { + // Canonical `target` wins over old `to`/`channelId` aliases before validation. delete normalizedArgs.to; delete normalizedArgs.channelId; } diff --git a/src/infra/outbound/message-action-param-keys.ts b/src/infra/outbound/message-action-param-keys.ts index bbe409d6e8f..af463ae9e99 100644 --- a/src/infra/outbound/message-action-param-keys.ts +++ b/src/infra/outbound/message-action-param-keys.ts @@ -42,6 +42,9 @@ const STANDARD_MESSAGE_ACTION_PARAM_KEYS = new Set([ "to", ]); +/** + * Detects non-standard message action params that may need plugin-owned handling. + */ export function hasPotentialPluginActionParam(params: Record): boolean { return Object.entries(params).some(([key, value]) => { if (STANDARD_MESSAGE_ACTION_PARAM_KEYS.has(key)) { diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index c2c29f30881..d1bce843c02 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -21,6 +21,7 @@ import { resolveSnakeCaseParamKey } from "../../param-key.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; import { hasPotentialPluginActionParam } from "./message-action-param-keys.js"; +/** Shared boolean param reader used by message-action argument normalization. */ export const readBooleanParam = readBooleanParamShared; const BASE_ACTION_MEDIA_SOURCE_PARAM_KEYS = [ @@ -140,6 +141,7 @@ function buildActionMediaSourceParamKeys(extraParamKeys?: readonly string[]): st return Array.from(keys); } +/** Resolves plugin-declared media source param aliases for a message action. */ export function resolveExtraActionMediaSourceParamKeys(params: { cfg: OpenClawConfig; action?: ChannelMessageActionName; @@ -168,6 +170,7 @@ export function resolveExtraActionMediaSourceParamKeys(params: { }); } +/** Collects candidate media source strings from message-action args. */ export function collectActionMediaSourceHints( args: Record, extraParamKeys?: readonly string[], @@ -248,6 +251,7 @@ function normalizeBase64Payload(params: { base64?: string; contentType?: string }; } +/** Media access policy used when hydrating attachment action parameters. */ export type AttachmentMediaPolicy = | { mode: "sandbox"; @@ -260,6 +264,7 @@ export type AttachmentMediaPolicy = mediaReadFile?: OutboundMediaReadFile; }; +/** Chooses sandbox or host media loading policy for attachment hydration. */ export function resolveAttachmentMediaPolicy(params: { sandboxRoot?: string; mediaAccess?: OutboundMediaAccess; @@ -391,6 +396,7 @@ async function hydrateAttachmentPayload(params: { } } +/** Rewrites action media params to sandbox-safe paths and rejects data URLs. */ export async function normalizeSandboxMediaParams(params: { args: Record; mediaPolicy: AttachmentMediaPolicy; @@ -437,6 +443,7 @@ export async function normalizeSandboxMediaParams(params: { } } +/** Normalizes a list of media hints against an optional sandbox root. */ export async function normalizeSandboxMediaList(params: { values: string[]; sandboxRoot?: string; @@ -511,6 +518,7 @@ async function hydrateAttachmentActionPayload(params: { }); } +/** Hydrates attachment-bearing message actions with base64 buffers and metadata. */ export async function hydrateAttachmentParamsForAction(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -551,6 +559,7 @@ export async function hydrateAttachmentParamsForAction(params: { }); } +/** Parses a named string param as JSON for structured message action fields. */ export function parseJsonMessageParam(params: Record, key: string): void { const raw = params[key]; if (typeof raw !== "string") { @@ -568,6 +577,7 @@ export function parseJsonMessageParam(params: Record, key: stri } } +/** Parses the interactive message action param as JSON when provided as a string. */ export function parseInteractiveParam(params: Record): void { const raw = params.interactive; if (typeof raw !== "string") { diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index a6c9b2078f2..7d32be91d3a 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -6,8 +6,14 @@ import { getBootstrapChannelPlugin } from "../../channels/plugins/bootstrap-regi import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; import { hasPotentialPluginActionParam } from "./message-action-param-keys.js"; +/** + * Canonical parameter shape used by an outbound message action target. + */ export type MessageActionTargetMode = "to" | "channelId" | "none"; +/** + * Target-parameter policy for each supported channel message action. + */ export const MESSAGE_ACTION_TARGET_MODE: Record = { send: "to", @@ -97,6 +103,7 @@ function listActionTargetAliasSpecs( if (!normalizedChannel || !hasPotentialPluginActionParam(params)) { return specs; } + // Plugin aliases are only checked after cheap param-shape screening to avoid bootstrap reads. const plugin = getBootstrapChannelPlugin(normalizedChannel); const channelSpec = plugin?.actions?.messageActionTargetAliases?.[action]; if (channelSpec) { @@ -105,10 +112,16 @@ function listActionTargetAliasSpecs( return specs; } +/** + * Reports whether an action normally needs a destination target. + */ export function actionRequiresTarget(action: ChannelMessageActionName): boolean { return MESSAGE_ACTION_TARGET_MODE[action] !== "none"; } +/** + * Detects whether an action invocation already carries a usable target. + */ export function actionHasTarget( action: ChannelMessageActionName, params: Record, diff --git a/src/infra/outbound/message-action-threading.ts b/src/infra/outbound/message-action-threading.ts index de56900ef8f..b5a11f3c45a 100644 --- a/src/infra/outbound/message-action-threading.ts +++ b/src/infra/outbound/message-action-threading.ts @@ -17,6 +17,7 @@ function suppressesImplicitThreading(actionParams: Record): boo return actionParams.topLevel === true || actionParams.threadId === null; } +/** Resolves and writes the outbound thread id used by message-action sends. */ export function resolveAndApplyOutboundThreadId( actionParams: Record, context: { @@ -28,6 +29,7 @@ export function resolveAndApplyOutboundThreadId( }, ): string | undefined { const threadId = readStringParam(actionParams, "threadId"); + // `topLevel` and explicit null thread ids are caller opt-outs from inherited threading. if (!threadId && suppressesImplicitThreading(actionParams)) { return undefined; } @@ -69,6 +71,7 @@ function isSameConversationTarget( return explicitTarget.trim() === currentChannelId; } +/** Resolves and writes reply-to metadata for same-conversation message-action sends. */ export function resolveAndApplyOutboundReplyToId( actionParams: Record, context: { @@ -108,6 +111,7 @@ export function resolveAndApplyOutboundReplyToId( if (hasRepliedRef?.value) { return undefined; } + // First-reply mode consumes the current inbound message once across batched sends. if (hasRepliedRef) { hasRepliedRef.value = true; } @@ -122,6 +126,7 @@ export function resolveAndApplyOutboundReplyToId( return resolvedReplyToId; } +/** Prepares outbound session mirroring metadata for message-action sends. */ export async function prepareOutboundMirrorRoute(params: { cfg: OpenClawConfig; channel: ChannelId; diff --git a/src/infra/outbound/message-action-tts.ts b/src/infra/outbound/message-action-tts.ts index b7dc9b4989b..657c84f8e4d 100644 --- a/src/infra/outbound/message-action-tts.ts +++ b/src/infra/outbound/message-action-tts.ts @@ -11,10 +11,12 @@ import { shouldAttemptTtsPayload } from "../../tts/tts-config.js"; let ttsRuntimePromise: Promise | null = null; function loadMessageActionTtsRuntime() { + // Keep the TTS runtime lazy so ordinary message sends do not pay the provider import cost. ttsRuntimePromise ??= import("../../tts/tts.runtime.js"); return ttsRuntimePromise; } +/** Reads the session-level TTS auto mode for a message-action send. */ export function resolveMessageActionSessionTtsAuto(params: { cfg: OpenClawConfig; sessionKey?: string; @@ -29,10 +31,12 @@ export function resolveMessageActionSessionTtsAuto(params: { const store = loadSessionStore(storePath); return resolveSessionStoreEntry({ store, sessionKey }).existing?.ttsAuto; } catch { + // Missing or unreadable session stores should not block message delivery. return undefined; } } +/** Applies automatic TTS to a message-action send payload when config/session policy allows it. */ export async function maybeApplyTtsToMessageActionSendPayload(params: { payload: ReplyPayload; cfg: OpenClawConfig; diff --git a/src/infra/outbound/message-gateway-options.ts b/src/infra/outbound/message-gateway-options.ts index 24519ee24c4..0804649ec97 100644 --- a/src/infra/outbound/message-gateway-options.ts +++ b/src/infra/outbound/message-gateway-options.ts @@ -6,6 +6,7 @@ import { type GatewayClientName, } from "../../utils/message-channel.js"; +/** Raw gateway options accepted by outbound message senders. */ export type OutboundMessageGatewayOptionsInput = { url?: string; token?: string; @@ -15,9 +16,11 @@ export type OutboundMessageGatewayOptionsInput = { mode?: GatewayClientMode; }; +/** Normalizes outbound gateway options and fills CLI defaults. */ export function resolveOutboundMessageGatewayOptions(gateway?: OutboundMessageGatewayOptionsInput) { const clientName = gateway?.clientName ?? GATEWAY_CLIENT_NAMES.CLI; const mode = gateway?.mode ?? GATEWAY_CLIENT_MODES.CLI; + // Backend-mode callers and gateway clients use the managed transport endpoint. const url = mode === GATEWAY_CLIENT_MODES.BACKEND || clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT ? undefined diff --git a/src/infra/outbound/message-plan.ts b/src/infra/outbound/message-plan.ts index cadff0c98d2..189efedc0d2 100644 --- a/src/infra/outbound/message-plan.ts +++ b/src/infra/outbound/message-plan.ts @@ -6,6 +6,9 @@ import { import type { OutboundDeliveryFormattingOptions } from "./formatting.js"; import type { ReplyToOverride } from "./reply-policy.js"; +/** + * Per-send overrides carried from outbound planning into channel delivery. + */ export type OutboundMessageSendOverrides = ReplyToOverride & { threadId?: string | number | null; audioAsVoice?: boolean; @@ -13,6 +16,9 @@ export type OutboundMessageSendOverrides = ReplyToOverride & { formatting?: OutboundDeliveryFormattingOptions; }; +/** + * Planned outbound delivery unit after text chunking or media expansion. + */ export type OutboundMessageUnit = | { kind: "text"; @@ -26,6 +32,9 @@ export type OutboundMessageUnit = overrides: OutboundMessageSendOverrides; }; +/** + * Splits outbound text with optional formatting-aware context. + */ export type OutboundMessageChunker = ( text: string, limit: number, @@ -38,6 +47,7 @@ function withPlannedReplyTo( overrides: OutboundMessageSendOverrides, consumeReplyTo?: PlanReplyToConsumption, ): OutboundMessageSendOverrides { + // Reply-to policies can be single-use; clone overrides before consuming the implicit slot. return consumeReplyTo ? consumeReplyTo({ ...overrides }) : { ...overrides }; } @@ -61,6 +71,9 @@ function chunkTextForPlan(params: { : params.chunker(params.text, params.limit); } +/** + * Plans text sends, preserving reply-to policy across chunked delivery units. + */ export function planOutboundTextMessageUnits(params: { text: string; overrides: OutboundMessageSendOverrides; @@ -125,6 +138,9 @@ export function planOutboundTextMessageUnits(params: { }).map(planChunkedTextUnit); } +/** + * Plans media sends with a caption only on the leading media unit. + */ export function planOutboundMediaMessageUnits(params: { caption: string; mediaUrls: readonly string[]; diff --git a/src/infra/outbound/mirror.ts b/src/infra/outbound/mirror.ts index 09b68016ec6..c3acc14faf2 100644 --- a/src/infra/outbound/mirror.ts +++ b/src/infra/outbound/mirror.ts @@ -1,3 +1,6 @@ +/** + * Transcript append data emitted after an outbound send completes. + */ export type OutboundMirror = { sessionKey: string; agentId?: string; @@ -6,6 +9,9 @@ export type OutboundMirror = { idempotencyKey?: string; }; +/** + * Delivery-layer mirror data with optional group/channel correlation metadata. + */ export type DeliveryMirror = OutboundMirror & { /** Whether this message is being sent in a group/channel context */ isGroup?: boolean; diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 3efbc96316a..2c5affa9acc 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -11,8 +11,14 @@ import type { MessagePresentation } from "../../interactive/payload.js"; import { normalizeTargetForProvider } from "./target-normalization.js"; import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js"; +/** + * Builds a channel-native presentation for forwarded cross-context text. + */ export type CrossContextPresentationBuilder = (message: string) => MessagePresentation; +/** + * Text and optional rich-presentation wrapper for cross-context outbound sends. + */ export type CrossContextDecoration = { prefix: string; suffix: string; @@ -104,6 +110,7 @@ function resolveAgentMessageToolsConfig( if (!agentConfig) { return globalConfig; } + // Agent message-tool policy is an override layer; nested policy groups must merge independently. return { ...globalConfig, ...agentConfig, @@ -138,6 +145,9 @@ function resolveAgentMessageToolsConfig( }; } +/** + * Resolves the message-tool policy after applying any agent-specific overrides. + */ export function resolveEffectiveMessageToolsConfig(params: { cfg: OpenClawConfig; agentId?: string | null; @@ -145,6 +155,9 @@ export function resolveEffectiveMessageToolsConfig(params: { return resolveAgentMessageToolsConfig(params.cfg, params.agentId); } +/** + * Returns the normalized allowed message actions for an agent or the global policy. + */ export function resolveAllowedMessageActions(params: { cfg: OpenClawConfig; agentId?: string | null; @@ -157,6 +170,9 @@ export function resolveAllowedMessageActions(params: { return normalized.length > 0 ? normalized : undefined; } +/** + * Rejects disabled message actions before channel-specific send handling runs. + */ export function enforceMessageActionAllowlist(params: { cfg: OpenClawConfig; agentId?: string | null; @@ -169,6 +185,9 @@ export function enforceMessageActionAllowlist(params: { throw new Error(`Message action "${params.action}" is disabled for this agent.`); } +/** + * Enforces cross-context message-send policy for a bound channel/thread context. + */ export function enforceCrossContextPolicy(params: { channel: ChannelId; action: ChannelMessageActionName; @@ -197,6 +216,7 @@ export function enforceCrossContextPolicy(params: { const allowWithinProvider = messageConfig?.crossContext?.allowWithinProvider !== false; const allowAcrossProviders = messageConfig?.crossContext?.allowAcrossProviders === true; + // Provider mismatch is stronger than target mismatch; normalize targets only within one provider. if (currentProvider && currentProvider !== params.channel) { if (!allowAcrossProviders) { throw new Error( @@ -224,6 +244,9 @@ export function enforceCrossContextPolicy(params: { ); } +/** + * Builds cross-context marker text or a channel-native presentation for forwarded sends. + */ export async function buildCrossContextDecoration(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -235,7 +258,7 @@ export async function buildCrossContextDecoration(params: { if (!params.toolContext?.currentChannelId) { return null; } - // Skip decoration for direct tool sends (agent composing, not forwarding) + // Direct tool sends are authored for their destination, not forwarded from a bound context. if (params.toolContext.skipCrossContextDecoration) { return null; } @@ -284,10 +307,16 @@ export async function buildCrossContextDecoration(params: { return { prefix, suffix, presentationBuilder }; } +/** + * Reports whether an action can carry a cross-context marker in outbound payloads. + */ export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean { return CONTEXT_MARKER_ACTIONS.has(action); } +/** + * Applies text markers or a preferred rich presentation to a cross-context message. + */ export function applyCrossContextDecoration(params: { message: string; decoration: CrossContextDecoration; diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 4264e8d0f15..fdcd61a0205 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -20,6 +20,7 @@ import { sendMessage, sendPoll } from "./message.js"; import type { OutboundMirror } from "./mirror.js"; import { extractToolPayload } from "./tool-payload.js"; +/** Gateway connection settings forwarded to outbound send helpers. */ export type OutboundGatewayContext = { url?: string; token?: string; @@ -29,6 +30,7 @@ export type OutboundGatewayContext = { mode: GatewayClientMode; }; +/** Shared execution context for message-tool send and poll actions. */ export type OutboundSendContext = { cfg: OpenClawConfig; channel: ChannelId; @@ -234,6 +236,7 @@ async function tryPreparePluginSendPayload(params: { ); } +/** Executes a message-tool send through plugin handlers or the core outbound path. */ export async function executeSendAction(params: { ctx: OutboundSendContext; to: string; @@ -270,6 +273,7 @@ export async function executeSendAction(params: { }); if (preparedPayload) { throwIfAborted(params.ctx.abortSignal); + // Prepared plugin payloads still use core delivery so queueing, hooks, and mirrors stay uniform. const result = await sendCoreMessage({ ...params, queuePolicy, @@ -322,6 +326,7 @@ export async function executeSendAction(params: { }; } +/** Executes a message-tool poll through plugin handlers or the core poll path. */ export async function executePollAction(params: { ctx: OutboundSendContext; resolveCorePoll: () => { diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 85a1733decb..119974f411f 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -14,6 +14,7 @@ import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { buildOutboundBaseSessionKey } from "./base-session-key.js"; import type { ResolvedMessagingTarget } from "./target-resolver.js"; +/** Session route produced for an outbound message target. */ export type OutboundSessionRoute = { sessionKey: string; baseSessionKey: string; @@ -24,6 +25,7 @@ export type OutboundSessionRoute = { threadId?: string | number; }; +/** Inputs required to resolve an outbound target into a session route. */ export type ResolveOutboundSessionRouteParams = { cfg: OpenClawConfig; channel: ChannelId; @@ -177,6 +179,7 @@ function resolveFallbackSession( }; } +/** Resolves the session route used to mirror outbound delivery into conversation state. */ export async function resolveOutboundSessionRoute( params: ResolveOutboundSessionRouteParams, ): Promise { @@ -188,11 +191,13 @@ export async function resolveOutboundSessionRoute( const resolver = resolveOutboundChannelPlugin(params.channel)?.messaging ?.resolveOutboundSessionRoute; if (resolver) { + // Channel plugins can provide richer route semantics than the generic target parser. return await resolver(nextParams); } return resolveFallbackSession(nextParams); } +/** Persists best-effort session metadata for an outbound-only route. */ export async function ensureOutboundSessionEntry(params: { cfg: OpenClawConfig; channel: ChannelId; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index fe7d1b4cc63..1aec76faf4c 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -21,6 +21,7 @@ import { import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citation-control-markers.js"; +/** Runtime-ready outbound payload after text/media/rich-content normalization. */ export type NormalizedOutboundPayload = { text: string; mediaUrls: string[]; @@ -33,6 +34,7 @@ export type NormalizedOutboundPayload = { hookContent?: string; }; +/** JSON-safe outbound payload projection used for envelopes and diagnostics. */ export type OutboundPayloadJson = { text: string; mediaUrl: string | null; @@ -44,6 +46,7 @@ export type OutboundPayloadJson = { channelData?: Record; }; +/** Prepared payload entry that keeps source indexing plus reusable projections. */ export type OutboundPayloadPlan = { sourceIndex: number; payload: ReplyPayload; @@ -61,6 +64,7 @@ type OutboundPayloadPlanContext = { extractMarkdownImages?: boolean; }; +/** Text/media projection used to mirror outbound replies into session state. */ export type OutboundPayloadMirror = { text: string; mediaUrls: string[]; @@ -247,6 +251,7 @@ function createOutboundPayloadPlanEntry( }; } +/** Builds the canonical outbound payload plan shared by delivery projections. */ export function createOutboundPayloadPlan( payloads: readonly ReplyPayload[], context: OutboundPayloadPlanContext = {}, @@ -281,12 +286,14 @@ export function createOutboundPayloadPlan( return plan; } +/** Projects a payload plan back to normalized reply payloads for delivery. */ export function projectOutboundPayloadPlanForDelivery( plan: readonly OutboundPayloadPlan[], ): ReplyPayload[] { return plan.map((entry) => entry.payload); } +/** Projects a payload plan into runtime transport payload summaries. */ export function projectOutboundPayloadPlanForOutbound( plan: readonly OutboundPayloadPlan[], ): NormalizedOutboundPayload[] { @@ -315,6 +322,7 @@ export function projectOutboundPayloadPlanForOutbound( return normalizedPayloads; } +/** Projects a payload plan into JSON-safe envelope/debug payloads. */ export function projectOutboundPayloadPlanForJson( plan: readonly OutboundPayloadPlan[], ): OutboundPayloadJson[] { @@ -335,6 +343,7 @@ export function projectOutboundPayloadPlanForJson( return normalized; } +/** Projects a payload plan into text/media content for session mirroring. */ export function projectOutboundPayloadPlanForMirror( plan: readonly OutboundPayloadPlan[], ): OutboundPayloadMirror { @@ -347,6 +356,7 @@ export function projectOutboundPayloadPlanForMirror( }; } +/** Summarizes one reply payload for channel transport and hook processing. */ export function summarizeOutboundPayloadForTransport( payload: ReplyPayload, ): NormalizedOutboundPayload { @@ -369,24 +379,28 @@ export function summarizeOutboundPayloadForTransport( }; } +/** Normalizes reply payloads for direct delivery using the shared plan. */ export function normalizeReplyPayloadsForDelivery( payloads: readonly ReplyPayload[], ): ReplyPayload[] { return projectOutboundPayloadPlanForDelivery(createOutboundPayloadPlan(payloads)); } +/** Normalizes reply payloads into runtime outbound transport payloads. */ export function normalizeOutboundPayloads( payloads: readonly ReplyPayload[], ): NormalizedOutboundPayload[] { return projectOutboundPayloadPlanForOutbound(createOutboundPayloadPlan(payloads)); } +/** Normalizes reply payloads into JSON-safe outbound envelope payloads. */ export function normalizeOutboundPayloadsForJson( payloads: readonly ReplyPayload[], ): OutboundPayloadJson[] { return projectOutboundPayloadPlanForJson(createOutboundPayloadPlan(payloads)); } +/** Formats normalized outbound payload text and attachments for logs. */ export function formatOutboundPayloadLog( payload: Pick & { mediaUrls: readonly string[]; diff --git a/src/infra/outbound/reply-payload-normalize.ts b/src/infra/outbound/reply-payload-normalize.ts index 4f5e8d6f6db..f4ec8ed8706 100644 --- a/src/infra/outbound/reply-payload-normalize.ts +++ b/src/infra/outbound/reply-payload-normalize.ts @@ -1,6 +1,9 @@ import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import type { ReplyPayload as InternalReplyPayload } from "../../auto-reply/reply-payload.js"; +/** + * Outbound-facing subset of reply payload fields accepted from loose producers. + */ export type OutboundReplyPayload = { text?: string; mediaUrls?: string[]; diff --git a/src/infra/outbound/reply-policy.ts b/src/infra/outbound/reply-policy.ts index 85583143fd8..0873c254289 100644 --- a/src/infra/outbound/reply-policy.ts +++ b/src/infra/outbound/reply-policy.ts @@ -2,16 +2,19 @@ import { isSingleUseReplyToMode } from "../../auto-reply/reply/reply-reference.j import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/types.js"; +/** Per-payload reply target override passed to outbound channel adapters. */ export type ReplyToOverride = { replyToId?: string | null | undefined; replyToIdSource?: ReplyToResolution["source"] | undefined; }; +/** Resolved reply target plus whether it came from payload or ambient context. */ export type ReplyToResolution = { replyToId?: string; source?: "explicit" | "implicit"; }; +/** Creates a reply-to supplier that consumes implicit single-use reply ids once. */ export function createReplyToFanout(params: { replyToId?: string | null; replyToMode?: ReplyToMode; @@ -36,6 +39,7 @@ export function createReplyToFanout(params: { }; } +/** Builds per-payload reply routing policy for outbound delivery batches. */ export function createReplyToDeliveryPolicy(params: { replyToId?: string | null; replyToMode?: ReplyToMode; @@ -71,6 +75,8 @@ export function createReplyToDeliveryPolicy(params: { return overrides; } if (replyToConsumed) { + // Single-use implicit reply targets apply to the first delivered payload only; + // later payloads must not accidentally thread into the same source message. return { ...overrides, replyToId: undefined }; } replyToConsumed = true; diff --git a/src/infra/outbound/sanitize-text.ts b/src/infra/outbound/sanitize-text.ts index f702757cf87..26f3d7734f1 100644 --- a/src/infra/outbound/sanitize-text.ts +++ b/src/infra/outbound/sanitize-text.ts @@ -1,16 +1,3 @@ -/** - * Sanitize model output for plain-text messaging surfaces. - * - * LLMs occasionally produce HTML tags (`
`, ``, ``, etc.) that render - * correctly on web but appear as literal text on WhatsApp, Signal, SMS, and IRC. - * - * Converts common inline HTML to lightweight-markup equivalents used by - * WhatsApp/Signal/Telegram and strips any remaining tags. - * - * @see https://github.com/openclaw/openclaw/issues/31884 - * @see https://github.com/openclaw/openclaw/issues/18558 - */ - import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js"; const INTERNAL_RUNTIME_SCAFFOLDING_TAGS = ["system-reminder", "previous_response"] as const; @@ -60,6 +47,8 @@ function stripDelimitedRuntimeBlock(text: string, begin: string, end: string): s `${standaloneLinePattern(begin)}[\\s\\S]*?${standaloneLinePattern(end)}`, "g", ); + // If the closing delimiter is missing, drop the rest rather than leaking + // internal runtime context to user-visible outbound text. const unmatchedBeginRe = new RegExp(`${standaloneLinePattern(begin)}[\\s\\S]*$`, "g"); return stripStandaloneMarkerLine( text.replace(closedBlockRe, "").replace(unmatchedBeginRe, ""), @@ -102,6 +91,7 @@ function unwrapPromptDataWrapperLines(text: string): string { return changed ? output.join("\n") : text; } +/** Removes prompt/runtime scaffolding that must never leak to plain-text channels. */ export function stripInternalRuntimeScaffolding(text: string): string { let stripped = unwrapPromptDataWrapperLines(text) .replace(INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE, "") diff --git a/src/infra/outbound/send-deps.ts b/src/infra/outbound/send-deps.ts index b6394cede17..c7120b5a57d 100644 --- a/src/infra/outbound/send-deps.ts +++ b/src/infra/outbound/send-deps.ts @@ -5,6 +5,9 @@ */ export type OutboundSendDeps = { [channelId: string]: unknown }; +/** + * Builds historical dependency keys for channel send functions. + */ export function resolveLegacyOutboundSendDepKeys(channelId: string): string[] { const compact = channelId.replace(/[^a-z0-9]+/gi, ""); if (!compact) { @@ -22,10 +25,16 @@ export function resolveLegacyOutboundSendDepKeys(channelId: string): string[] { return [...keys]; } +/** + * Extra historical keys to try after the normalized channel-derived keys. + */ export type ResolveOutboundSendDepOptions = { legacyKeys?: readonly string[]; }; +/** + * Resolves a channel send dependency from modern channel IDs or legacy helper keys. + */ // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Channel-specific dependency lookup returns caller-typed values. export function resolveOutboundSendDep( deps: OutboundSendDeps | null | undefined, diff --git a/src/infra/outbound/session-binding-normalization.ts b/src/infra/outbound/session-binding-normalization.ts index 49afd933aad..2a1a3edb3fd 100644 --- a/src/infra/outbound/session-binding-normalization.ts +++ b/src/infra/outbound/session-binding-normalization.ts @@ -4,6 +4,9 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { normalizeAccountId } from "../../routing/session-key.js"; +/** + * Minimal conversation shape normalized before binding lookup or storage. + */ export type ConversationRefShape = { channel: string; accountId: string; @@ -16,6 +19,9 @@ type ConversationTargetRefShape = { parentConversationId?: string | null; }; +/** + * Normalizes conversation ids and drops self-referential parent ids. + */ export function normalizeConversationTargetRef(ref: T): T { const conversationId = normalizeOptionalString(ref.conversationId) ?? ""; const parentConversationId = normalizeOptionalString(ref.parentConversationId); @@ -29,6 +35,9 @@ export function normalizeConversationTargetRef(ref: T): T { const normalizedTarget = normalizeConversationTargetRef(ref); return { @@ -38,6 +47,9 @@ export function normalizeConversationRef(ref: T) }; } +/** + * Builds the adapter registry key shared by channel/account scoped bindings. + */ export function buildChannelAccountKey(params: { channel: string; accountId: string }): string { return `${normalizeLowercaseStringOrEmpty(params.channel)}:${normalizeAccountId(params.accountId)}`; } diff --git a/src/infra/outbound/session-binding.types.ts b/src/infra/outbound/session-binding.types.ts index 11b523ce991..7d81a6c5c88 100644 --- a/src/infra/outbound/session-binding.types.ts +++ b/src/infra/outbound/session-binding.types.ts @@ -1,11 +1,29 @@ +/** + * Runtime destination a conversation binding points at. + */ export type BindingTargetKind = "subagent" | "session"; + +/** + * Lifecycle state for a registered session binding. + */ export type BindingStatus = "active" | "ending" | "ended"; + +/** + * Placement requested when binding a child/current session to a conversation. + */ export type SessionBindingPlacement = "current" | "child"; + +/** + * Stable error codes emitted by session-binding service failures. + */ export type SessionBindingErrorCode = | "BINDING_ADAPTER_UNAVAILABLE" | "BINDING_CAPABILITY_UNSUPPORTED" | "BINDING_CREATE_FAILED"; +/** + * Channel/account/conversation tuple used to resolve a bound delivery route. + */ export type ConversationRef = { channel: string; accountId: string; @@ -13,6 +31,9 @@ export type ConversationRef = { parentConversationId?: string; }; +/** + * Persistable record that connects one conversation to one target session. + */ export type SessionBindingRecord = { bindingId: string; targetSessionKey: string; @@ -24,6 +45,9 @@ export type SessionBindingRecord = { metadata?: Record; }; +/** + * Request to create or refresh a session binding for a conversation. + */ export type SessionBindingBindInput = { targetSessionKey: string; targetKind: BindingTargetKind; @@ -33,12 +57,18 @@ export type SessionBindingBindInput = { ttlMs?: number; }; +/** + * Request to remove bindings by id or target session. + */ export type SessionBindingUnbindInput = { bindingId?: string; targetSessionKey?: string; reason: string; }; +/** + * Capability summary exposed by the active binding adapter for a conversation scope. + */ export type SessionBindingCapabilities = { adapterAvailable: boolean; bindSupported: boolean; diff --git a/src/infra/outbound/session-context.ts b/src/infra/outbound/session-context.ts index eec93cdd900..8f295082fd0 100644 --- a/src/infra/outbound/session-context.ts +++ b/src/infra/outbound/session-context.ts @@ -47,6 +47,7 @@ export type OutboundSessionContext = { requesterSenderE164?: string; }; +/** Builds the outbound delivery session context, omitting empty policy fields. */ export function buildOutboundSessionContext(params: { cfg: OpenClawConfig; sessionKey?: string | null; @@ -82,6 +83,8 @@ export function buildOutboundSessionContext(params: { const derivedAgentId = key ? resolveSessionAgentId({ sessionKey: key, config: params.cfg }) : undefined; + // Prefer explicit caller ownership, but derive from the canonical session key + // so redirected deliveries still get workspace-scoped media policy. const agentId = explicitAgentId ?? derivedAgentId; if ( !key && diff --git a/src/infra/outbound/source-delivery-plan.ts b/src/infra/outbound/source-delivery-plan.ts index a6e118b1153..0f6d597a69c 100644 --- a/src/infra/outbound/source-delivery-plan.ts +++ b/src/infra/outbound/source-delivery-plan.ts @@ -2,6 +2,7 @@ import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import { normalizeTargetForProvider } from "./target-normalization.js"; +/** Owner responsible for making source delivery visible to the user. */ export type SourceVisibleDeliveryOwner = | "automatic_source" | "message_tool" @@ -9,6 +10,7 @@ export type SourceVisibleDeliveryOwner = | "direct_fallback" | "none"; +/** Reason code explaining why source delivery policy took this shape. */ export type SourceDeliveryPlanReason = | "config" | "room_event" @@ -18,6 +20,7 @@ export type SourceDeliveryPlanReason = | "media_completion" | "subagent_completion"; +/** Configured or inferred destination source delivery must satisfy. */ export type SourceDeliveryTarget = { channel?: string; to?: string; @@ -25,6 +28,7 @@ export type SourceDeliveryTarget = { threadId?: string | number; }; +/** Message-tool destination observed during a run. */ export type SourceDeliveryMessageToolTarget = { tool?: string; provider?: string; @@ -37,12 +41,14 @@ export type SourceDeliveryMessageToolTarget = { mediaUrls?: string[]; }; +/** Visible message-tool delivery with target verification state. */ export type SourceDeliveryVisibleDelivery = { via: "message_tool"; target: SourceDeliveryMessageToolTarget; verifiedTarget: boolean; }; +/** Resolved source-delivery satisfaction result after a run. */ export type SourceDeliveryOutcome = { visibleDeliveries: SourceDeliveryVisibleDelivery[]; verifiedMessageToolDelivery: boolean; @@ -50,6 +56,7 @@ export type SourceDeliveryOutcome = { unverifiedMessageToolDelivery: boolean; }; +/** Policy contract that decides message-tool ownership and fallback delivery. */ export type SourceDeliveryPlan = { owner: SourceVisibleDeliveryOwner; reason: SourceDeliveryPlanReason; @@ -99,6 +106,8 @@ function deliveryTargetsMatch(channel: string, targetTo: string, deliveryTo: str targetKind === deliveryKind && ["channel", "conversation", "group", "user"].includes(targetKind) ) { + // Some provider-owned ids are case-sensitive while Slack/Discord ids are + // compared case-insensitively; decide that before generic target normalization. const targetId = targetPrefixed?.[2]?.trim(); const deliveryId = deliveryPrefixed?.[2]?.trim(); if (caseSensitivePrefixedTargetProviders.has(channel)) { @@ -122,6 +131,7 @@ function extractTopicThreadId(targetTo: string): string | undefined { return targetTo.match(/:topic:(\d+)$/i)?.[1]; } +/** Compares a message-tool target with the required source delivery target. */ export function sourceDeliveryTargetsMatch( target: SourceDeliveryMessageToolTarget, delivery: SourceDeliveryTarget, @@ -154,6 +164,7 @@ export function sourceDeliveryTargetsMatch( return deliveryThreadId === targetThreadId; } +/** Builds a source delivery plan from ownership and fallback inputs. */ export function createSourceDeliveryPlan(params: { owner: SourceVisibleDeliveryOwner; reason: SourceDeliveryPlanReason; @@ -216,6 +227,7 @@ function resolveImplicitMessageToolDeliveryTarget( }; } +/** Evaluates whether observed message-tool sends satisfy the source delivery plan. */ export function resolveSourceDeliveryOutcome( plan: SourceDeliveryPlan, params: { @@ -225,6 +237,7 @@ export function resolveSourceDeliveryOutcome( ): SourceDeliveryOutcome { const didSendViaMessageTool = params.didSendViaMessageTool === true; const explicitTargets = params.messageToolSentTargets ?? []; + // A send without explicit target metadata still counts when the plan has a default target. const sentTargets = explicitTargets.length > 0 ? explicitTargets diff --git a/src/infra/outbound/target-errors.ts b/src/infra/outbound/target-errors.ts index eedaf66631b..8f3a858dd9f 100644 --- a/src/infra/outbound/target-errors.ts +++ b/src/infra/outbound/target-errors.ts @@ -1,23 +1,41 @@ +/** + * Formats the user-facing error shown when no target is available. + */ export function missingTargetMessage(provider: string, hint?: string): string { return `Delivering to ${provider} requires target${formatTargetHint(hint)}`; } +/** + * Builds an Error for missing outbound target failures. + */ export function missingTargetError(provider: string, hint?: string): Error { return new Error(missingTargetMessage(provider, hint)); } +/** + * Formats the user-facing error shown when a target name resolves ambiguously. + */ export function ambiguousTargetMessage(provider: string, raw: string, hint?: string): string { return `Ambiguous target "${raw}" for ${provider}. Provide a unique name or an explicit id.${formatTargetHint(hint, true)}`; } +/** + * Builds an Error for ambiguous outbound target failures. + */ export function ambiguousTargetError(provider: string, raw: string, hint?: string): Error { return new Error(ambiguousTargetMessage(provider, raw, hint)); } +/** + * Formats the user-facing error shown when no target matches the input. + */ export function unknownTargetMessage(provider: string, raw: string, hint?: string): string { return `Unknown target "${raw}" for ${provider}.${formatTargetHint(hint, true)}`; } +/** + * Builds an Error for unknown outbound target failures. + */ export function unknownTargetError(provider: string, raw: string, hint?: string): Error { return new Error(unknownTargetMessage(provider, raw, hint)); } diff --git a/src/infra/outbound/target-id-resolution.ts b/src/infra/outbound/target-id-resolution.ts index fa9d22103a9..e7ee1e8c2a7 100644 --- a/src/infra/outbound/target-id-resolution.ts +++ b/src/infra/outbound/target-id-resolution.ts @@ -2,6 +2,7 @@ import type { ChannelDirectoryEntryKind, ChannelId } from "../../channels/plugin import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { maybeResolvePluginMessagingTarget } from "./target-normalization.js"; +/** Plugin-resolved destination for a channel target that already looks id-like. */ export type ResolvedIdLikeTarget = { to: string; kind: ChannelDirectoryEntryKind | "channel"; @@ -10,6 +11,7 @@ export type ResolvedIdLikeTarget = { resolutionSource: "plugin"; }; +/** Resolves an id-like outbound target through the channel plugin directory. */ export async function maybeResolveIdLikeTarget(params: { cfg: OpenClawConfig; channel: ChannelId; diff --git a/src/infra/outbound/target-normalization.ts b/src/infra/outbound/target-normalization.ts index 6a8a170767d..a2d37f077ad 100644 --- a/src/infra/outbound/target-normalization.ts +++ b/src/infra/outbound/target-normalization.ts @@ -9,6 +9,9 @@ import type { ChannelDirectoryEntryKind, ChannelId } from "../../channels/plugin import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js"; +/** + * Normalizes raw user/channel target input before provider-specific parsing. + */ export function normalizeChannelTargetInput(raw: string): string { return raw.trim(); } @@ -39,6 +42,7 @@ function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer { if (cached && cached.version === version) { return cached.normalizer; } + // Plugin channel metadata is process-stable between registry version bumps. const plugin = resolveChannelPluginForTargetRead(channelId); const normalizer = plugin?.messaging?.normalizeTarget; targetNormalizerCacheByChannelId.set(channelId, { @@ -48,6 +52,9 @@ function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer { return normalizer; } +/** + * Applies a channel plugin normalizer and falls back to trimmed input. + */ export function normalizeTargetForProvider(provider: string, raw?: string): string | undefined { if (!raw) { return undefined; @@ -61,8 +68,14 @@ export function normalizeTargetForProvider(provider: string, raw?: string): stri return normalizeOptionalString(normalizer?.(raw) ?? fallback); } +/** + * Directory target kinds accepted by plugin-backed target resolution. + */ export type TargetResolveKindLike = ChannelDirectoryEntryKind | "channel"; +/** + * Resolved outbound target returned by a channel plugin target resolver. + */ export type ResolvedPluginMessagingTarget = { to: string; kind: TargetResolveKindLike; @@ -71,6 +84,9 @@ export type ResolvedPluginMessagingTarget = { resolutionSource: "plugin"; }; +/** + * Produces raw and provider-normalized forms of a nonblank target input. + */ export function resolveNormalizedTargetInput( provider: string, raw?: string, @@ -85,6 +101,9 @@ export function resolveNormalizedTargetInput( }; } +/** + * Detects whether input is specific enough to invoke plugin target resolution. + */ export function looksLikeTargetId(params: { channel: ChannelId; raw: string; @@ -112,6 +131,9 @@ export function looksLikeTargetId(params: { return /^(conversation|user):/i.test(params.raw); } +/** + * Resolves a normalized target through the channel plugin when a resolver is available. + */ export async function maybeResolvePluginMessagingTarget(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -157,11 +179,15 @@ export async function maybeResolvePluginMessagingTarget(params: { }; } +/** + * Builds a cache signature for target-resolution behavior exposed by a channel plugin. + */ export function buildTargetResolverSignature(channel: ChannelId): string { const plugin = resolveChannelPluginForTargetRead(channel); const resolver = plugin?.messaging?.targetResolver; const hint = resolver?.hint ?? ""; const looksLike = resolver?.looksLikeId; + // Function source is only a cheap invalidation hint; resolver behavior still belongs to the plugin. const source = looksLike ? looksLike.toString() : ""; return hashSignature(`${hint}|${source}`); } diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 16e41ded97f..7b5caf20d24 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -19,10 +19,13 @@ import { resolveNormalizedTargetInput, } from "./target-normalization.js"; +/** Directory-backed destination kind used by outbound target resolution. */ export type TargetResolveKind = ChannelDirectoryEntryKind | "channel"; +/** Strategy for resolving multiple matching directory entries. */ export type ResolveAmbiguousMode = "error" | "best" | "first"; +/** Canonical outbound target produced by plugin, directory, or normalized fallback resolution. */ export type ResolvedMessagingTarget = { to: string; kind: TargetResolveKind; @@ -31,6 +34,7 @@ export type ResolvedMessagingTarget = { resolutionSource: "plugin" | "directory" | "normalized"; }; +/** Result of resolving a user-supplied outbound target. */ export type ResolveMessagingTargetResult = | { ok: true; target: ResolvedMessagingTarget } | { ok: false; error: Error; candidates?: ChannelDirectoryEntry[] }; @@ -43,6 +47,7 @@ function asResolvedMessagingTarget( export { maybeResolveIdLikeTarget } from "./target-id-resolution.js"; +/** Resolves a channel target using the shared outbound target resolver. */ export async function resolveChannelTarget(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -59,6 +64,7 @@ export async function resolveChannelTarget(params: { const CACHE_TTL_MS = 30 * 60 * 1000; const directoryCache = new DirectoryCache(CACHE_TTL_MS); +/** Clears cached directory entries for all channels or one channel/account scope. */ export function resetDirectoryCache(params?: { channel?: ChannelId; accountId?: string | null }) { if (!params?.channel) { directoryCache.clear(); @@ -88,6 +94,7 @@ function stripTargetPrefixes(value: string): string { .trim(); } +/** Formats a resolved target for user-facing summaries. */ export function formatTargetDisplay(params: { channel: ChannelId; target: string; @@ -339,6 +346,7 @@ function pickAmbiguousMatch( return best ?? entries[0] ?? null; } +/** Resolves a user target through id-like, directory, plugin, and normalized fallback paths. */ export async function resolveMessagingTarget(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -461,6 +469,7 @@ export async function resolveMessagingTarget(params: { }; } +/** Looks up a display label for a resolved target id from cached/live directory entries. */ export async function lookupDirectoryDisplay(params: { cfg: OpenClawConfig; channel: ChannelId; diff --git a/src/infra/outbound/targets-loaded.ts b/src/infra/outbound/targets-loaded.ts index 72a656b9907..f16d500d807 100644 --- a/src/infra/outbound/targets-loaded.ts +++ b/src/infra/outbound/targets-loaded.ts @@ -18,6 +18,7 @@ function resolveLoadedOutboundChannelPlugin(channel: string): ChannelPlugin | un return getLoadedChannelPluginForRead(normalized); } +/** Resolves targets through an already-loaded channel plugin without bootstrap discovery. */ export function tryResolveLoadedOutboundTarget(params: { channel: GatewayMessageChannel; to?: string; diff --git a/src/infra/outbound/targets-resolve-shared.ts b/src/infra/outbound/targets-resolve-shared.ts index c58e43f360b..c3c861e0eb7 100644 --- a/src/infra/outbound/targets-resolve-shared.ts +++ b/src/infra/outbound/targets-resolve-shared.ts @@ -8,8 +8,14 @@ import type { GatewayMessageChannel } from "../../utils/message-channel.js"; import { validateTargetProviderPrefix } from "./channel-target-prefix.js"; import { missingTargetError } from "./target-errors.js"; +/** + * Result of resolving a concrete outbound target for a channel send. + */ export type OutboundTargetResolution = { ok: true; to: string } | { ok: false; error: Error }; +/** + * Inputs shared by direct and heartbeat outbound target resolution. + */ export type ResolveOutboundTargetParams = { channel: GatewayMessageChannel; to?: string; @@ -25,6 +31,9 @@ function buildWebChatDeliveryError(): Error { ); } +/** + * Resolves a target through a channel plugin or the generic fallback path. + */ export function resolveOutboundTargetWithPlugin(params: { plugin: ChannelPlugin | undefined; target: ResolveOutboundTargetParams; @@ -42,6 +51,7 @@ export function resolveOutboundTargetWithPlugin(params: { return params.onMissingPlugin?.(); } + // Plugin defaults and allowlists can be account-scoped; resolve them before target validation. const allowFromRaw = params.target.allowFrom ?? (params.target.cfg && plugin.config.resolveAllowFrom diff --git a/src/infra/outbound/targets-session.ts b/src/infra/outbound/targets-session.ts index d30b4c8a7c7..cd098ad78fb 100644 --- a/src/infra/outbound/targets-session.ts +++ b/src/infra/outbound/targets-session.ts @@ -18,6 +18,9 @@ import type { } from "../../utils/message-channel-normalize.js"; import { resolveTargetPrefixedChannel } from "./channel-target-prefix.js"; +/** + * Resolved delivery destination derived from session history, turn source, or explicit input. + */ export type SessionDeliveryTarget = { channel?: DeliverableMessageChannel; to?: string; @@ -56,6 +59,9 @@ function resolveParsedRouteTarget(params: { }; } +/** + * Resolves the effective outbound target for a session-scoped delivery request. + */ export function resolveSessionDeliveryTarget(params: { entry?: SessionEntry; requestedChannel?: GatewayMessageChannel; @@ -109,6 +115,8 @@ export function resolveSessionDeliveryTarget(params: { left: parsedTurnSourceTarget, right: parsedSessionTarget, })); + // Shared sessions can receive cross-channel updates mid-turn; only inherit session threads + // when the turn source still identifies the same conversation. const lastThreadId = hasTurnSourceThreadId ? parsedTurnSourceTarget?.threadId : hasTurnSourceChannel && diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index a7ffb24dc55..5bc4c08ad9c 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -30,10 +30,13 @@ import { type OutboundTargetResolution, } from "./targets-resolve-shared.js"; +/** Deliverable channel id accepted by outbound target resolution. */ export type OutboundChannel = DeliverableMessageChannel; +/** Heartbeat target channel id from agent/default heartbeat config. */ export type HeartbeatTarget = OutboundChannel; +/** Resolved outbound delivery destination and routing hints. */ export type OutboundTarget = { channel: OutboundChannel; to?: string; @@ -45,6 +48,7 @@ export type OutboundTarget = { lastAccountId?: string; }; +/** Sender identity context used when a heartbeat needs channel-compatible metadata. */ export type HeartbeatSenderContext = { sender: string; provider?: DeliverableMessageChannel; @@ -55,7 +59,7 @@ export type { OutboundTargetResolution } from "./targets-resolve-shared.js"; export { resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets-session.js"; import { resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets-session.js"; -// Channel docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations. +/** Resolves a user-supplied outbound destination through the channel plugin. */ export function resolveOutboundTarget(params: { channel: GatewayMessageChannel; to?: string; @@ -87,6 +91,7 @@ export function resolveOutboundTarget(params: { ); } +/** Resolves the heartbeat delivery destination from config, session state, and turn source. */ export function resolveHeartbeatDeliveryTarget(params: { cfg: OpenClawConfig; entry?: SessionEntry; @@ -266,6 +271,7 @@ function buildNoHeartbeatDeliveryTarget(params: { }; } +/** Resolves heartbeat delivery and lets plugins refine the outbound session route. */ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: { cfg: OpenClawConfig; agentId: string; @@ -298,6 +304,7 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: { unknownTargetMode: "normalized", }); } catch { + // Target normalization failure should not suppress an otherwise deliverable heartbeat. return null; } })(); @@ -436,6 +443,7 @@ function resolveHeartbeatSenderId(params: { return candidates[0] ?? "heartbeat"; } +/** Resolves the sender id/allow-list context used for heartbeat sends. */ export function resolveHeartbeatSenderContext(params: { cfg: OpenClawConfig; entry?: SessionEntry; diff --git a/src/infra/outbound/thread-id.ts b/src/infra/outbound/thread-id.ts index b04237a7853..e307956ccde 100644 --- a/src/infra/outbound/thread-id.ts +++ b/src/infra/outbound/thread-id.ts @@ -1,5 +1,6 @@ import { normalizeOptionalStringifiedId } from "@openclaw/normalization-core/string-coerce"; +/** Normalizes channel thread/topic ids before outbound payload construction. */ export function normalizeOutboundThreadId(value?: string | number | null): string | undefined { return normalizeOptionalStringifiedId(value); } diff --git a/src/plugin-sdk/access-groups.ts b/src/plugin-sdk/access-groups.ts index e2023b22234..084a772d941 100644 --- a/src/plugin-sdk/access-groups.ts +++ b/src/plugin-sdk/access-groups.ts @@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; export { ACCESS_GROUP_ALLOW_FROM_PREFIX, parseAccessGroupAllowFromEntry }; +/** Resolves membership for an access group using the full OpenClaw config. */ export type AccessGroupMembershipResolver = (params: { cfg: OpenClawConfig; name: string; @@ -18,6 +19,7 @@ export type AccessGroupMembershipResolver = (params: { senderId: string; }) => boolean | Promise; +/** Resolves membership for one access group when the caller already selected the config group. */ export type AccessGroupMembershipLookup = (params: { name: string; group: AccessGroupConfig; @@ -26,6 +28,7 @@ export type AccessGroupMembershipLookup = (params: { senderId: string; }) => boolean | Promise; +/** Reports how access-group allowlist entries resolved for a channel sender. */ export type ResolvedAccessGroupAllowFromState = { referenced: string[]; matched: string[]; @@ -47,6 +50,7 @@ function resolveMessageSenderGroupEntries(params: { return [...(params.group.members["*"] ?? []), ...(params.group.members[params.channel] ?? [])]; } +/** Resolves `accessGroup:` allowlist entries without changing the original allowlist. */ export async function resolveAccessGroupAllowFromState(params: { accessGroups?: Record; allowFrom: Array | null | undefined; @@ -124,6 +128,7 @@ export async function resolveAccessGroupAllowFromState(params: { return state; } +/** Returns the matched `accessGroup:` allowlist entries for a sender. */ export async function resolveAccessGroupAllowFromMatches(params: { cfg?: OpenClawConfig; allowFrom: Array | null | undefined; @@ -154,6 +159,7 @@ export async function resolveAccessGroupAllowFromMatches(params: { return state.matchedAllowFromEntries; } +/** Expands a matching access-group allowlist with the concrete sender entry. */ export async function expandAllowFromWithAccessGroups(params: { cfg?: OpenClawConfig; allowFrom: Array | null | undefined; @@ -178,5 +184,6 @@ export async function expandAllowFromWithAccessGroups(params: { return allowFrom; } const senderEntry = params.senderAllowEntry ?? params.senderId; + // Downstream legacy sender checks still expect a concrete allowlist entry after a group match. return uniqueStrings([...allowFrom, senderEntry]); } diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 4473706cb1e..84e62f0326b 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -74,15 +74,18 @@ type DefineBundledChannelSetupEntryOptions = { features?: BundledChannelSetupEntryFeatures; }; +/** Feature flags exposed by bundled setup entries for optional migration/session surfaces. */ export type BundledChannelSetupEntryFeatures = { legacyStateMigrations?: boolean; legacySessionSurfaces?: boolean; }; +/** Feature flags exposed by full bundled channel entries. */ export type BundledChannelEntryFeatures = { accountInspect?: boolean; }; +/** Legacy session helpers used while bundled channels migrate old session key formats. */ export type BundledChannelLegacySessionSurface = { isLegacyGroupSessionKey?: (key: string) => boolean; canonicalizeLegacySessionKey?: (params: { @@ -91,6 +94,7 @@ export type BundledChannelLegacySessionSurface = { }) => string | null | undefined; }; +/** Detects channel-owned state migrations needed before a bundled channel starts. */ export type BundledChannelLegacyStateMigrationDetector = (params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -102,6 +106,7 @@ export type BundledChannelLegacyStateMigrationDetector = (params: { | null | undefined; +/** Runtime contract returned by a bundled channel's main entrypoint definition. */ export type BundledChannelEntryContract = { kind: "bundled-channel-entry"; id: string; @@ -123,6 +128,7 @@ export type BundledChannelEntryContract = { setChannelRuntime?: (runtime: PluginRuntime) => void; }; +/** Runtime contract returned by a bundled channel's setup-only entrypoint definition. */ export type BundledChannelSetupEntryContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: (options?: BundledEntryModuleLoadOptions) => TPlugin; @@ -140,6 +146,7 @@ export type BundledChannelSetupEntryContract = { features?: BundledChannelSetupEntryFeatures; }; +/** Test hook for swapping the source-module loader used by bundled entry imports. */ export type BundledEntryModuleLoadOptions = { createLoaderForTest?: PluginModuleLoaderFactory; }; @@ -261,6 +268,8 @@ function resolveBundledEntryModuleCandidates( return candidates; } + // Published bundles resolve from dist first, then fall back to source so local dev checkouts + // can exercise bundled entries without requiring a fresh package build. addBundledEntryCandidates( candidates, path.resolve(sourcePluginRoot, specifier), @@ -457,6 +466,7 @@ function loadBundledEntryModuleSync( } // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Dynamic entry export loaders use caller-supplied export types. +/** Loads one export from a bundled channel sidecar module through the guarded entry boundary. */ export function loadBundledEntryExportSync( importMetaUrl: string, reference: BundledEntryModuleRef, @@ -479,6 +489,7 @@ export function loadBundledEntryExportSync( return record[reference.exportName] as T; } +/** Defines the full bundled channel entry contract used by core plugin registration. */ export function defineBundledChannelEntry({ id, name, @@ -577,6 +588,7 @@ export function defineBundledChannelEntry({ }; } +/** Defines the setup-only bundled channel entry contract for onboarding and migration surfaces. */ export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts index 8c3c9f2c444..5f3d1ed1163 100644 --- a/src/plugin-sdk/channel-inbound.ts +++ b/src/plugin-sdk/channel-inbound.ts @@ -1,4 +1,3 @@ -// Shared inbound parsing helpers for channel plugins. import { buildChannelInboundEventContext, finalizeChannelInboundContext, @@ -101,7 +100,11 @@ export type { FinalizeChannelInboundContextParams, FinalizeChannelInboundContextResult, }; -/** @deprecated Use `BuildChannelInboundEventContextParams`. */ +/** + * Deprecated turn-context input alias that still accepts the old `inboundTurnKind` name. + * + * @deprecated Use `BuildChannelInboundEventContextParams`. + */ export type BuildChannelTurnContextParams = Omit< BuildChannelInboundEventContextParams, "message" @@ -110,12 +113,20 @@ export type BuildChannelTurnContextParams = Omit< inboundTurnKind?: InboundEventKind; }; }; -/** @deprecated Use `BuiltChannelInboundEventContext`. */ +/** + * Deprecated turn-context result alias with the historical `InboundTurnKind` field. + * + * @deprecated Use `BuiltChannelInboundEventContext`. + */ export type BuiltChannelTurnContext = BuiltChannelInboundEventContext & { InboundTurnKind: InboundEventKind; }; -/** @deprecated Use `buildChannelInboundEventContext`. */ +/** + * Builds inbound-event context for callers still passing `inboundTurnKind`. + * + * @deprecated Use `buildChannelInboundEventContext`. + */ export function buildChannelTurnContext( params: BuildChannelTurnContextParams, ): BuiltChannelTurnContext { @@ -133,7 +144,11 @@ export function buildChannelTurnContext( }; } -/** @deprecated Use `filterChannelInboundSupplementalContext`. */ +/** + * Deprecated supplemental-context filter alias retained for channel SDK compatibility. + * + * @deprecated Use `filterChannelInboundSupplementalContext`. + */ export const filterChannelTurnSupplementalContext = filterChannelInboundSupplementalContext; export { runChannelInboundEvent, diff --git a/src/plugin-sdk/channel-outbound.ts b/src/plugin-sdk/channel-outbound.ts index 2f955f6c6ca..c051514acc5 100644 --- a/src/plugin-sdk/channel-outbound.ts +++ b/src/plugin-sdk/channel-outbound.ts @@ -1,4 +1,3 @@ -// Shared outbound/message lifecycle helpers for channel plugins. import type { DurableMessageBatchSendResult, DurableMessageSendContext, @@ -10,6 +9,8 @@ type ChannelMessageRuntimeModule = typeof import("../channels/message/runtime.js let channelMessageRuntimeModulePromise: Promise | null = null; const loadChannelMessageRuntimeModule = async () => { + // Share one lazy import across SDK helper calls so plugin barrels do not eagerly pull + // message runtime internals into registration/discovery-only paths. channelMessageRuntimeModulePromise ??= import("../channels/message/runtime.js"); return await channelMessageRuntimeModulePromise; }; @@ -196,12 +197,14 @@ export type { RenderedMessageBatchPlanKind, } from "../channels/message/index.js"; +/** Lazily forwards inbound reply delivery through the channel turn kernel. */ export const deliverInboundReplyWithMessageSendContext: ChannelInboundKernelModule["deliverInboundReplyWithMessageSendContext"] = async (...args) => { const mod = await import("../channels/turn/kernel.js"); return await mod.deliverInboundReplyWithMessageSendContext(...args); }; +/** Sends a durable message batch without eager-loading channel message runtime internals. */ export async function sendDurableMessageBatch( params: DurableMessageSendContextParams, ): Promise { @@ -209,6 +212,7 @@ export async function sendDurableMessageBatch( return await mod.sendDurableMessageBatch(params); } +/** Runs work inside a durable message send context loaded through the SDK lazy boundary. */ export async function withDurableMessageSendContext( params: DurableMessageSendContextParams, run: (ctx: DurableMessageSendContext) => Promise, diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index e3471aa085d..1fa13ff4431 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -62,6 +62,7 @@ export { } from "./group-access.js"; export { createAllowlistProviderRestrictSendersWarningCollector }; +/** Normalizes allowFrom entries into trimmed unique string identifiers. */ export function normalizeAllowFromList(list: Array | undefined | null): string[] { if (!Array.isArray(list)) { return []; @@ -69,6 +70,7 @@ export function normalizeAllowFromList(list: Array | undefined return normalizeStringEntries(list); } +/** Coerces native feature settings to the supported boolean/auto shape. */ export function coerceNativeSetting(value: unknown): boolean | "auto" | undefined { if (value === true || value === false || value === "auto") { return value; @@ -76,6 +78,7 @@ export function coerceNativeSetting(value: unknown): boolean | "auto" | undefine return undefined; } +/** Candidate mutable allowlist path inspected for dangerous name-matching warnings. */ export type ChannelMutableAllowlistCandidate = { pathLabel: string; list: unknown; @@ -97,6 +100,7 @@ function collectMutableAllowlistWarningLines( const exampleLines = hits .slice(0, 8) .map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`); + // Keep doctor output actionable without dumping large allowlists into logs. const remaining = hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null; const flagPaths = uniqueStrings(hits.map((hit) => hit.dangerousFlagPath)); @@ -113,6 +117,7 @@ function collectMutableAllowlistWarningLines( ]; } +/** Creates a warning collector for mutable name/email/nick allowlists when matching is disabled. */ export function createDangerousNameMatchingMutableAllowlistWarningCollector(params: { channel: string; detector: (entry: string) => boolean; diff --git a/src/plugin-sdk/channel-route.ts b/src/plugin-sdk/channel-route.ts index 2662e2ec456..c94f45b7a02 100644 --- a/src/plugin-sdk/channel-route.ts +++ b/src/plugin-sdk/channel-route.ts @@ -5,12 +5,16 @@ import { } from "../../packages/normalization-core/src/string-coerce.js"; import { normalizeOptionalAccountId } from "../routing/account-id.js"; +/** Coarse chat shape used when a channel can distinguish direct, group, and broadcast targets. */ export type ChannelRouteChatType = "direct" | "group" | "channel"; +/** Provider-specific thread kind carried with normalized channel routes. */ export type ChannelRouteThreadKind = "topic" | "thread" | "reply"; +/** Describes which runtime surface supplied a channel route thread id. */ export type ChannelRouteThreadSource = "explicit" | "target" | "session" | "turn"; +/** Normalized channel route used for comparison, binding, and dedupe helpers. */ export type ChannelRouteRef = { channel?: string; accountId?: string; @@ -26,6 +30,7 @@ export type ChannelRouteRef = { }; }; +/** Loose route input accepted at SDK boundaries before normalization. */ export type ChannelRouteRefInput = { channel?: unknown; accountId?: unknown; @@ -37,11 +42,13 @@ export type ChannelRouteRefInput = { threadSource?: ChannelRouteThreadSource; }; +/** Raw outbound target input shape used by helpers that do not need thread metadata source. */ export type ChannelRouteTargetInput = Pick< ChannelRouteRefInput, "channel" | "accountId" | "to" | "rawTo" | "chatType" | "threadId" >; +/** Route input accepted by compact-key helpers after legacy and normalized callers converge. */ export type ChannelRouteKeyInput = ChannelRouteRef | ChannelRouteTargetInput; /** @deprecated Use `messaging.resolveOutboundSessionRoute` for provider-specific target grammar. */ @@ -57,15 +64,18 @@ export type ChannelRouteExplicitTargetParser = ( rawTarget: string, ) => ChannelRouteExplicitTarget | null; +/** Normalizes a route thread id while preserving provider string ids. */ export function normalizeRouteThreadId(value: unknown): string | number | undefined { return normalizeOptionalThreadValue(value); } +/** Stringifies a normalized thread id for stable route keys and comparisons. */ export function stringifyRouteThreadId(value: unknown): string | undefined { const normalized = normalizeRouteThreadId(value); return normalized == null ? undefined : String(normalized); } +/** Converts loose channel/account/target/thread input into a normalized route reference. */ export function normalizeChannelRouteRef( input?: ChannelRouteRefInput, ): ChannelRouteRef | undefined { @@ -105,20 +115,24 @@ export function normalizeChannelRouteRef( }; } +/** Returns the normalized destination id for a route reference. */ export function channelRouteTarget(route?: ChannelRouteRef): string | undefined { return route?.target?.to; } +/** Returns the normalized thread id for a route reference. */ export function channelRouteThreadId(route?: ChannelRouteRef): string | number | undefined { return route?.thread?.id; } +/** Normalizes raw target-only route input. */ export function normalizeChannelRouteTarget( input?: ChannelRouteTargetInput | null, ): ChannelRouteRef | undefined { return input ? normalizeChannelRouteRef(input) : undefined; } +/** Parsed target shape retained for deprecated explicit-target parser adapters. */ export type ChannelRouteParsedTarget = ChannelRouteTargetInput & { channel: string; rawTo: string; @@ -150,6 +164,7 @@ export function resolveChannelRouteTargetWithParser(params: { }; } +/** Builds a JSON route dedupe key that remains unambiguous when route parts contain separators. */ export function channelRouteDedupeKey(input?: ChannelRouteTargetInput | null): string { const route = normalizeChannelRouteTarget(input); return JSON.stringify([ @@ -187,6 +202,7 @@ export function channelRoutesMatchExact(params: { if (!left || !right) { return false; } + // Exact route equality treats a missing account differently from an explicit account. return ( left.channel === right.channel && left.target?.to === right.target?.to && @@ -195,6 +211,7 @@ export function channelRoutesMatchExact(params: { ); } +/** Checks whether two normalized routes point at the same conversation or parent route. */ export function channelRoutesShareConversation(params: { left?: ChannelRouteRef | null; right?: ChannelRouteRef | null; @@ -211,11 +228,13 @@ export function channelRoutesShareConversation(params: { return false; } if (left.thread?.id == null || right.thread?.id == null) { + // Parent route matches any child thread once channel, target, and compatible account match. return true; } return threadIdsEqual(left.thread.id, right.thread.id); } +/** Exact route comparison for loose target input. */ export function channelRouteTargetsMatchExact(params: { left?: ChannelRouteTargetInput | null; right?: ChannelRouteTargetInput | null; @@ -226,6 +245,7 @@ export function channelRouteTargetsMatchExact(params: { }); } +/** Conversation-level route comparison for loose target input. */ export function channelRouteTargetsShareConversation(params: { left?: ChannelRouteTargetInput | null; right?: ChannelRouteTargetInput | null; @@ -256,6 +276,7 @@ function normalizeChannelRouteKeyInput( : normalizeChannelRouteTarget(route); } +/** Builds a compact human-readable route key when channel and target are both present. */ export function channelRouteCompactKey(route?: ChannelRouteKeyInput | null): string | undefined { const normalized = normalizeChannelRouteKeyInput(route); if (!normalized?.channel || !normalized.target?.to) { diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index 2046ad85b5f..46d71d87ad3 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -4,12 +4,15 @@ import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; export type { ChannelOutboundAdapter } from "../channels/plugins/outbound.types.js"; export type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; + +/** Legacy raw send result shape accepted from channel SDK adapters. */ export type ChannelSendRawResult = { ok: boolean; messageId?: string | null; error?: string | null; }; +/** Attaches the channel id to a single outbound send result. */ export function attachChannelToResult(channel: string, result: T) { return { channel, @@ -17,16 +20,19 @@ export function attachChannelToResult(channel: string, result: }; } +/** Attaches the channel id to each outbound send result in order. */ export function attachChannelToResults(channel: string, results: readonly T[]) { return results.map((result) => attachChannelToResult(channel, result)); } +/** Creates an empty outbound delivery result for send paths that produced no platform id. */ export function createEmptyChannelResult( channel: string, result: Partial> & { messageId?: string; } = {}, ): OutboundDeliveryResult { + // Empty message ids are the legacy "no platform id" sentinel expected by outbound callers. return attachChannelToResult(channel, { messageId: "", ...result, @@ -38,6 +44,7 @@ type SendTextParams = Parameters type SendMediaParams = Parameters>[0]; type SendPollParams = Parameters>[0]; +/** Wraps outbound send methods that already return delivery-shaped results without channel ids. */ export function createAttachedChannelResultAdapter(params: { channel: string; sendText?: (ctx: SendTextParams) => MaybePromise>; @@ -57,6 +64,7 @@ export function createAttachedChannelResultAdapter(params: { }; } +/** Wraps legacy raw text/media send methods and normalizes their results. */ export function createRawChannelSendResultAdapter(params: { channel: string; sendText?: (ctx: SendTextParams) => MaybePromise; diff --git a/src/plugin-sdk/pair-loop-guard-runtime.ts b/src/plugin-sdk/pair-loop-guard-runtime.ts index bd4f7f28781..641c19cf95b 100644 --- a/src/plugin-sdk/pair-loop-guard-runtime.ts +++ b/src/plugin-sdk/pair-loop-guard-runtime.ts @@ -1,3 +1,4 @@ +/** Resolved pair-loop guard settings in milliseconds for runtime checks. */ export type PairLoopGuardSettings = { enabled: boolean; maxEventsPerWindow: number; @@ -5,6 +6,7 @@ export type PairLoopGuardSettings = { cooldownMs: number; }; +/** User-facing pair-loop guard config accepted by channel plugins. */ export type PairLoopGuardConfig = { enabled?: boolean; maxEventsPerWindow?: number; @@ -19,10 +21,12 @@ const PAIR_LOOP_GUARD_CONFIG_KEYS = [ "cooldownSeconds", ] as const satisfies ReadonlyArray; +/** Result of recording one pair interaction against the loop guard. */ export type PairLoopGuardResult = | { suppressed: false } | { suppressed: true; cooldownUntilMs: number }; +/** Snapshot entry for observability and tests. */ export type PairLoopGuardSnapshotEntry = { key: string; recentCount: number; @@ -36,6 +40,7 @@ type PairLoopGuardEntry = { cooldownUntilMs: number; }; +/** In-memory guard for suppressing repeated bidirectional bot pair loops. */ export type PairLoopGuard = { recordAndCheck: (params: { scopeId: string; @@ -52,6 +57,7 @@ export type PairLoopGuard = { const DEFAULT_PRUNE_INTERVAL_MS = 60_000; const KEY_SEPARATOR = "\u0001"; +/** Default plugin-facing loop guard config before per-channel overrides. */ export const DEFAULT_PAIR_LOOP_GUARD_CONFIG: Required = { enabled: true, maxEventsPerWindow: 20, @@ -59,6 +65,7 @@ export const DEFAULT_PAIR_LOOP_GUARD_CONFIG: Required = { cooldownSeconds: 60, }; +/** Default runtime loop guard settings derived from the default config. */ export const DEFAULT_PAIR_LOOP_GUARD_SETTINGS: PairLoopGuardSettings = { enabled: DEFAULT_PAIR_LOOP_GUARD_CONFIG.enabled, maxEventsPerWindow: DEFAULT_PAIR_LOOP_GUARD_CONFIG.maxEventsPerWindow, @@ -66,6 +73,7 @@ export const DEFAULT_PAIR_LOOP_GUARD_SETTINGS: PairLoopGuardSettings = { cooldownMs: DEFAULT_PAIR_LOOP_GUARD_CONFIG.cooldownSeconds * 1000, }; +/** Merges pair-loop configs from broad defaults to narrow overrides, ignoring undefined values. */ export function mergePairLoopGuardConfig( ...configs: Array ): PairLoopGuardConfig | undefined { @@ -104,6 +112,7 @@ function positiveInteger(value: unknown): number | undefined { : undefined; } +/** Resolves runtime loop guard settings from config/defaults and the channel default-enabled gate. */ export function resolvePairLoopGuardSettings(params: { config?: PairLoopGuardConfig; defaultsConfig?: PairLoopGuardConfig; @@ -142,6 +151,7 @@ function buildPairKey(params: { senderId: string; receiverId: string; }): string { + // Sort sender/receiver so A->B and B->A count as the same bot loop pair. const lhs = params.senderId < params.receiverId ? params.senderId : params.receiverId; const rhs = params.senderId < params.receiverId ? params.receiverId : params.senderId; return [params.scopeId, params.conversationId, lhs, rhs].join(KEY_SEPARATOR); @@ -156,6 +166,7 @@ function countCurrentWindowEvents(entry: PairLoopGuardEntry, nowMs: number): num return entry.recentMs.filter((timestampMs) => timestampMs <= nowMs).length; } +/** Creates an in-memory pair-loop guard with bounded periodic pruning. */ export function createPairLoopGuard(params?: { pruneIntervalMs?: number }): PairLoopGuard { const tracked = new Map(); const pruneIntervalMs = params?.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS; @@ -223,6 +234,7 @@ export function createPairLoopGuard(params?: { pruneIntervalMs?: number }): Pair if (countCurrentWindowEvents(entry, nowMs) > maxEventsPerWindow) { entry.cooldownStartedAtMs = nowMs; entry.cooldownUntilMs = nowMs + cooldownMs; + // Keep only future records during cooldown; past events should not extend suppression. entry.recentMs = entry.recentMs.filter((timestampMs) => timestampMs > nowMs); return { suppressed: true, cooldownUntilMs: entry.cooldownUntilMs }; } diff --git a/src/shared/agent-run-status.ts b/src/shared/agent-run-status.ts index d43a4646b41..3aaf0ead331 100644 --- a/src/shared/agent-run-status.ts +++ b/src/shared/agent-run-status.ts @@ -1,5 +1,6 @@ const NON_TERMINAL_AGENT_RUN_STATUSES = new Set(["accepted", "started", "in_flight"]); +/** Returns true for agent-run statuses that still need polling or live updates. */ export function isNonTerminalAgentRunStatus(status: unknown): boolean { return typeof status === "string" && NON_TERMINAL_AGENT_RUN_STATUSES.has(status); } diff --git a/src/shared/assistant-identity-values.ts b/src/shared/assistant-identity-values.ts index 436307c1883..5dba17c26e2 100644 --- a/src/shared/assistant-identity-values.ts +++ b/src/shared/assistant-identity-values.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Normalizes optional assistant identity fields and truncates them to the caller's limit. */ export function coerceIdentityValue( value: string | undefined, maxLength: number, diff --git a/src/shared/avatar-policy.ts b/src/shared/avatar-policy.ts index e4c9bbb1b13..a0e34428372 100644 --- a/src/shared/avatar-policy.ts +++ b/src/shared/avatar-policy.ts @@ -26,31 +26,38 @@ export const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/; const AVATAR_PATH_EXT_RE = /\.(png|jpe?g|gif|webp|svg|ico)$/i; +/** Resolves a local avatar file MIME type from its extension. */ export function resolveAvatarMime(filePath: string): string { const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return AVATAR_MIME_BY_EXT[ext] ?? "application/octet-stream"; } +/** Detects any data URL value before image-specific validation. */ export function isAvatarDataUrl(value: string): boolean { return AVATAR_DATA_RE.test(value); } +/** Detects image data URLs accepted by avatar sources. */ export function isAvatarImageDataUrl(value: string): boolean { return AVATAR_IMAGE_DATA_RE.test(value); } +/** Detects remote HTTP(S) avatar URLs. */ export function isAvatarHttpUrl(value: string): boolean { return AVATAR_HTTP_RE.test(value); } +/** Detects URI-scheme-like avatar values, including non-HTTP schemes. */ export function hasAvatarUriScheme(value: string): boolean { return AVATAR_SCHEME_RE.test(value); } +/** Detects Windows absolute paths so they are not mistaken for URI schemes. */ export function isWindowsAbsolutePath(value: string): boolean { return WINDOWS_ABS_RE.test(value); } +/** Accepts workspace-relative avatar paths while rejecting home paths and URI values. */ export function isWorkspaceRelativeAvatarPath(value: string): boolean { if (!value) { return false; @@ -64,10 +71,12 @@ export function isWorkspaceRelativeAvatarPath(value: string): boolean { return true; } +/** Checks that a resolved avatar path remains inside its configured root. */ export function isPathWithinRoot(rootDir: string, targetPath: string): boolean { return isPathInside(rootDir, targetPath); } +/** Heuristically detects strings that look like local avatar file paths. */ export function looksLikeAvatarPath(value: string): boolean { if (/[\\/]/.test(value)) { return true; @@ -75,6 +84,7 @@ export function looksLikeAvatarPath(value: string): boolean { return AVATAR_PATH_EXT_RE.test(value); } +/** Restricts local avatar files to image extensions that can be safely served inline. */ export function isSupportedLocalAvatarExtension(filePath: string): boolean { const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return LOCAL_AVATAR_EXTENSIONS.has(ext); diff --git a/src/shared/chat-content.ts b/src/shared/chat-content.ts index ec67a393323..c9ebc83021e 100644 --- a/src/shared/chat-content.ts +++ b/src/shared/chat-content.ts @@ -1,3 +1,4 @@ +/** Coerces arbitrary provider content values into displayable text without throwing. */ export function coerceChatContentText(value: unknown): string { if (typeof value === "string") { return value; @@ -23,6 +24,7 @@ export function coerceChatContentText(value: unknown): string { return ""; } +/** Extracts normalized plain text from string content or OpenAI-style text blocks. */ export function extractTextFromChatContent( content: unknown, opts?: { @@ -56,6 +58,7 @@ export function extractTextFromChatContent( if (!block || typeof block !== "object") { continue; } + // Non-text blocks can contain media or tool payloads; callers here need visible text only. if ((block as { type?: unknown }).type !== "text") { continue; } diff --git a/src/shared/chat-envelope.ts b/src/shared/chat-envelope.ts index 697d146c0d3..1ead5fb4789 100644 --- a/src/shared/chat-envelope.ts +++ b/src/shared/chat-envelope.ts @@ -26,18 +26,21 @@ function looksLikeEnvelopeHeader(header: string): boolean { return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `)); } +/** Removes recognized channel/timestamp prefixes while preserving user-authored bracket text. */ export function stripEnvelope(text: string): string { const match = text.match(ENVELOPE_PREFIX); if (!match) { return text; } const header = match[1] ?? ""; + // Only strip known generated envelopes; arbitrary `[name]` text may be part of the message. if (!looksLikeEnvelopeHeader(header)) { return text; } return text.slice(match[0].length); } +/** Removes standalone message-id hint lines without touching inline user mentions. */ export function stripMessageIdHints(text: string): string { if (!/\[message_id:/i.test(text)) { return text; diff --git a/src/shared/chat-message-content.ts b/src/shared/chat-message-content.ts index e487e485002..0124e4f928c 100644 --- a/src/shared/chat-message-content.ts +++ b/src/shared/chat-message-content.ts @@ -1,5 +1,6 @@ import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +/** Returns inline string content or the first array text block without scanning later blocks. */ export function extractFirstTextBlock(message: unknown): string | undefined { if (!message || typeof message !== "object") { return undefined; @@ -21,10 +22,12 @@ export function extractFirstTextBlock(message: unknown): string | undefined { export type AssistantPhase = "commentary" | "final_answer"; +/** Narrows unknown phase metadata to assistant text phases that affect visibility. */ export function normalizeAssistantPhase(value: unknown): AssistantPhase | undefined { return value === "commentary" || value === "final_answer" ? value : undefined; } +/** Parses assistant text block signatures, preserving legacy raw ids when not JSON encoded. */ export function parseAssistantTextSignature( value: unknown, ): { id?: string; phase?: AssistantPhase } | null { @@ -50,6 +53,7 @@ export function parseAssistantTextSignature( } } +/** Encodes versioned assistant text metadata stored alongside streamed text blocks. */ export function encodeAssistantTextSignature(params: { id: string; phase?: AssistantPhase; @@ -61,6 +65,7 @@ export function encodeAssistantTextSignature(params: { }); } +/** Resolves a message phase only when the top-level phase or all explicit blocks agree. */ export function resolveAssistantMessagePhase(message: unknown): AssistantPhase | undefined { if (!message || typeof message !== "object") { return undefined; @@ -90,6 +95,7 @@ export function resolveAssistantMessagePhase(message: unknown): AssistantPhase | return explicitPhases.size === 1 ? [...explicitPhases][0] : undefined; } +/** Finds assistant phase metadata on event payloads that may wrap message-like records. */ export function resolveAssistantEventPhase(data: unknown): AssistantPhase | undefined { if (!data || typeof data !== "object") { return undefined; @@ -109,6 +115,7 @@ export function resolveAssistantEventPhase(data: unknown): AssistantPhase | unde ); } +/** Extracts assistant text for a requested phase without mixing legacy and explicitly phased text. */ export function extractAssistantTextForPhase( message: unknown, options?: { @@ -166,8 +173,7 @@ export function extractAssistantTextForPhase( return Boolean(parseAssistantTextSignature(record.textSignature)?.phase); }); - // Once explicit phased blocks exist, unphased extraction should not revive - // legacy text from the same message. + // Once explicit phased blocks exist, unphased extraction should not revive legacy text. if (!phase && hasExplicitPhasedTextBlocks) { return undefined; } @@ -198,6 +204,7 @@ export function extractAssistantTextForPhase( return normalizeJoinedText(parts.join(joinWith)); } +/** Returns user-visible assistant text, preferring final answers over legacy unphased text. */ export function extractAssistantVisibleText(message: unknown): string | undefined { const finalAnswerText = extractAssistantTextForPhase(message, { phase: "final_answer" }); if (finalAnswerText) { diff --git a/src/shared/config-eval.ts b/src/shared/config-eval.ts index 5d1fb10807b..afcc10347d2 100644 --- a/src/shared/config-eval.ts +++ b/src/shared/config-eval.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; +/** Normalizes primitive config values into the truthiness rules used by requirements checks. */ export function isTruthy(value: unknown): boolean { if (value === undefined || value === null) { return false; @@ -17,6 +18,7 @@ export function isTruthy(value: unknown): boolean { return true; } +/** Resolves dotted config paths, tolerating extra dots and missing branches. */ export function resolveConfigPath(config: unknown, pathStr: string): unknown { const parts = pathStr.split(".").filter(Boolean); let current: unknown = config; @@ -29,6 +31,7 @@ export function resolveConfigPath(config: unknown, pathStr: string): unknown { return current; } +/** Checks a config path with fallback defaults only when the path is unresolved. */ export function isConfigPathTruthyWithDefaults( config: unknown, pathStr: string, @@ -57,6 +60,7 @@ type RuntimeRequirementEvalParams = { isConfigPathTruthy: (pathStr: string) => boolean; }; +/** Evaluates binary/env/config requirements against local and optional remote capabilities. */ export function evaluateRuntimeRequires(params: RuntimeRequirementEvalParams): boolean { const requires = params.requires; if (!requires) { @@ -105,6 +109,7 @@ export function evaluateRuntimeRequires(params: RuntimeRequirementEvalParams): b return true; } +/** Evaluates OS gating and runtime requirements for skill/plugin entry eligibility. */ export function evaluateRuntimeEligibility( params: { os?: string[]; @@ -134,6 +139,7 @@ export function evaluateRuntimeEligibility( }); } +/** Returns the current Node runtime platform used by eligibility checks. */ export function resolveRuntimePlatform(): string { return process.platform; } @@ -149,10 +155,13 @@ let cachedHasBinaryPath: string | undefined; let cachedHasBinaryPathExt: string | undefined; const hasBinaryCache = new Map(); +/** Checks PATH for an executable binary, including PATHEXT candidates on Windows. */ export function hasBinary(bin: string): boolean { const pathEnv = process.env.PATH ?? ""; const pathExt = process.platform === "win32" ? (process.env.PATHEXT ?? "") : ""; if (cachedHasBinaryPath !== pathEnv || cachedHasBinaryPathExt !== pathExt) { + // PATH/PATHEXT changes invalidate all cached binary probes; keeping stale misses + // would make newly installed tools invisible until process restart. cachedHasBinaryPath = pathEnv; cachedHasBinaryPathExt = pathExt; hasBinaryCache.clear(); diff --git a/src/shared/config-ui-hints-types.ts b/src/shared/config-ui-hints-types.ts index 3994842d987..2e4bb75298c 100644 --- a/src/shared/config-ui-hints-types.ts +++ b/src/shared/config-ui-hints-types.ts @@ -1,3 +1,4 @@ +/** UI metadata attached to config schema paths for forms, docs, and redaction policy. */ export type ConfigUiHint = { label?: string; help?: string; @@ -10,4 +11,5 @@ export type ConfigUiHint = { itemTemplate?: unknown; }; +/** Config UI hints keyed by dotted config path, with `*` matching dynamic segments. */ export type ConfigUiHints = Record; diff --git a/src/shared/device-auth-store.ts b/src/shared/device-auth-store.ts index 28fcf2b2406..0c7d5a5971d 100644 --- a/src/shared/device-auth-store.ts +++ b/src/shared/device-auth-store.ts @@ -46,6 +46,7 @@ function copyCanonicalDeviceAuthTokens( return out; } +/** Coerces raw persisted device-auth JSON into the current canonical store shape. */ export function coerceDeviceAuthStore(value: unknown): DeviceAuthStore | null { if (!isRecord(value) || value.version !== 1 || typeof value.deviceId !== "string") { return null; @@ -88,6 +89,8 @@ export function storeDeviceAuthTokenInStore(params: { version: 1, deviceId: params.deviceId, tokens: + // Device-auth stores are scoped to one gateway device id; never merge stale + // tokens copied from another gateway identity. existing && existing.deviceId === params.deviceId && existing.tokens ? copyCanonicalDeviceAuthTokens(existing.tokens) : {}, diff --git a/src/shared/device-pairing-access.ts b/src/shared/device-pairing-access.ts index 30be1448f60..fb1cc5de8a6 100644 --- a/src/shared/device-pairing-access.ts +++ b/src/shared/device-pairing-access.ts @@ -1,10 +1,13 @@ import { normalizeDeviceAuthScopes } from "./device-auth.js"; export type DevicePairingAccessSummary = { + /** Normalized role ids requested or approved for a device. */ roles: string[]; + /** Normalized scope ids, including implied operator scopes. */ scopes: string[]; }; +/** Approval classification shown when a pending pairing differs from existing grants. */ export type PendingDeviceApprovalKind = | "new-pairing" | "role-upgrade" @@ -13,7 +16,9 @@ export type PendingDeviceApprovalKind = export type PendingDeviceApprovalState = { kind: PendingDeviceApprovalKind; + /** Access requested by the pending pairing attempt. */ requested: DevicePairingAccessSummary; + /** Existing active access, or null for a new pairing. */ approved: DevicePairingAccessSummary | null; }; @@ -69,6 +74,7 @@ function includesAll(allowed: readonly string[], requested: readonly string[]): return requested.every((value) => allowedSet.has(value)); } +/** Normalizes requested roles/scopes from pending pairing records, including legacy singular role. */ export function summarizePendingDeviceAccess(request: PendingLike): DevicePairingAccessSummary { return { roles: normalizeRoleList(request.roles, request.role), @@ -76,6 +82,7 @@ export function summarizePendingDeviceAccess(request: PendingLike): DevicePairin }; } +/** Summarizes currently approved device access, excluding roles whose tokens are revoked. */ export function summarizeApprovedDeviceAccess(device: PairedLike): DevicePairingAccessSummary { const approvedRoles = normalizeRoleList(device.roles, device.role); const tokenList = Array.isArray(device.tokens) @@ -95,6 +102,7 @@ export function summarizeApprovedDeviceAccess(device: PairedLike): DevicePairing }; } +/** Classifies a pending pairing request as new pairing, role upgrade, scope upgrade, or re-approval. */ export function resolvePendingDeviceApprovalState( request: PendingLike, paired?: PairedLike, diff --git a/src/shared/entry-metadata.ts b/src/shared/entry-metadata.ts index 8664e847142..5e8cbb18258 100644 --- a/src/shared/entry-metadata.ts +++ b/src/shared/entry-metadata.ts @@ -1,5 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +/** Resolves entry emoji/homepage with metadata taking precedence over frontmatter aliases. */ export function resolveEmojiAndHomepage(params: { metadata?: { emoji?: string; homepage?: string } | null; frontmatter?: { diff --git a/src/shared/entry-status.ts b/src/shared/entry-status.ts index 0141e0815a9..9a583ee7c1f 100644 --- a/src/shared/entry-status.ts +++ b/src/shared/entry-status.ts @@ -11,6 +11,7 @@ export type EntryMetadataRequirementsParams = Parameters< typeof evaluateEntryMetadataRequirements >[0]; +/** Resolves entry presentation metadata and requirement eligibility in one shared shape. */ export function evaluateEntryMetadataRequirements(params: { always: boolean; metadata?: (RequirementsMetadata & { emoji?: string; homepage?: string }) | null; @@ -56,6 +57,7 @@ export function evaluateEntryMetadataRequirements(params: { }; } +/** Evaluates entry metadata requirements against the current Node platform. */ export function evaluateEntryMetadataRequirementsForCurrentPlatform( params: Omit, ): ReturnType { @@ -65,6 +67,7 @@ export function evaluateEntryMetadataRequirementsForCurrentPlatform( }); } +/** Evaluates an entry object's metadata/frontmatter requirements on the current platform. */ export function evaluateEntryRequirementsForCurrentPlatform(params: { always: boolean; entry: { diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts index 64a1e8be45a..8ad5ecdbf10 100644 --- a/src/shared/frontmatter.ts +++ b/src/shared/frontmatter.ts @@ -7,10 +7,12 @@ import JSON5 from "json5"; import { LEGACY_MANIFEST_KEYS, MANIFEST_KEY } from "../compat/legacy-names.js"; import { parseBooleanValue } from "../utils/boolean.js"; +/** Normalizes comma-delimited or loose array metadata fields into string lists. */ export function normalizeStringList(input: unknown): string[] { return normalizeCsvOrLooseStringList(input); } +/** Reads a frontmatter field only when it is represented as a string value. */ export function getFrontmatterString( frontmatter: Record, key: string, @@ -18,11 +20,13 @@ export function getFrontmatterString( return readStringValue(frontmatter[key]); } +/** Parses boolean frontmatter strings while preserving the caller's default for missing values. */ export function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean { const parsed = parseBooleanValue(value); return parsed === undefined ? fallback : parsed; } +/** Parses the JSON5 OpenClaw manifest block embedded inside a string frontmatter field. */ export function resolveOpenClawManifestBlock(params: { frontmatter: Record; key?: string; @@ -39,6 +43,7 @@ export function resolveOpenClawManifestBlock(params: { } const manifestKeys = [MANIFEST_KEY, ...LEGACY_MANIFEST_KEYS]; + // Prefer the current manifest key, but still read legacy names for existing skill/hook files. for (const key of manifestKeys) { const candidate = (parsed as Record)[key]; if (candidate && typeof candidate === "object") { @@ -58,6 +63,7 @@ export type OpenClawManifestRequires = { config: string[]; }; +/** Extracts normalized runtime requirement lists from an OpenClaw manifest block. */ export function resolveOpenClawManifestRequires( metadataObj: Record, ): OpenClawManifestRequires | undefined { @@ -76,6 +82,7 @@ export function resolveOpenClawManifestRequires( }; } +/** Parses manifest install entries with a caller-owned parser and drops unsupported specs. */ export function resolveOpenClawManifestInstall( metadataObj: Record, parseInstallSpec: (input: unknown) => T | undefined, @@ -86,6 +93,7 @@ export function resolveOpenClawManifestInstall( .filter((entry): entry is T => Boolean(entry)); } +/** Extracts normalized OS allowlist entries from an OpenClaw manifest block. */ export function resolveOpenClawManifestOs(metadataObj: Record): string[] { return normalizeStringList(metadataObj.os); } @@ -98,6 +106,7 @@ export type ParsedOpenClawManifestInstallBase = { bins?: string[]; }; +/** Parses kind/type plus common install fields shared by package-manager install specs. */ export function parseOpenClawManifestInstallBase( input: unknown, allowedKinds: readonly string[], @@ -130,6 +139,7 @@ export function parseOpenClawManifestInstallBase( return spec; } +/** Copies optional common install fields onto a caller-specific install spec object. */ export function applyOpenClawManifestInstallCommonFields< T extends { id?: string; label?: string; bins?: string[] }, >(spec: T, parsed: Pick): T { diff --git a/src/shared/gateway-bind-url.ts b/src/shared/gateway-bind-url.ts index 2b2e63b74ce..62f32f286ab 100644 --- a/src/shared/gateway-bind-url.ts +++ b/src/shared/gateway-bind-url.ts @@ -10,6 +10,7 @@ export type GatewayBindUrlResult = } | null; +/** Resolves the externally advertised gateway URL for non-loopback bind modes. */ export function resolveGatewayBindUrl(params: { bind?: string; customBindHost?: string; @@ -43,5 +44,6 @@ export function resolveGatewayBindUrl(params: { return { error: "gateway.bind=lan set, but no private LAN IP was found." }; } + // Loopback/default and unknown bind values do not need an advertised non-local URL. return null; } diff --git a/src/shared/gateway-tailscale-auth-policy.ts b/src/shared/gateway-tailscale-auth-policy.ts index 69b1cc7054e..e1f222f53a3 100644 --- a/src/shared/gateway-tailscale-auth-policy.ts +++ b/src/shared/gateway-tailscale-auth-policy.ts @@ -1,5 +1,6 @@ import type { GatewayAuthMode, GatewayTailscaleMode } from "../config/types.gateway.js"; +/** True when Tailscale exposure is configured without gateway authentication. */ export function isUnsafeGatewayTailscaleNoAuth(params: { authMode?: GatewayAuthMode; tailscaleMode?: GatewayTailscaleMode; @@ -10,6 +11,7 @@ export function isUnsafeGatewayTailscaleNoAuth(params: { ); } +/** Formats the shared validation message for unsafe Tailscale no-auth exposure. */ export function formatUnsafeGatewayTailscaleNoAuthMessage( tailscaleMode: GatewayTailscaleMode, ): string { diff --git a/src/shared/global-singleton.ts b/src/shared/global-singleton.ts index a472b01a5e5..c7c49e062c3 100644 --- a/src/shared/global-singleton.ts +++ b/src/shared/global-singleton.ts @@ -1,6 +1,4 @@ -// Safe for process-local caches and registries that can tolerate helper-based -// resolution. Do not use this for live mutable state that must survive split -// runtime chunks; keep those on a direct globalThis[Symbol.for(...)] lookup. +/** Resolves a process-local singleton for caches and registries that tolerate helper lookup. */ export function resolveGlobalSingleton(key: symbol, create: () => T): T { const globalStore = globalThis as Record; if (Object.hasOwn(globalStore, key)) { @@ -11,6 +9,7 @@ export function resolveGlobalSingleton(key: symbol, create: () => T): T { return created; } +/** Resolves a process-local Map singleton for keyed caches backed by globalThis. */ export function resolveGlobalMap(key: symbol): Map { return resolveGlobalSingleton(key, () => new Map()); } diff --git a/src/shared/human-list.ts b/src/shared/human-list.ts index 8f1e8d4ddcd..aeedf61a8f0 100644 --- a/src/shared/human-list.ts +++ b/src/shared/human-list.ts @@ -1,3 +1,4 @@ +/** Formats a short human-readable disjunction such as "A, B, or C". */ export function formatHumanList(values: readonly string[]): string { if (values.length === 0) { return ""; diff --git a/src/shared/lazy-promise.ts b/src/shared/lazy-promise.ts index 4015e8cf17f..6dc4fd4770d 100644 --- a/src/shared/lazy-promise.ts +++ b/src/shared/lazy-promise.ts @@ -7,6 +7,7 @@ export type LazyPromiseLoaderOptions = { cacheRejections?: boolean; }; +/** Creates a small promise cache that dedupes concurrent loads and can be cleared manually. */ export function createLazyPromiseLoader( load: () => T | Promise, options: LazyPromiseLoaderOptions = {}, @@ -17,6 +18,8 @@ export function createLazyPromiseLoader( const loaded = Promise.resolve().then(load); if (options.cacheRejections !== true) { void loaded.catch(() => { + // Failed lazy loads are usually transient import/runtime issues; evict the exact + // rejected promise so the next caller can retry without racing a newer load. if (promise === loaded) { promise = undefined; } @@ -36,6 +39,7 @@ export function createLazyPromiseLoader( }; } +/** Convenience wrapper for dynamic-import-shaped loaders. */ export function createLazyImportLoader( load: () => Promise, options?: LazyPromiseLoaderOptions, diff --git a/src/shared/listeners.ts b/src/shared/listeners.ts index 5430ce8f980..e20f83335ae 100644 --- a/src/shared/listeners.ts +++ b/src/shared/listeners.ts @@ -1,3 +1,4 @@ +/** Notifies every registered listener while isolating individual listener failures. */ export function notifyListeners( listeners: Iterable<(event: T) => void>, event: T, @@ -12,6 +13,7 @@ export function notifyListeners( } } +/** Registers a listener in a Set and returns an idempotent unsubscribe handle. */ export function registerListener( listeners: Set<(event: T) => void>, listener: (event: T) => void, diff --git a/src/shared/model-param-b.ts b/src/shared/model-param-b.ts index ce66ce5b9d7..cdb425c5529 100644 --- a/src/shared/model-param-b.ts +++ b/src/shared/model-param-b.ts @@ -1,5 +1,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +/** Infers the largest `b` parameter-size token from a model id or display name. */ export function inferParamBFromIdOrName(text: string): number | null { const raw = normalizeLowercaseStringOrEmpty(text); const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g); diff --git a/src/shared/node-list-parse.ts b/src/shared/node-list-parse.ts index 17d52c82d0d..f748de8aa79 100644 --- a/src/shared/node-list-parse.ts +++ b/src/shared/node-list-parse.ts @@ -1,6 +1,7 @@ import { asRecord } from "@openclaw/normalization-core/record-coerce"; import type { NodeListNode, PairedNode, PairingList, PendingRequest } from "./node-list-types.js"; +/** Extracts pending and paired node arrays from permissive node.pair.list payloads. */ export function parsePairingList(value: unknown): PairingList { const obj = asRecord(value); const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : []; @@ -8,6 +9,7 @@ export function parsePairingList(value: unknown): PairingList { return { pending, paired }; } +/** Extracts the nodes array from a node.list response, treating malformed payloads as empty. */ export function parseNodeList(value: unknown): NodeListNode[] { const obj = asRecord(value); return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : []; diff --git a/src/shared/node-match.ts b/src/shared/node-match.ts index 73e7a1f06be..54bb8443260 100644 --- a/src/shared/node-match.ts +++ b/src/shared/node-match.ts @@ -18,6 +18,7 @@ type ScoredNodeMatch = { selectionScore: number; }; +/** Normalizes human node names into stable lookup keys for fuzzy CLI/API matching. */ export function normalizeNodeKey(value: string) { return normalizeLowercaseStringOrEmpty(value) .replace(/[^a-z0-9]+/g, "-") @@ -63,6 +64,8 @@ function pickPreferredLegacyMigrationMatch( if (legacyCount === 0 || current.length + legacyCount !== matches.length) { return undefined; } + // During Clawdbot -> OpenClaw migration, a unique current client should win only + // when every other tie is a known legacy client for the same human-facing node. return current[0]; } @@ -121,6 +124,7 @@ function resolveScoredMatches(nodes: NodeMatchCandidate[], query: string): Score .filter((entry): entry is ScoredNodeMatch => entry !== null); } +/** Returns candidates matching a node id, remote ip, normalized display name, or long id prefix. */ export function resolveNodeMatches( nodes: NodeMatchCandidate[], query: string, @@ -128,6 +132,7 @@ export function resolveNodeMatches( return resolveScoredMatches(nodes, query).map((entry) => entry.node); } +/** Resolves a single node id or throws an operator-readable unknown/ambiguous-node error. */ export function resolveNodeIdFromCandidates(nodes: NodeMatchCandidate[], query: string): string { const q = query.trim(); if (!q) { diff --git a/src/shared/node-resolve.ts b/src/shared/node-resolve.ts index 0b9a922c539..fd609d9d90d 100644 --- a/src/shared/node-resolve.ts +++ b/src/shared/node-resolve.ts @@ -6,6 +6,7 @@ type ResolveNodeFromListOptions = { pickDefaultNode?: (nodes: TNode[]) => TNode | null; }; +/** Resolves a user query to a node id, optionally using a caller-defined blank-query default. */ export function resolveNodeIdFromNodeList( nodes: TNode[], query?: string, @@ -24,11 +25,13 @@ export function resolveNodeIdFromNodeList( return resolveNodeIdFromCandidates(nodes, q); } +/** Resolves a full node entry, preserving synthetic defaults returned by the picker. */ export function resolveNodeFromNodeList( nodes: TNode[], query?: string, options: ResolveNodeFromListOptions = {}, ): TNode { const nodeId = resolveNodeIdFromNodeList(nodes, query, options); + // Default pickers may return a node not present in the original list; keep that id usable. return nodes.find((node) => node.nodeId === nodeId) ?? ({ nodeId } as TNode); } diff --git a/src/shared/operator-scope-compat.ts b/src/shared/operator-scope-compat.ts index 9987b16ac55..6ed805dd8f9 100644 --- a/src/shared/operator-scope-compat.ts +++ b/src/shared/operator-scope-compat.ts @@ -31,6 +31,7 @@ function operatorScopeSatisfied(requestedScope: string, granted: Set): b return granted.has(requestedScope); } +/** Returns true when a role grant satisfies requested scopes, including operator implications. */ export function roleScopesAllow(params: { role: string; requestedScopes: readonly string[]; @@ -52,6 +53,7 @@ export function roleScopesAllow(params: { return requested.every((scope) => operatorScopeSatisfied(scope, allowedSet)); } +/** Returns the first requested scope not covered by the role's allowed scopes. */ export function resolveMissingRequestedScope(params: { role: string; requestedScopes: readonly string[]; @@ -71,6 +73,7 @@ export function resolveMissingRequestedScope(params: { return null; } +/** Returns the first requested scope that does not belong to any requested role. */ export function resolveScopeOutsideRequestedRoles(params: { requestedRoles: readonly string[]; requestedScopes: readonly string[]; diff --git a/src/shared/path-array-index.ts b/src/shared/path-array-index.ts index ee54e769ee8..26e94582e22 100644 --- a/src/shared/path-array-index.ts +++ b/src/shared/path-array-index.ts @@ -1,7 +1,9 @@ +/** Upper bound for config path array indexes to reject impractical sparse writes. */ export const MAX_CONFIG_PATH_ARRAY_INDEX = 100_000; const CANONICAL_ARRAY_INDEX_SEGMENT = /^(0|[1-9]\d*)$/; +/** Parses a canonical non-negative array index segment used by config and JSON paths. */ export function parseConfigPathArrayIndex(segment: string): number | undefined { if (!CANONICAL_ARRAY_INDEX_SEGMENT.test(segment)) { return undefined; diff --git a/src/shared/pid-alive.ts b/src/shared/pid-alive.ts index 826ba2eebeb..456256f9acb 100644 --- a/src/shared/pid-alive.ts +++ b/src/shared/pid-alive.ts @@ -21,6 +21,7 @@ function isZombieProcess(pid: number): boolean { } } +/** Returns true only when a positive PID exists and is not a Linux zombie process. */ export function isPidAlive(pid: number): boolean { if (!isValidPid(pid)) { return false; @@ -36,6 +37,7 @@ export function isPidAlive(pid: number): boolean { return true; } +/** Returns true only when the PID is invalid, missing, or known to be a Linux zombie. */ export function isPidDefinitelyDead(pid: number): boolean { if (!isValidPid(pid)) { return true; diff --git a/src/shared/requirements.ts b/src/shared/requirements.ts index 58c011d7caa..14c75e44e71 100644 --- a/src/shared/requirements.ts +++ b/src/shared/requirements.ts @@ -36,6 +36,7 @@ type RequirementsEvaluationRemoteContext = { remotePlatforms?: string[]; }; +/** Returns required binaries absent from both the local host and optional remote target. */ export function resolveMissingBins(params: { required: string[]; hasLocalBin: (bin: string) => boolean; @@ -53,6 +54,7 @@ export function resolveMissingBins(params: { }); } +/** Treats an any-bin requirement as satisfied when any listed binary exists locally or remotely. */ export function resolveMissingAnyBins(params: { required: string[]; hasLocalBin: (bin: string) => boolean; @@ -70,6 +72,7 @@ export function resolveMissingAnyBins(params: { return params.required; } +/** Resolves OS requirements against local and remote platforms, accepting macos as darwin. */ export function resolveMissingOs(params: { required: string[]; localPlatform: string; @@ -100,6 +103,7 @@ function normalizeOsRequirementPlatform(platform: string): string { return normalized === "macos" ? "darwin" : normalized; } +/** Returns environment variable names whose caller-provided satisfaction check fails. */ export function resolveMissingEnv(params: { required: string[]; isSatisfied: (envName: string) => boolean; @@ -114,6 +118,7 @@ export function resolveMissingEnv(params: { return missing; } +/** Builds per-config-path status while preserving every declared path for UI diagnostics. */ export function buildConfigChecks(params: { required: string[]; isSatisfied: (pathStr: string) => boolean; @@ -124,6 +129,7 @@ export function buildConfigChecks(params: { }); } +/** Evaluates normalized requirements and returns missing categories plus config diagnostics. */ export function evaluateRequirements( params: RequirementsEvaluationContext & RequirementsEvaluationRemoteContext & { @@ -155,6 +161,7 @@ export function evaluateRequirements( }); const missingConfig = configChecks.filter((check) => !check.satisfied).map((check) => check.path); + // `always` keeps diagnostics visible while making runtime eligibility unconditional. const missing = params.always ? { bins: [], anyBins: [], env: [], config: [], os: [] } : { @@ -176,6 +183,7 @@ export function evaluateRequirements( return { missing, eligible, configChecks }; } +/** Converts entry metadata into the canonical requirement shape before evaluation. */ export function evaluateRequirementsFromMetadata( params: RequirementsEvaluationContext & RequirementsEvaluationRemoteContext & { @@ -209,6 +217,7 @@ export function evaluateRequirementsFromMetadata( return { required, ...result }; } +/** Convenience wrapper for callers that receive remote capability checks as one object. */ export function evaluateRequirementsFromMetadataWithRemote( params: RequirementsEvaluationContext & { metadata?: RequirementsMetadata; diff --git a/src/shared/runtime-import.ts b/src/shared/runtime-import.ts index 5c41f257d9a..2141b69d65e 100644 --- a/src/shared/runtime-import.ts +++ b/src/shared/runtime-import.ts @@ -2,6 +2,7 @@ import { toSafeImportPath } from "./import-specifier.js"; export { toSafeImportPath as toSafeRuntimeImportPath } from "./import-specifier.js"; +/** Resolves a runtime import part against a base URL/path after platform-safe normalization. */ export function resolveRuntimeImportSpecifier(baseUrl: string, parts: readonly string[]): string { const joined = parts.join(""); const safeJoined = toSafeImportPath(joined); @@ -11,6 +12,7 @@ export function resolveRuntimeImportSpecifier(baseUrl: string, parts: readonly s return new URL(joined, toSafeImportPath(baseUrl)).href; } +/** Imports a lazy runtime module through the normalized runtime specifier. */ export async function importRuntimeModule( baseUrl: string, parts: readonly string[], diff --git a/src/shared/scoped-expiring-id-cache.ts b/src/shared/scoped-expiring-id-cache.ts index 66d41f847ca..51290e46eb2 100644 --- a/src/shared/scoped-expiring-id-cache.ts +++ b/src/shared/scoped-expiring-id-cache.ts @@ -8,6 +8,7 @@ function resolveNonNegativeInteger(value: number, fallback: number): number { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback; } +/** Creates a scoped TTL cache for ids that should expire independently per scope. */ export function createScopedExpiringIdCache< TScope extends string | number, TId extends string | number, @@ -21,6 +22,7 @@ export function createScopedExpiringIdCache< function cleanupExpired(scopeKey: string, entry: Map, now: number): void { for (const [id, timestamp] of entry) { + // Equality stays live so callers can treat ttlMs as an inclusive age limit. if (now - timestamp > ttlMs) { entry.delete(id); } @@ -41,6 +43,7 @@ export function createScopedExpiringIdCache< } entry.set(idKey, now); if (entry.size > cleanupThreshold) { + // Avoid per-record scans until a scope grows past the caller's expected steady state. cleanupExpired(scopeKey, entry, now); } }, diff --git a/src/shared/silent-reply-policy.ts b/src/shared/silent-reply-policy.ts index 8654bd07e25..b773e256aae 100644 --- a/src/shared/silent-reply-policy.ts +++ b/src/shared/silent-reply-policy.ts @@ -12,6 +12,7 @@ export const DEFAULT_SILENT_REPLY_POLICY: Record Promise; resolve: (value: unknown) => void; reject: (reason: unknown) => void; }; +/** Per-store-path FIFO queue that serializes file writes within one process. */ export type StoreWriterQueue = { running: boolean; pending: StoreWriterTask[]; drainPromise: Promise | null; }; +/** Store writer queues keyed by the canonical store path. */ export type StoreWriterQueues = Map; function getOrCreateStoreWriterQueue( @@ -63,6 +66,8 @@ async function drainStoreWriterQueue(queues: StoreWriterQueues, storePath: strin if (queue.pending.length === 0) { queues.delete(storePath); } else { + // Late enqueues after the loop drained run in a fresh microtask so this + // drainPromise can settle before the next writer batch starts. queueMicrotask(() => { void drainStoreWriterQueue(queues, storePath); }); @@ -72,6 +77,7 @@ async function drainStoreWriterQueue(queues: StoreWriterQueues, storePath: strin await queue.drainPromise; } +/** Runs one store write after prior writes for the same store path have finished. */ export async function runQueuedStoreWrite(params: { queues: StoreWriterQueues; storePath: string; @@ -97,6 +103,7 @@ export async function runQueuedStoreWrite(params: { }); } +/** Rejects pending queued writes and clears queue state for test cleanup. */ export function clearStoreWriterQueuesForTest(queues: StoreWriterQueues, message: string): void { for (const queue of queues.values()) { for (const task of queue.pending) { @@ -106,6 +113,7 @@ export function clearStoreWriterQueuesForTest(queues: StoreWriterQueues, message queues.clear(); } +/** Waits for active drains to settle while rejecting still-pending test writes. */ export async function drainStoreWriterQueuesForTest( queues: StoreWriterQueues, message: string, diff --git a/src/shared/string-sample.ts b/src/shared/string-sample.ts index c91b64d14f9..f9691ace82d 100644 --- a/src/shared/string-sample.ts +++ b/src/shared/string-sample.ts @@ -1,3 +1,4 @@ +/** Formats a bounded comma-separated sample of string entries with a hidden-count suffix. */ export function summarizeStringEntries(params: { entries?: ReadonlyArray | null; limit?: number; diff --git a/src/shared/subagents-format.ts b/src/shared/subagents-format.ts index 0fda6c9da47..f681b81fa62 100644 --- a/src/shared/subagents-format.ts +++ b/src/shared/subagents-format.ts @@ -1,5 +1,6 @@ export { formatDurationCompact } from "../infra/format-time/format-duration.ts"; +/** Formats token counts using compact k/m suffixes for subagent summaries. */ export function formatTokenShort(value?: number) { if (!value || !Number.isFinite(value) || value <= 0) { return undefined; @@ -22,6 +23,7 @@ export function formatTokenShort(value?: number) { return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; } +/** Truncates a single-line display string without preserving trailing whitespace. */ export function truncateLine(value: string, maxLength: number) { if (value.length <= maxLength) { return value; @@ -35,6 +37,7 @@ export type TokenUsageLike = { outputTokens?: unknown; }; +/** Resolves total token usage, falling back to input+output when no explicit total exists. */ export function resolveTotalTokens(entry?: TokenUsageLike) { if (!entry || typeof entry !== "object") { return undefined; @@ -48,6 +51,7 @@ export function resolveTotalTokens(entry?: TokenUsageLike) { return total > 0 ? total : undefined; } +/** Resolves finite input/output token usage and the derived total. */ export function resolveIoTokens(entry?: TokenUsageLike) { if (!entry || typeof entry !== "object") { return undefined; @@ -67,6 +71,7 @@ export function resolveIoTokens(entry?: TokenUsageLike) { return { input, output, total }; } +/** Formats token usage for compact subagent list/detail displays. */ export function formatTokenUsageDisplay(entry?: TokenUsageLike) { const io = resolveIoTokens(entry); const promptCache = resolveTotalTokens(entry); diff --git a/src/shared/tailscale-status.ts b/src/shared/tailscale-status.ts index 6243d3bcd86..a3c55cd5935 100644 --- a/src/shared/tailscale-status.ts +++ b/src/shared/tailscale-status.ts @@ -44,6 +44,7 @@ function extractTailnetHostFromStatusJson(raw: string): string | null { return ips.length > 0 ? (ips[0] ?? null) : null; } +/** Resolves the host published to clients for tailnet or Tailscale Serve gateway modes. */ export function resolveTailscalePublishedHost(params: { tailscaleMode: string; tailnetHost: string | null; @@ -58,6 +59,7 @@ export function resolveTailscalePublishedHost(params: { if (!serviceName) { return tailnetHost; } + // Tailscale Serve service names compose with DNS hosts, not raw tailnet IP addresses. if (/^[\d.:]+$/.test(tailnetHost)) { return null; } @@ -66,6 +68,7 @@ export function resolveTailscalePublishedHost(params: { return tailnetSuffix ? `${bareServiceName}.${tailnetSuffix}` : null; } +/** Runs known Tailscale status commands and returns the first DNS name or tailnet IP found. */ export async function resolveTailnetHostWithRunner( runCommandWithTimeout?: TailscaleStatusCommandRunner, ): Promise { diff --git a/src/shared/text-chunking.ts b/src/shared/text-chunking.ts index 9b75368fcd4..6809f19f569 100644 --- a/src/shared/text-chunking.ts +++ b/src/shared/text-chunking.ts @@ -1,3 +1,4 @@ +/** Splits text into bounded chunks using caller-owned soft-break selection. */ export function chunkTextByBreakResolver( text: string, limit: number, @@ -14,6 +15,7 @@ export function chunkTextByBreakResolver( while (remaining.length > limit) { const window = remaining.slice(0, limit); const candidateBreak = resolveBreakIndex(window); + // Invalid or zero-width soft breaks would stall the loop, so fall back to the hard limit. const breakIdx = Number.isFinite(candidateBreak) && candidateBreak > 0 && candidateBreak <= limit ? candidateBreak @@ -23,6 +25,7 @@ export function chunkTextByBreakResolver( if (chunk.length > 0) { chunks.push(chunk); } + // If the break lands before whitespace, consume one separator and trim the rest for the next chunk. const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); remaining = remaining.slice(nextStart).trimStart(); diff --git a/src/shared/text/citation-control-markers.ts b/src/shared/text/citation-control-markers.ts index 39dfa0e99fc..c0622de0140 100644 --- a/src/shared/text/citation-control-markers.ts +++ b/src/shared/text/citation-control-markers.ts @@ -1,6 +1,7 @@ const UNSUPPORTED_CITATION_CONTROL_MARKER_RE = /cite(?:[^]*)?/g; const TRAILING_UNSUPPORTED_CITATION_CONTROL_MARKER_RE = /[ \t]*cite(?:[^]*)?(?=\r?\n|$)/g; +/** Removes unsupported model citation-control markers without disturbing normal hard breaks. */ export function stripUnsupportedCitationControlMarkers(text: string): string { return text .replace(TRAILING_UNSUPPORTED_CITATION_CONTROL_MARKER_RE, "") diff --git a/src/shared/text/code-regions.ts b/src/shared/text/code-regions.ts index 2109c6f7894..4576088306b 100644 --- a/src/shared/text/code-regions.ts +++ b/src/shared/text/code-regions.ts @@ -3,6 +3,7 @@ export interface CodeRegion { end: number; } +/** Finds fenced and inline Markdown code regions so text sanitizers can avoid examples. */ export function findCodeRegions(text: string): CodeRegion[] { const regions: CodeRegion[] = []; @@ -26,6 +27,7 @@ export function findCodeRegions(text: string): CodeRegion[] { return regions; } +/** Returns true when a character offset falls inside one of the discovered code regions. */ export function isInsideCode(pos: number, regions: CodeRegion[]): boolean { return regions.some((r) => pos >= r.start && pos < r.end); } diff --git a/src/shared/text/final-tags.ts b/src/shared/text/final-tags.ts index 5689fbbb95a..5b42e1865d0 100644 --- a/src/shared/text/final-tags.ts +++ b/src/shared/text/final-tags.ts @@ -76,6 +76,7 @@ function parseAttributeList(text: string): boolean { return true; } +/** Parses a candidate `` tag while rejecting lookalike names and malformed attributes. */ export function parseFinalTag(text: string): Omit | null { if (!text.startsWith("<") || !text.endsWith(">")) { return null; @@ -110,6 +111,7 @@ export function parseFinalTag(text: string): Omit` control tags so callers can strip only actual model markers. */ export function findFinalTagMatches(text: string): FinalTagMatch[] { const matches: FinalTagMatch[] = []; for (const match of text.matchAll(FINAL_TAG_CANDIDATE_RE)) { @@ -127,10 +129,12 @@ export function findFinalTagMatches(text: string): FinalTagMatch[] { return matches; } +/** Returns true when text contains at least one valid `` control tag. */ export function containsFinalTag(text: string): boolean { return findFinalTagMatches(text).length > 0; } +/** Removes valid `` tags while preserving their enclosed visible answer text. */ export function stripFinalTags(text: string): string { let output = ""; let lastIndex = 0; diff --git a/src/shared/text/join-segments.ts b/src/shared/text/join-segments.ts index e6215d7caf3..02a2b7b53a4 100644 --- a/src/shared/text/join-segments.ts +++ b/src/shared/text/join-segments.ts @@ -1,3 +1,4 @@ +/** Concatenates two optional text blocks, preserving the right block's explicit empty string. */ export function concatOptionalTextSegments(params: { left?: string; right?: string; @@ -10,6 +11,7 @@ export function concatOptionalTextSegments(params: { return params.right ?? params.left; } +/** Joins non-empty string segments, optionally trimming each segment before presence checks. */ export function joinPresentTextSegments( segments: ReadonlyArray, options?: { diff --git a/src/shared/text/model-special-tokens.ts b/src/shared/text/model-special-tokens.ts index 338b7c55d72..710fe0a1a70 100644 --- a/src/shared/text/model-special-tokens.ts +++ b/src/shared/text/model-special-tokens.ts @@ -1,18 +1,3 @@ -/** - * Strip model control tokens leaked into assistant text output. - * - * Models like GLM-5 and DeepSeek sometimes emit internal delimiter tokens - * (e.g. `<|assistant|>`, `<|tool_call_result_begin|>`, `<|begin▁of▁sentence|>`) - * in their responses. These use the universal `<|...|>` convention (ASCII or - * full-width pipe variants) and should never reach end users. - * - * Matches inside fenced code blocks or inline code spans are preserved so - * that documentation / examples that reference these tokens are not corrupted. - * - * This is a provider bug — no upstream fix tracked yet. - * Remove this function when upstream providers stop leaking tokens. - * @see https://github.com/openclaw/openclaw/issues/40020 - */ import { findCodeRegions, isInsideCode } from "./code-regions.js"; // Match both ASCII pipe <|...|> and full-width pipe <|...|> (U+FF5C) variants. @@ -30,6 +15,12 @@ function shouldInsertSeparator(before: string | undefined, after: string | undef return Boolean(before && after && !/\s/.test(before) && !/\s/.test(after)); } +/** + * Strips leaked model control tokens like `<|assistant|>` or full-width pipe variants. + * Code examples are preserved; remove this when providers stop emitting these tokens. + * + * @see https://github.com/openclaw/openclaw/issues/40020 + */ export function stripModelSpecialTokens(text: string): string { if (!text) { return text; diff --git a/src/shared/text/reasoning-tags.ts b/src/shared/text/reasoning-tags.ts index 4d035766bfb..4de9d119eb0 100644 --- a/src/shared/text/reasoning-tags.ts +++ b/src/shared/text/reasoning-tags.ts @@ -17,6 +17,7 @@ function applyTrim(value: string, mode: ReasoningTagTrim): string { return value.trim(); } +/** Detects whether a stray reasoning close tag separates two visible text regions. */ export function hasOrphanReasoningCloseBoundary(params: { before: string; after: string; @@ -24,6 +25,7 @@ export function hasOrphanReasoningCloseBoundary(params: { return params.before.trim().length > 0 && params.after.trim().length > 0; } +/** Strips model reasoning/final tags from visible text while preserving literal code examples. */ export function stripReasoningTagsFromText( text: string, options?: { @@ -91,6 +93,8 @@ export function stripReasoningTagsFromText( const before = cleaned.slice(lastIndex, idx); const after = cleaned.slice(afterIndex); if (hasOrphanReasoningCloseBoundary({ before, after })) { + // A lone close tag after visible preamble means the hidden opening tag was + // probably truncated; drop the preamble so partial reasoning is not leaked. result = ""; } else { result += before; diff --git a/src/shared/text/tool-call-shaped-text.ts b/src/shared/text/tool-call-shaped-text.ts index 0af19af4898..d8bd5650e32 100644 --- a/src/shared/text/tool-call-shaped-text.ts +++ b/src/shared/text/tool-call-shaped-text.ts @@ -104,6 +104,8 @@ function findBalancedJsonEnd(text: string, start: number): number | null { let inString = false; let escaped = false; for (let index = start + 1; index < text.length; index += 1) { + // Cap candidate size so diagnostic scans cannot spend unbounded time on prose + // that happens to contain many braces. if (index - start > MAX_JSON_CANDIDATE_CHARS) { return null; } @@ -215,6 +217,7 @@ function detectReactAction(text: string): ToolCallShapedTextDetection | null { return { kind: "react_action", toolName: match[1] }; } +/** Detects assistant-visible text that looks like an unexecuted tool call instead of prose. */ export function detectToolCallShapedText(text: string): ToolCallShapedTextDetection | null { const trimmed = text.slice(0, MAX_SCAN_CHARS).trim(); if (!trimmed || !TOOL_TEXT_PREFILTER_RE.test(trimmed)) { diff --git a/src/shared/thread-binding-lifecycle.ts b/src/shared/thread-binding-lifecycle.ts index 1898d8594e4..7ae05962202 100644 --- a/src/shared/thread-binding-lifecycle.ts +++ b/src/shared/thread-binding-lifecycle.ts @@ -5,6 +5,7 @@ export type ThreadBindingLifecycleRecord = { maxAgeMs?: number; }; +/** Resolves the next expiration for a channel thread binding from idle and max-age limits. */ export function resolveThreadBindingLifecycle(params: { record: ThreadBindingLifecycleRecord; defaultIdleTimeoutMs: number; @@ -22,12 +23,14 @@ export function resolveThreadBindingLifecycle(params: { ? Math.max(0, Math.floor(params.record.maxAgeMs)) : params.defaultMaxAgeMs; + // Activity imported from older stores may predate the binding; never expire before bind time. const inactivityExpiresAt = idleTimeoutMs > 0 ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs : undefined; const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + // The lifecycle reports the first real reason so callers can prune or surface it accurately. if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { return inactivityExpiresAt <= maxAgeExpiresAt ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } diff --git a/src/shared/usage-aggregates.ts b/src/shared/usage-aggregates.ts index ebc1b73d097..68ed7925fd3 100644 --- a/src/shared/usage-aggregates.ts +++ b/src/shared/usage-aggregates.ts @@ -29,6 +29,7 @@ type LatencyLike = { type DailyLatencyInput = LatencyLike & { date: string }; +/** Merges latency summaries by keeping weighted averages as sum/count accumulator state. */ export function mergeUsageLatency( totals: LatencyTotalsLike, latency: LatencyLike | undefined, @@ -43,6 +44,7 @@ export function mergeUsageLatency( totals.p95Max = Math.max(totals.p95Max, latency.p95Ms); } +/** Groups daily latency summaries by date while preserving weighted averages for output. */ export function mergeUsageDailyLatency( dailyLatencyMap: Map, dailyLatency?: DailyLatencyInput[] | null, @@ -65,6 +67,7 @@ export function mergeUsageDailyLatency( } } +/** Builds deterministic usage aggregate arrays for API responses and UI rendering. */ export function buildUsageAggregateTail< TTotals extends { totalCost: number }, TDaily extends DailyLike, @@ -85,6 +88,7 @@ export function buildUsageAggregateTail< ? { count: params.latencyTotals.count, avgMs: params.latencyTotals.sum / params.latencyTotals.count, + // Empty aggregates keep Infinity internally so later real samples win min comparisons. minMs: params.latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : params.latencyTotals.min, maxMs: params.latencyTotals.max, diff --git a/src/utils/boolean.ts b/src/utils/boolean.ts index 58d9bb3ba5c..2ee5a5d3dcb 100644 --- a/src/utils/boolean.ts +++ b/src/utils/boolean.ts @@ -1,5 +1,6 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +/** Accepted string literals for boolean parsing beyond actual booleans. */ export type BooleanParseOptions = { truthy?: string[]; falsy?: string[]; @@ -10,10 +11,12 @@ const DEFAULT_FALSY = ["false", "0", "no", "off"] as const; const DEFAULT_TRUTHY_SET = new Set(DEFAULT_TRUTHY); const DEFAULT_FALSY_SET = new Set(DEFAULT_FALSY); +/** Returns only real boolean values and leaves boolean-like strings for explicit parsing. */ export function asBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } +/** Parses booleans and configured string literals, returning undefined for ambiguous input. */ export function parseBooleanValue( value: unknown, options: BooleanParseOptions = {}, @@ -31,6 +34,7 @@ export function parseBooleanValue( } const truthy = options.truthy ?? DEFAULT_TRUTHY; const falsy = options.falsy ?? DEFAULT_FALSY; + // Reuse default sets on hot paths; custom literals get per-call sets to keep caller state immutable. const truthySet = truthy === DEFAULT_TRUTHY ? DEFAULT_TRUTHY_SET : new Set(truthy); const falsySet = falsy === DEFAULT_FALSY ? DEFAULT_FALSY_SET : new Set(falsy); if (truthySet.has(normalized)) { diff --git a/src/utils/chunk-items.ts b/src/utils/chunk-items.ts index 58ceebff1a4..d9a56f82838 100644 --- a/src/utils/chunk-items.ts +++ b/src/utils/chunk-items.ts @@ -1,3 +1,4 @@ +/** Splits items into fixed-size chunks, preserving order and returning one row for non-positive sizes. */ export function chunkItems(items: readonly T[], size: number): T[][] { if (size <= 0) { return [Array.from(items)]; diff --git a/src/utils/shell-argv.ts b/src/utils/shell-argv.ts index 3f75dfa22ef..14815ff9d1a 100644 --- a/src/utils/shell-argv.ts +++ b/src/utils/shell-argv.ts @@ -4,6 +4,7 @@ function isDoubleQuoteEscape(next: string | undefined): next is string { return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next)); } +/** Splits a shell-like argv string into tokens, returning null for unterminated quotes or escapes. */ export function splitShellArgs(raw: string): string[] | null { const tokens: string[] = []; let buf = ""; @@ -39,6 +40,7 @@ export function splitShellArgs(raw: string): string[] | null { } if (inDouble) { const next = raw[i + 1]; + // Inside double quotes, only POSIX-recognized escapes consume the backslash. if (ch === "\\" && isDoubleQuoteEscape(next)) { buf += next; i += 1; diff --git a/src/utils/utils-misc.test.ts b/src/utils/utils-misc.test.ts index 8df314e2b27..f8e8f6a3e89 100644 --- a/src/utils/utils-misc.test.ts +++ b/src/utils/utils-misc.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { asBoolean, parseBooleanValue } from "./boolean.js"; +import { chunkItems } from "./chunk-items.js"; import { splitShellArgs } from "./shell-argv.js"; +import { safeParseJsonWithSchema, safeParseWithSchema } from "./zod-parse.js"; describe("asBoolean", () => { it("accepts booleans only", () => { @@ -72,3 +75,28 @@ describe("splitShellArgs", () => { expect(splitShellArgs(`echo hi#tail`)).toEqual(["echo", "hi#tail"]); }); }); + +describe("chunkItems", () => { + it("splits items into fixed-size chunks", () => { + expect(chunkItems([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it("keeps one row when the requested size is not positive", () => { + expect(chunkItems([1, 2, 3], 0)).toEqual([[1, 2, 3]]); + }); +}); + +describe("zod parse helpers", () => { + const schema = z.object({ name: z.string() }); + + it("returns parsed data for schema-valid values", () => { + expect(safeParseWithSchema(schema, { name: "Ada" })).toEqual({ name: "Ada" }); + expect(safeParseJsonWithSchema(schema, `{"name":"Ada"}`)).toEqual({ name: "Ada" }); + }); + + it("returns null for schema failures or invalid JSON", () => { + expect(safeParseWithSchema(schema, { name: 1 })).toBeNull(); + expect(safeParseJsonWithSchema(schema, `{"name":1}`)).toBeNull(); + expect(safeParseJsonWithSchema(schema, `{`)).toBeNull(); + }); +}); diff --git a/src/utils/zod-parse.ts b/src/utils/zod-parse.ts index 21c1711130a..32fb879558a 100644 --- a/src/utils/zod-parse.ts +++ b/src/utils/zod-parse.ts @@ -1,10 +1,12 @@ import type { ZodType } from "zod"; +/** Safely validates an unknown value with a Zod schema, returning null on validation failure. */ export function safeParseWithSchema(schema: ZodType, value: unknown): T | null { const parsed = schema.safeParse(value); return parsed.success ? parsed.data : null; } +/** Parses JSON, then safely validates it with a Zod schema, returning null for parse or schema failures. */ export function safeParseJsonWithSchema(schema: ZodType, raw: string): T | null { try { return safeParseWithSchema(schema, JSON.parse(raw));