From 33ea3e16e984ce409e67907319ea4562a5ebf149 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 23:10:46 -0700 Subject: [PATCH] refactor: consolidate core micro-helpers (#117825) * refactor: centralize stable stringification * refactor: reuse canonical record coercion * refactor: reuse safe JSON parsing in cron storage * refactor: centralize environment truthiness * fix: enforce model scan and block reply timeouts * refactor: consolidate signal-aware sleep helper * fix: preserve plugin SDK sleep contract * test: satisfy model scan timeout lint --- .../src/host/embeddings-debug.ts | 20 +- packages/normalization-core/package.json | 7 +- packages/normalization-core/src/index.ts | 1 + .../src}/stable-stringify.test.ts | 4 +- .../src}/stable-stringify.ts | 11 +- .../tool-call-repair/src/stream-normalizer.ts | 31 ++-- src/agents/cache-trace.ts | 2 +- src/agents/code-mode-bridge.ts | 2 +- src/agents/code-mode-swarm.test.ts | 2 +- .../embedded-agent-error-observation.ts | 2 +- .../sanitize-user-facing-text.ts | 2 +- .../google-prompt-cache.ts | 2 +- .../prompt-cache-observability.ts | 2 +- .../provider-prompt-state.ts | 2 +- .../run/attempt.user-message-boundary.ts | 2 +- src/agents/harness/native-hook-relay-codec.ts | 2 +- .../harness/native-hook-relay-events.ts | 2 +- .../media-generation-task-status-shared.ts | 2 +- src/agents/model-scan.test.ts | 17 +- src/agents/model-scan.ts | 175 +++++++++--------- src/agents/models-config.ts | 2 +- src/agents/prepared-model-runtime.facts.ts | 2 +- src/agents/tool-loop-detection.ts | 2 +- src/agents/tools/system-agent-tool.ts | 2 +- .../reply/block-reply-pipeline.test.ts | 16 ++ src/auto-reply/reply/block-reply-pipeline.ts | 44 ++--- src/auto-reply/reply/queue/drain.ts | 2 +- src/claws/add.ts | 2 +- src/claws/cron-update.ts | 2 +- src/claws/doctor.ts | 2 +- src/claws/lifecycle-config-removal.ts | 2 +- src/claws/lifecycle-state.ts | 2 +- src/claws/lifecycle.ts | 2 +- src/claws/mcp.ts | 2 +- src/claws/package-update-provenance.ts | 2 +- src/claws/package-update.ts | 2 +- src/claws/provenance.ts | 2 +- src/claws/update-apply.test.ts | 2 +- src/claws/update-apply.ts | 2 +- src/claws/update-capability-changes.ts | 2 +- src/claws/update-plan.test.ts | 2 +- src/claws/update-plan.ts | 2 +- src/cli/claws-cli.runtime.ts | 2 +- src/commands/doctor-platform-notes.ts | 8 +- src/config/mcp-config.ts | 2 +- src/cron/config-revision.ts | 2 +- src/cron/list-snapshot-revision.ts | 2 +- src/cron/run-diagnostics-normalize.ts | 4 - src/cron/run-diagnostics.ts | 26 +-- src/cron/store/payload-codec.ts | 8 +- src/cron/store/row-codec.ts | 19 +- src/cron/store/scalar-codec.ts | 22 +-- src/cron/store/state-codec.ts | 11 +- src/gateway/server-methods/migrations.ts | 2 +- .../inference-store.test.ts | 2 +- src/gateway/worker-environments/inference.ts | 2 +- .../worker-environments/transcript-commit.ts | 2 +- src/infra/process-respawn.ts | 11 +- ...e-migrations.meeting-transcripts-verify.ts | 26 +-- src/logging/diagnostic-stability.ts | 12 +- src/meeting-bot/node-host.ts | 9 +- src/meeting-bot/node-invoke-policy.ts | 9 +- src/meeting-bot/plugin-entry.ts | 21 +-- .../realtime-node-audio-transport.ts | 10 +- src/plugins/bundled-dir.ts | 7 +- src/plugins/web-search-credential-presence.ts | 16 +- src/proxy-capture/proxy-server.ts | 6 +- src/secrets/runtime-owner-contract.ts | 2 +- src/skills/runtime/session-snapshot.ts | 2 +- src/tui/tui-plugin-approvals.ts | 38 ++-- src/tui/tui-task-suggestions.ts | 41 ++-- 71 files changed, 309 insertions(+), 405 deletions(-) rename {src/agents => packages/normalization-core/src}/stable-stringify.test.ts (95%) rename {src/agents => packages/normalization-core/src}/stable-stringify.ts (93%) diff --git a/packages/memory-host-sdk/src/host/embeddings-debug.ts b/packages/memory-host-sdk/src/host/embeddings-debug.ts index 7cc1c6747051..2e8f5c72fb67 100644 --- a/packages/memory-host-sdk/src/host/embeddings-debug.ts +++ b/packages/memory-host-sdk/src/host/embeddings-debug.ts @@ -1,9 +1,14 @@ // Memory Host SDK module implements embeddings debug behavior. +import { parseBoolean } from "@openclaw/normalization-core/boolean-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; // Lightweight debug logging for memory embedding internals. -const debugEmbeddings = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS); +const normalizedDebugEmbeddings = normalizeLowercaseStringOrEmpty( + process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS, +); +const debugEmbeddings = + parseBoolean(normalizedDebugEmbeddings) ?? ["1", "on", "yes"].includes(normalizedDebugEmbeddings); /** Write embedding debug metadata when OPENCLAW_DEBUG_MEMORY_EMBEDDINGS is enabled. */ export function debugEmbeddingsLog(message: string, meta?: Record): void { @@ -13,16 +18,3 @@ export function debugEmbeddingsLog(message: string, meta?: Record + text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { it("sorts object keys recursively", () => { expect(stableStringify({ b: { d: 4, c: 3 }, a: 1 })).toBe('{"a":1,"b":{"c":3,"d":4}}'); diff --git a/src/agents/stable-stringify.ts b/packages/normalization-core/src/stable-stringify.ts similarity index 93% rename from src/agents/stable-stringify.ts rename to packages/normalization-core/src/stable-stringify.ts index 2eb2bc1c6662..c46e06f087e6 100644 --- a/src/agents/stable-stringify.ts +++ b/packages/normalization-core/src/stable-stringify.ts @@ -3,7 +3,6 @@ * Serializes arbitrary values with deterministic key ordering and explicit * handling for errors, binary data, bigint, non-finite numbers, and cycles. */ -import { Buffer } from "node:buffer"; type StableStringNormalizer = (value: string) => string; @@ -69,7 +68,7 @@ function stringifyObjectValue( return stringifyStableValue( { type: "Uint8Array", - data: Buffer.from(value).toString("base64"), + data: encodeBase64(value), }, stack, normalizeString, @@ -99,6 +98,14 @@ function stringifyObjectValue( return `{${serializedFields.join(",")}}`; } +function encodeBase64(value: Uint8Array): string { + let binary = ""; + for (const byte of value) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + function compareStableStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } diff --git a/packages/tool-call-repair/src/stream-normalizer.ts b/packages/tool-call-repair/src/stream-normalizer.ts index ec6315a7ba6f..70131b5b458b 100644 --- a/packages/tool-call-repair/src/stream-normalizer.ts +++ b/packages/tool-call-repair/src/stream-normalizer.ts @@ -1,3 +1,4 @@ +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { isOffsetInProtectedRanges, type PlainTextToolCallNameMatcher, @@ -103,10 +104,6 @@ type SuppressingPendingState = { type PendingState = CandidatePendingState | SuppressingPendingState; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function eventContentIndex(event: Record): number { const index = event.contentIndex; return typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : 0; @@ -120,7 +117,7 @@ function extractStandaloneCandidate( message: unknown, requireAssistantRole = false, ): StandalonePlainTextToolCallCandidate | undefined { - const record = asRecord(message); + const record = asOptionalObjectRecord(message); if (!record || (requireAssistantRole && record.role !== "assistant")) { return undefined; } @@ -132,7 +129,7 @@ function extractStandaloneCandidate( } const candidate: StandalonePlainTextToolCallCandidate = { text: "", parts: [] }; for (const [contentIndex, block] of record.content.entries()) { - const value = asRecord(block); + const value = asOptionalObjectRecord(block); if (!value) { return undefined; } @@ -397,7 +394,7 @@ function projectRangesOntoMessage( const sourceToProjectedContentIndex = new Map(); for (const [index, block] of record.content.entries()) { const part = parts.get(index); - const blockRecord = asRecord(block); + const blockRecord = asOptionalObjectRecord(block); if (!part || blockRecord?.type !== "text" || typeof blockRecord.text !== "string") { sourceToProjectedContentIndex.set(index, content.length); content.push(block); @@ -422,7 +419,7 @@ export function projectScrubbedPlainTextToolCallMessage(params: { resolveProtectedRanges?: PlainTextToolCallProtectedRangeResolver; requireAssistantRole?: boolean; }): PlainTextToolCallMessageProjection | undefined { - const record = asRecord(params.message); + const record = asOptionalObjectRecord(params.message); const candidate = extractStandaloneCandidate( params.message, params.requireAssistantRole === true, @@ -486,7 +483,7 @@ function resolvePartialProtectionCheck(params: { resolveProtectedRanges: PlainTextToolCallProtectedRangeResolver; }): ((offset: number) => boolean) | undefined { const candidate = extractStandaloneCandidate(params.partial); - const record = asRecord(params.partial); + const record = asOptionalObjectRecord(params.partial); if (!candidate || !record) { return undefined; } @@ -500,7 +497,7 @@ function resolvePartialProtectionCheck(params: { } else { const part = candidate.parts.find((entry) => entry.contentIndex === params.contentIndex); const block = Array.isArray(record.content) - ? asRecord(record.content[params.contentIndex]) + ? asOptionalObjectRecord(record.content[params.contentIndex]) : undefined; if (!part || block?.type !== "text" || typeof block.text !== "string") { return undefined; @@ -711,14 +708,14 @@ function projectedTextForEvent( event: Record, projection: PlainTextToolCallMessageProjection, ): string | undefined { - const content = asRecord(projection.message)?.content; + const content = asOptionalObjectRecord(projection.message)?.content; if (typeof content === "string") { return content; } const projectedIndex = projection.sourceToProjectedContentIndex.get(eventContentIndex(event)); const block = Array.isArray(content) && projectedIndex !== undefined - ? asRecord(content[projectedIndex]) + ? asOptionalObjectRecord(content[projectedIndex]) : undefined; return block?.type === "text" && typeof block.text === "string" ? block.text : undefined; } @@ -1088,7 +1085,7 @@ function orderByContentIndex( ): unknown[] { const contentLength = Array.isArray(message.content) ? message.content.length : 0; const order = (event: unknown) => { - const index = asRecord(event)?.contentIndex; + const index = asOptionalObjectRecord(event)?.contentIndex; return typeof index === "number" && Number.isInteger(index) && index >= 0 && @@ -1223,7 +1220,7 @@ export async function* normalizePlainTextToolCallStreamEvents( async function* normalizeEvents() { for await (const sourceEvent of source) { - let record = asRecord(sourceEvent); + let record = asOptionalObjectRecord(sourceEvent); if (!record) { yield sourceEvent; continue; @@ -1411,7 +1408,7 @@ export async function* normalizePlainTextToolCallStreamEvents( yield createSyntheticTextDelta( visibleTemplate, novelVisiblePrefix, - asRecord(visibleProjection?.message), + asOptionalObjectRecord(visibleProjection?.message), ); } } @@ -1456,7 +1453,7 @@ export async function* normalizePlainTextToolCallStreamEvents( createSyntheticTextDelta( pending.template, candidateText, - asRecord(record.partial), + asOptionalObjectRecord(record.partial), ), { ...incomingRecord, content: incoming }, ]; @@ -1823,7 +1820,7 @@ export async function* normalizePlainTextToolCallStreamEvents( } } for await (const event of normalizeEvents()) { - const record = asRecord(event); + const record = asOptionalObjectRecord(event); if (record?.type === "text_delta" && typeof record.delta === "string") { const key = eventKey(record); const previous = emittedTextUnits.get(key) ?? 0; diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index 16c4558dac1b..3b0b8d4ec903 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -4,6 +4,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { sanitizeSurrogates } from "@openclaw/ai/internal/shared"; +import { stableStringify } from "@openclaw/normalization-core"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; @@ -12,7 +13,6 @@ import { safeJsonStringify } from "../utils/safe-json.js"; import { redactAgentDiagnosticPayload } from "./diagnostic-redaction.js"; import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js"; import type { AgentMessage, StreamFn } from "./runtime/index.js"; -import { stableStringify } from "./stable-stringify.js"; import { buildAgentTraceBase } from "./trace-base.js"; // Payloads are redacted before JSONL output while stable digests preserve diff --git a/src/agents/code-mode-bridge.ts b/src/agents/code-mode-bridge.ts index 1fd4e619ea1b..bfeb7d198944 100644 --- a/src/agents/code-mode-bridge.ts +++ b/src/agents/code-mode-bridge.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { NODE_FS_LIST_DIR_COMMAND } from "../infra/node-commands.js"; import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; @@ -13,7 +14,6 @@ import { } from "./code-mode-runtime.js"; import { readCodeModeSkill } from "./code-mode-skills.js"; import type { AgentToolUpdateCallback } from "./runtime/index.js"; -import { stableStringify } from "./stable-stringify.js"; import { getSwarmRunByLaunchReplayKey, initSubagentRegistry } from "./subagent-registry.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; import { diff --git a/src/agents/code-mode-swarm.test.ts b/src/agents/code-mode-swarm.test.ts index 769e6a79d040..cdd76bf8ef19 100644 --- a/src/agents/code-mode-swarm.test.ts +++ b/src/agents/code-mode-swarm.test.ts @@ -1,9 +1,9 @@ import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createCodeModeNamespaceRuntime } from "./code-mode-namespaces.js"; import { resolveCodeModeConfig } from "./code-mode.js"; import { testing } from "./code-mode.test-support.js"; -import { stableStringify } from "./stable-stringify.js"; import { SWARM_CODE_MODE_IDEMPOTENCY_KEY, SWARM_CODE_MODE_REQUEST_FINGERPRINT, diff --git a/src/agents/embedded-agent-error-observation.ts b/src/agents/embedded-agent-error-observation.ts index 3dca9e5b7e4a..8019f1fd5127 100644 --- a/src/agents/embedded-agent-error-observation.ts +++ b/src/agents/embedded-agent-error-observation.ts @@ -1,3 +1,4 @@ +import { stableStringify } from "@openclaw/normalization-core"; /** * Builds structured observations for embedded-agent API/text failures. */ @@ -12,7 +13,6 @@ import { parseApiErrorInfo, type ProviderRuntimeFailureKind, } from "./embedded-agent-helpers.js"; -import { stableStringify } from "./stable-stringify.js"; const MAX_OBSERVATION_INPUT_CHARS = 64_000; const MAX_FINGERPRINT_MESSAGE_CHARS = 8_000; diff --git a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts index 9a16f3eaf457..dbde05c62e67 100644 --- a/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts +++ b/src/agents/embedded-agent-helpers/sanitize-user-facing-text.ts @@ -1,3 +1,4 @@ +import { stableStringify } from "@openclaw/normalization-core"; /** * Converts raw provider/transport errors into concise user-facing copy. */ @@ -27,7 +28,6 @@ import { findCodeRegions } from "../../shared/text/code-regions.js"; import { stripFinalTags } from "../../shared/text/final-tags.js"; import { formatExecDeniedUserMessage } from "../exec-approval-result.js"; import { stripInternalRuntimeContext } from "../internal-runtime-context.js"; -import { stableStringify } from "../stable-stringify.js"; import { isBillingErrorMessage, isOverloadedErrorMessage, diff --git a/src/agents/embedded-agent-runner/google-prompt-cache.ts b/src/agents/embedded-agent-runner/google-prompt-cache.ts index 1c6a2d3fbf3c..d143102c8d4d 100644 --- a/src/agents/embedded-agent-runner/google-prompt-cache.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.ts @@ -7,6 +7,7 @@ import { stripSystemPromptCacheBoundary, } from "@openclaw/ai/internal/shared"; import { mergeTransportHeaders, sanitizeTransportPayloadText } from "@openclaw/ai/transports"; +import { stableStringify } from "@openclaw/normalization-core"; import { asDateTimestampMs, isFutureDateTimestampMs, @@ -28,7 +29,6 @@ import { resolveProviderRequestHeaders } from "../provider-request-config.js"; import { buildGuardedModelFetch } from "../provider-transport-fetch.js"; import type { StreamFn } from "../runtime/index.js"; import { isSessionWriteLockAcquireError } from "../session-write-lock-error.js"; -import { stableStringify } from "../stable-stringify.js"; import { log } from "./logger.js"; import { isGooglePromptCacheEligible, resolveCacheRetention } from "./prompt-cache-retention.js"; import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js"; diff --git a/src/agents/embedded-agent-runner/prompt-cache-observability.ts b/src/agents/embedded-agent-runner/prompt-cache-observability.ts index c9831f92d382..dbdbcca097e5 100644 --- a/src/agents/embedded-agent-runner/prompt-cache-observability.ts +++ b/src/agents/embedded-agent-runner/prompt-cache-observability.ts @@ -6,8 +6,8 @@ import { sortPromptCacheToolsByName, splitSystemPromptCacheBoundary, } from "@openclaw/ai/internal/shared"; +import { stableStringify } from "@openclaw/normalization-core"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { stableStringify } from "../stable-stringify.js"; import type { NormalizedUsage } from "../usage.js"; type PromptCacheChangeCode = diff --git a/src/agents/embedded-agent-runner/provider-prompt-state.ts b/src/agents/embedded-agent-runner/provider-prompt-state.ts index 8fbf27c2af5e..a3780c1ecef5 100644 --- a/src/agents/embedded-agent-runner/provider-prompt-state.ts +++ b/src/agents/embedded-agent-runner/provider-prompt-state.ts @@ -1,9 +1,9 @@ import { Buffer } from "node:buffer"; import crypto from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { Model } from "openclaw/plugin-sdk/llm"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; -import { stableStringify } from "../stable-stringify.js"; type ProviderPromptSnapshot = { scopeDigest: string; diff --git a/src/agents/embedded-agent-runner/run/attempt.user-message-boundary.ts b/src/agents/embedded-agent-runner/run/attempt.user-message-boundary.ts index b4333a40dfcb..5c257d7607bc 100644 --- a/src/agents/embedded-agent-runner/run/attempt.user-message-boundary.ts +++ b/src/agents/embedded-agent-runner/run/attempt.user-message-boundary.ts @@ -1,3 +1,4 @@ +import { stableStringify } from "@openclaw/normalization-core"; import { formatContextJsonBlock } from "../../../auto-reply/reply/channel-prompt-context.js"; import { markInboundContextLabel } from "../../../auto-reply/reply/inbound-context-marker.js"; import { @@ -5,7 +6,6 @@ import { INTER_SESSION_PROMPT_PREFIX_BASE, } from "../../../sessions/input-provenance.js"; import type { AgentMessage } from "../../runtime/index.js"; -import { stableStringify } from "../../stable-stringify.js"; import { isRunnerToolCallBlockType } from "./attempt.tool-call-block-type.js"; export type UserTranscriptContext = { diff --git a/src/agents/harness/native-hook-relay-codec.ts b/src/agents/harness/native-hook-relay-codec.ts index c707d872027c..5650008c144a 100644 --- a/src/agents/harness/native-hook-relay-codec.ts +++ b/src/agents/harness/native-hook-relay-codec.ts @@ -1,4 +1,4 @@ -import { stableStringify } from "../stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { normalizeToolName } from "../tool-policy.js"; import { codexNativeHookRelayResponseCodec } from "./native-hook-relay-response-codec.js"; import type { diff --git a/src/agents/harness/native-hook-relay-events.ts b/src/agents/harness/native-hook-relay-events.ts index 677d8d56868e..235083bd23e9 100644 --- a/src/agents/harness/native-hook-relay-events.ts +++ b/src/agents/harness/native-hook-relay-events.ts @@ -1,3 +1,4 @@ +import { stableStringify } from "@openclaw/normalization-core"; import { getAgentToolResultMiddlewareMatcherScope, listAgentToolResultMiddlewares, @@ -12,7 +13,6 @@ import { hasBeforeToolCallPolicy, runBeforeToolCallHook, } from "../agent-tools.before-tool-call.js"; -import { stableStringify } from "../stable-stringify.js"; import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js"; import { payloadTextResult } from "../tools/common.js"; import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js"; diff --git a/src/agents/media-generation-task-status-shared.ts b/src/agents/media-generation-task-status-shared.ts index de2bc67ec85f..db9d255e3c6e 100644 --- a/src/agents/media-generation-task-status-shared.ts +++ b/src/agents/media-generation-task-status-shared.ts @@ -1,3 +1,4 @@ +import { stableStringify } from "@openclaw/normalization-core"; /** * Shared media generation task status and duplicate-guard helpers. * @@ -12,7 +13,6 @@ import { import { listFreshTasksForOwnerKey } from "../tasks/runtime-internal.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { buildSessionAsyncTaskStatusDetails } from "./session-async-task-status.js"; -import { stableStringify } from "./stable-stringify.js"; /** Marks media as ready while requester delivery is still being confirmed. */ export const MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS = diff --git a/src/agents/model-scan.test.ts b/src/agents/model-scan.test.ts index c2c0c1060ece..93c91b16e3f8 100644 --- a/src/agents/model-scan.test.ts +++ b/src/agents/model-scan.test.ts @@ -179,16 +179,9 @@ describe("scanOpenRouterModels", () => { it("applies the scan timeout before the OpenRouter catalog responds", async () => { vi.useFakeTimers(); - const fetchImpl: typeof fetch = async (_input, init) => - await new Promise((_resolve, reject) => { - const signal = typeof init === "object" && init ? init.signal : undefined; - if (signal?.aborted) { - reject(new Error("catalog aborted")); - return; - } - signal?.addEventListener("abort", () => reject(new Error("catalog aborted")), { - once: true, - }); + const fetchImpl: typeof fetch = async () => + await new Promise(() => { + // Deliberately ignore cancellation to prove the timeout race settles independently. }); const scan = expect( @@ -197,7 +190,7 @@ describe("scanOpenRouterModels", () => { probe: false, timeoutMs: 1, }), - ).rejects.toThrow(/catalog aborted/); + ).rejects.toThrow(/OpenRouter model scan timed out/); await vi.advanceTimersByTimeAsync(1); await scan; @@ -253,7 +246,7 @@ describe("scanOpenRouterModels", () => { const scan = expect( scanOpenRouterModels({ fetchImpl, probe: false, timeoutMs }), - ).rejects.toThrow(/aborted/i); + ).rejects.toThrow(/timed out/i); await vi.advanceTimersByTimeAsync(timeoutMs - 1); expect(chunkCount).toBe(3); diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index de9be83dc12f..d490b81cf2c3 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -24,6 +24,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { readResponseWithLimit } from "../infra/http-body.js"; import "../llm/ai-transport-host.js"; import type { Context, Model, Tool } from "../llm/types.js"; +import { withTimeout } from "../node-host/with-timeout.js"; import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; @@ -181,19 +182,6 @@ function isFreeOpenRouterModel(entry: OpenRouterModelMeta): boolean { return entry.pricing.prompt === 0 && entry.pricing.completion === 0; } -async function withTimeout( - timeoutMs: number, - fn: (signal: AbortSignal) => Promise, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(controller.abort.bind(controller), timeoutMs); - try { - return await fn(controller.signal); - } finally { - clearTimeout(timer); - } -} - // Reads the OpenRouter /models success body under a byte cap before JSON.parse. // The success path was previously buffered with an unbounded res.json(); a faulty // or hostile provider could stream an effectively endless document and exhaust @@ -222,74 +210,79 @@ async function fetchOpenRouterModels( try { // fetch resolves after headers, so keep the shared timeout active until // the provider-controlled catalog body has been consumed. - return await withTimeout(timeoutMs, async (signal) => { - res = await fetchImpl(OPENROUTER_MODELS_URL, { - headers: { Accept: "application/json" }, - signal, - }); - if (!res.ok) { - throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); - } - const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown }; - const entries = Array.isArray(payload.data) ? payload.data : []; + return await withTimeout( + async (signal) => { + res = await fetchImpl(OPENROUTER_MODELS_URL, { + headers: { Accept: "application/json" }, + signal, + }); + if (!res.ok) { + throw new Error(`OpenRouter /models failed: HTTP ${res.status}`); + } + const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown }; + const entries = Array.isArray(payload.data) ? payload.data : []; - return entries - .map((entry) => { - if (!entry || typeof entry !== "object") { - return null; - } - const obj = entry as Record; - const id = normalizeOptionalString(obj.id) ?? ""; - if (!id) { - return null; - } - const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id; + return entries + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + const obj = entry as Record; + const id = normalizeOptionalString(obj.id) ?? ""; + if (!id) { + return null; + } + const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id; - const contextLength = - typeof obj.context_length === "number" && Number.isFinite(obj.context_length) - ? obj.context_length - : null; - - const maxCompletionTokens = - typeof obj.max_completion_tokens === "number" && - Number.isFinite(obj.max_completion_tokens) - ? obj.max_completion_tokens - : typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens) - ? obj.max_output_tokens + const contextLength = + typeof obj.context_length === "number" && Number.isFinite(obj.context_length) + ? obj.context_length : null; - const supportedParameters = Array.isArray(obj.supported_parameters) - ? normalizeStringEntries( - obj.supported_parameters.filter((value) => typeof value === "string"), - ) - : []; + const maxCompletionTokens = + typeof obj.max_completion_tokens === "number" && + Number.isFinite(obj.max_completion_tokens) + ? obj.max_completion_tokens + : typeof obj.max_output_tokens === "number" && + Number.isFinite(obj.max_output_tokens) + ? obj.max_output_tokens + : null; - const supportedParametersCount = supportedParameters.length; - const supportsToolsMeta = supportedParameters.includes("tools"); + const supportedParameters = Array.isArray(obj.supported_parameters) + ? normalizeStringEntries( + obj.supported_parameters.filter((value) => typeof value === "string"), + ) + : []; - const modality = - typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null; + const supportedParametersCount = supportedParameters.length; + const supportsToolsMeta = supportedParameters.includes("tools"); - const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`); - const createdAtMs = normalizeCreatedAtMs(obj.created_at); - const pricing = parseOpenRouterPricing(obj.pricing); + const modality = + typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null; - return { - id, - name, - contextLength, - maxCompletionTokens, - supportedParameters, - supportedParametersCount, - supportsToolsMeta, - modality, - inferredParamB, - createdAtMs, - pricing, - } satisfies OpenRouterModelMeta; - }) - .filter((entry): entry is OpenRouterModelMeta => Boolean(entry)); - }); + const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`); + const createdAtMs = normalizeCreatedAtMs(obj.created_at); + const pricing = parseOpenRouterPricing(obj.pricing); + + return { + id, + name, + contextLength, + maxCompletionTokens, + supportedParameters, + supportedParametersCount, + supportsToolsMeta, + modality, + inferredParamB, + createdAtMs, + pricing, + } satisfies OpenRouterModelMeta; + }) + .filter((entry): entry is OpenRouterModelMeta => Boolean(entry)); + }, + timeoutMs, + "OpenRouter model scan", + ); } finally { if (res && !res.bodyUsed) { await res.body?.cancel().catch(() => undefined); @@ -315,14 +308,17 @@ async function probeTool( }; const startedAt = Date.now(); try { - const message = await withTimeout(timeoutMs, (signal) => - complete(model, context, { - apiKey, - maxTokens: 256, - temperature: 0, - toolChoice: "required", - signal, - } satisfies OpenAICompletionsOptions), + const message = await withTimeout( + (signal) => + complete(model, context, { + apiKey, + maxTokens: 256, + temperature: 0, + toolChoice: "required", + signal, + } satisfies OpenAICompletionsOptions), + timeoutMs, + "model tool probe", ); const hasToolCall = message.content.some((block) => block.type === "toolCall"); @@ -364,13 +360,16 @@ async function probeImage( }; const startedAt = Date.now(); try { - await withTimeout(timeoutMs, (signal) => - complete(model, context, { - apiKey, - maxTokens: 16, - temperature: 0, - signal, - } satisfies OpenAICompletionsOptions), + await withTimeout( + (signal) => + complete(model, context, { + apiKey, + maxTokens: 16, + temperature: 0, + signal, + } satisfies OpenAICompletionsOptions), + timeoutMs, + "model image probe", ); return { ok: true, latencyMs: Date.now() - startedAt }; } catch (err) { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index b0d3c62e2400..88666672191e 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -6,6 +6,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { stableStringify } from "@openclaw/normalization-core"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { getRuntimeConfig, @@ -44,7 +45,6 @@ import { resolvePluginModelCatalogOwnerPluginId, type PersistedPluginModelCatalog, } from "./plugin-model-catalog.js"; -import { stableStringify } from "./stable-stringify.js"; type PreparedOpenClawModelsJsonSource = ModelsJsonReadyResult & { fingerprint: string; diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index c7b910729674..bc9677696f22 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import type { ConfiguredModelRef } from "@openclaw/model-catalog-core/configured-model-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { stableStringify } from "@openclaw/normalization-core"; import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js"; import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; import { sha256Base64Url } from "../infra/crypto-digest.js"; @@ -60,7 +61,6 @@ import type { import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js"; import type { AuthStorage, AuthStorageData } from "./sessions/auth-storage.js"; import type { ModelRegistry } from "./sessions/model-registry.js"; -import { stableStringify } from "./stable-stringify.js"; const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; const fullModelCatalogSnapshots = new WeakSet(); diff --git a/src/agents/tool-loop-detection.ts b/src/agents/tool-loop-detection.ts index d2a6dc81d504..b84335baf115 100644 --- a/src/agents/tool-loop-detection.ts +++ b/src/agents/tool-loop-detection.ts @@ -4,6 +4,7 @@ * Watches recent tool history for repeated no-progress patterns and circuit-breaker thresholds. */ import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { normalizeNullableString as nonEmptyStringField, normalizeOptionalString as normalizeRunId, @@ -13,7 +14,6 @@ import type { SessionState, ToolCallRecord } from "../logging/diagnostic-session import { createSubsystemLogger } from "../logging/subsystem.js"; import { isPlainObject } from "../utils.js"; import { isMessagingToolSendAction } from "./embedded-agent-messaging.js"; -import { stableStringify } from "./stable-stringify.js"; import { buildArgumentChurnWarning, getArgumentChurnNoProgressStreak, diff --git a/src/agents/tools/system-agent-tool.ts b/src/agents/tools/system-agent-tool.ts index 4129d74c9c04..bd5df813d08c 100644 --- a/src/agents/tools/system-agent-tool.ts +++ b/src/agents/tools/system-agent-tool.ts @@ -5,6 +5,7 @@ * union with approval assertions and the audit log. */ import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { Type } from "typebox"; import type { RuntimeEnv } from "../../runtime.js"; import { @@ -14,7 +15,6 @@ import { } from "../../system-agent/operations.js"; import { validateSystemAgentPluginInstallSpec } from "../../system-agent/plugin-install.js"; import { stringEnum } from "../schema/typebox.js"; -import { stableStringify } from "../stable-stringify.js"; import { textResult, ToolInputError, readStringParam, type AnyAgentTool } from "./common.js"; export type SystemAgentToolOptions = { diff --git a/src/auto-reply/reply/block-reply-pipeline.test.ts b/src/auto-reply/reply/block-reply-pipeline.test.ts index 16be7407a04b..2dc5e23cdefc 100644 --- a/src/auto-reply/reply/block-reply-pipeline.test.ts +++ b/src/auto-reply/reply/block-reply-pipeline.test.ts @@ -55,6 +55,22 @@ describe("createBlockReplyContentKey", () => { }); describe("createBlockReplyPipeline dedup with threading", () => { + it("keeps an un-aborted delivery signal when timeouts are disabled", async () => { + let deliverySignal: AbortSignal | undefined; + const pipeline = createBlockReplyPipeline({ + onBlockReply: async (_payload, options) => { + deliverySignal = options?.abortSignal; + }, + timeoutMs: 0, + }); + + pipeline.enqueue({ text: "response text" }); + await pipeline.flush({ force: true }); + + expect(deliverySignal).toBeDefined(); + expect(deliverySignal?.aborted).toBe(false); + }); + it("does not count reasoning or commentary as a terminal reply", async () => { const pipeline = createBlockReplyPipeline({ onBlockReply: async () => {}, diff --git a/src/auto-reply/reply/block-reply-pipeline.ts b/src/auto-reply/reply/block-reply-pipeline.ts index f069127a26f2..b5293b8aa542 100644 --- a/src/auto-reply/reply/block-reply-pipeline.ts +++ b/src/auto-reply/reply/block-reply-pipeline.ts @@ -5,6 +5,7 @@ import { resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "../../globals.js"; +import { withTimeout } from "../../node-host/with-timeout.js"; import { getReplyPayloadMetadata, isReplyPayloadStatusNotice } from "../reply-payload.js"; import type { ReplyPayload } from "../types.js"; import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; @@ -79,27 +80,6 @@ export function createBlockReplyContentKey(payload: ReplyPayload): string { }); } -const withTimeout = async ( - promise: Promise, - timeoutMs: number, - timeoutError: Error, -): Promise => { - if (!timeoutMs || timeoutMs <= 0) { - return promise; - } - let timer: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout(() => reject(timeoutError), timeoutMs); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -}; - function resolveBlockReplyTimeoutMs(timeoutMs: number): number { return clampPositiveTimerTimeoutMs(timeoutMs) ?? 0; } @@ -158,22 +138,23 @@ export function createBlockReplyPipeline(params: { pendingKeys.add(payloadKey); // Preserve outbound order by chaining sends; abort after timeout to avoid stale blocks. - const timeoutError = new Error(`block reply delivery timed out after ${timeoutMs}ms`); - const abortController = new AbortController(); + const fallbackAbortController = new AbortController(); + let timeoutSignal: AbortSignal | undefined; sendChain = sendChain .then(async () => { if (aborted) { return false; } await withTimeout( - Promise.resolve( - onBlockReply(payload, { - abortSignal: abortController.signal, + async (signal) => { + timeoutSignal = signal; + await onBlockReply(payload, { + abortSignal: signal ?? fallbackAbortController.signal, timeoutMs, - }), - ), - timeoutMs, - timeoutError, + }); + }, + timeoutMs || undefined, + "block reply delivery", ); return true; }) @@ -208,8 +189,7 @@ export function createBlockReplyPipeline(params: { } }) .catch((err: unknown) => { - if (err === timeoutError) { - abortController.abort(); + if (timeoutSignal?.aborted) { aborted = true; if (!didLogTimeout) { didLogTimeout = true; diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 3e502877646d..39f869665189 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; import { expectDefined } from "@openclaw/normalization-core"; +import { stableStringify } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js"; -import { stableStringify } from "../../../agents/stable-stringify.js"; import { normalizeChatType } from "../../../channels/chat-type.js"; import { resolveStorePath } from "../../../config/sessions.js"; import { loadSessionEntryReadOnly } from "../../../config/sessions/session-accessor.js"; diff --git a/src/claws/add.ts b/src/claws/add.ts index 8be8871673cc..1e4fae2e92ef 100644 --- a/src/claws/add.ts +++ b/src/claws/add.ts @@ -1,9 +1,9 @@ // Applies the package, agent, workspace, and managed-file slices of a consented Claw add plan. import { lstat, mkdir, rmdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; +import { stableStringify } from "@openclaw/normalization-core"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; import { listAgentEntries } from "../agents/agent-scope.js"; -import { stableStringify } from "../agents/stable-stringify.js"; import { transformConfigFileWithRetry } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/claws/cron-update.ts b/src/claws/cron-update.ts index dd7d825e5f51..e4e3689efa33 100644 --- a/src/claws/cron-update.ts +++ b/src/claws/cron-update.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { CLAW_CRON_REF_SCHEMA_VERSION, diff --git a/src/claws/doctor.ts b/src/claws/doctor.ts index 70819f12557c..07ab0c72a5b7 100644 --- a/src/claws/doctor.ts +++ b/src/claws/doctor.ts @@ -1,7 +1,7 @@ // Claw doctor diagnostics project the lifecycle ownership ledger into health findings. import { createHash } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { listConfiguredMcpServers } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveDefaultCronStaggerMs } from "../cron/stagger.js"; diff --git a/src/claws/lifecycle-config-removal.ts b/src/claws/lifecycle-config-removal.ts index a23c6ae89bba..d34445c35dba 100644 --- a/src/claws/lifecycle-config-removal.ts +++ b/src/claws/lifecycle-config-removal.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { listAgentEntries } from "../agents/agent-scope.js"; -import { stableStringify } from "../agents/stable-stringify.js"; import { getRuntimeConfig } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/claws/lifecycle-state.ts b/src/claws/lifecycle-state.ts index 8b03c6feac7f..176d7b1f3ab5 100644 --- a/src/claws/lifecycle-state.ts +++ b/src/claws/lifecycle-state.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { getRuntimeConfig } from "../config/config.js"; import { listConfiguredMcpServers, unsetConfiguredMcpServer } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/claws/lifecycle.ts b/src/claws/lifecycle.ts index 27584010de11..5022996d37d7 100644 --- a/src/claws/lifecycle.ts +++ b/src/claws/lifecycle.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { lstat, realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { resolve } from "node:path"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js"; import { assertNoSymlinkParents } from "../infra/fs-safe-advanced.js"; import { FsSafeError, root as fsSafeRoot, type Root } from "../infra/fs-safe.js"; diff --git a/src/claws/mcp.ts b/src/claws/mcp.ts index 26544e4c3860..bfd46921b4a0 100644 --- a/src/claws/mcp.ts +++ b/src/claws/mcp.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { canonicalizeConfiguredMcpServer } from "../config/mcp-config-normalize.js"; import { listConfiguredMcpServers, setConfiguredMcpServer } from "../config/mcp-config.js"; import { diff --git a/src/claws/package-update-provenance.ts b/src/claws/package-update-provenance.ts index 8701ae5e13ff..74de84195d99 100644 --- a/src/claws/package-update-provenance.ts +++ b/src/claws/package-update-provenance.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, diff --git a/src/claws/package-update.ts b/src/claws/package-update.ts index 10864b37b339..4d3bb600b561 100644 --- a/src/claws/package-update.ts +++ b/src/claws/package-update.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { preflightPluginInstall } from "../plugins/plugin-install-preflight.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { diff --git a/src/claws/provenance.ts b/src/claws/provenance.ts index 517c5528833f..5aa4334c124e 100644 --- a/src/claws/provenance.ts +++ b/src/claws/provenance.ts @@ -1,7 +1,7 @@ // Persists the root ownership record for one Claw-created agent and workspace. import { createHash } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { openOpenClawStateDatabase, runOpenClawStateWriteTransaction, diff --git a/src/claws/update-apply.test.ts b/src/claws/update-apply.test.ts index de9e3ae1a633..6ef090317c42 100644 --- a/src/claws/update-apply.test.ts +++ b/src/claws/update-apply.test.ts @@ -654,4 +654,4 @@ describe("applyClawUpdatePlan", () => { }); }); import { createHash } from "node:crypto"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; diff --git a/src/claws/update-apply.ts b/src/claws/update-apply.ts index 3db8bbcd139c..980ac0fb1839 100644 --- a/src/claws/update-apply.ts +++ b/src/claws/update-apply.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { listAgentEntries } from "../agents/agent-scope.js"; -import { stableStringify } from "../agents/stable-stringify.js"; import { transformConfigFileWithRetry } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/claws/update-capability-changes.ts b/src/claws/update-capability-changes.ts index 3a6d75ebb3e3..c5833b14d9c6 100644 --- a/src/claws/update-capability-changes.ts +++ b/src/claws/update-capability-changes.ts @@ -1,8 +1,8 @@ // Builds field-level capability change summaries for Claw update previews. import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { listAgentEntries, toAgentEntriesRecord } from "../agents/agent-scope.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; -import { stableStringify } from "../agents/stable-stringify.js"; import { expandToolGroups, resolveToolProfilePolicy } from "../agents/tool-policy-shared.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; diff --git a/src/claws/update-plan.test.ts b/src/claws/update-plan.test.ts index a77d72e2d03d..4023cd4fd701 100644 --- a/src/claws/update-plan.test.ts +++ b/src/claws/update-plan.test.ts @@ -1,9 +1,9 @@ import { createHash } from "node:crypto"; import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { stableStringify } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { stableStringify } from "../agents/stable-stringify.js"; import type { McpServerConfig } from "../config/types.mcp.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { diff --git a/src/claws/update-plan.ts b/src/claws/update-plan.ts index 03a96d4e27b6..3f50c9349ac3 100644 --- a/src/claws/update-plan.ts +++ b/src/claws/update-plan.ts @@ -1,7 +1,7 @@ // Builds read-only, agent-centric Claw update plans from grouped manifests and ownership state. import { createHash } from "node:crypto"; import { lstat } from "node:fs/promises"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { root as fsSafeRoot } from "../infra/fs-safe.js"; diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index 1942d07704e4..ad220a98cd8a 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -1,10 +1,10 @@ import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; +import { stableStringify } from "@openclaw/normalization-core"; import { listAgentEntries, listAgentIds, resolveAgentWorkspaceDir, } from "../agents/agent-scope-config.js"; -import { stableStringify } from "../agents/stable-stringify.js"; import { applyClawAddPlan, CLAW_ADD_RESULT_SCHEMA_VERSION, diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts index 2c422e33388e..710faec5344b 100644 --- a/src/commands/doctor-platform-notes.ts +++ b/src/commands/doctor-platform-notes.ts @@ -242,10 +242,6 @@ export async function collectMacGatewayPlatformWarnings( return warnings; } -function isTruthyEnvValue(value: string | undefined): boolean { - return Boolean(normalizeOptionalString(value)); -} - function isTmpCompileCachePath(cachePath: string): boolean { const normalized = cachePath.trim().replace(/\/+$/, ""); return ( @@ -296,7 +292,7 @@ export function noteStartupOptimizationHints( ); } - if (isTruthyEnvValue(disableCompileCache)) { + if (disableCompileCache) { lines.push("- NODE_DISABLE_COMPILE_CACHE is set; startup compile cache is disabled."); } @@ -315,7 +311,7 @@ export function noteStartupOptimizationHints( " export NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache", " mkdir -p /var/tmp/openclaw-compile-cache", " export OPENCLAW_NO_RESPAWN=1", - isTruthyEnvValue(disableCompileCache) ? " unset NODE_DISABLE_COMPILE_CACHE" : undefined, + disableCompileCache ? " unset NODE_DISABLE_COMPILE_CACHE" : undefined, ].filter((line): line is string => Boolean(line)); noteFn([...lines, ...suggestions].join("\n"), "Startup optimization"); diff --git a/src/config/mcp-config.ts b/src/config/mcp-config.ts index b83d00019e5e..6dcdd7f0bdee 100644 --- a/src/config/mcp-config.ts +++ b/src/config/mcp-config.ts @@ -1,6 +1,6 @@ import { expectDefined } from "@openclaw/normalization-core"; // Normalizes MCP server config for runtime launch and validation. -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { markClawMcpServerIndependentlyOwned } from "../state/claw-mcp-adoption.js"; import { isRecord } from "../utils.js"; import { readSourceConfigSnapshot } from "./io.js"; diff --git a/src/cron/config-revision.ts b/src/cron/config-revision.ts index 51b2e4851de4..4b1fcb0a3ad2 100644 --- a/src/cron/config-revision.ts +++ b/src/cron/config-revision.ts @@ -1,5 +1,5 @@ /** Opaque revision token for cron configuration, excluding scheduler-maintained state. */ -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { sha256Base64Url } from "../infra/crypto-digest.js"; import { projectCronJobThroughStorageCodec } from "./store/row-codec.js"; import type { CronJob } from "./types.js"; diff --git a/src/cron/list-snapshot-revision.ts b/src/cron/list-snapshot-revision.ts index f425e5fd665b..b5c5c7297ca9 100644 --- a/src/cron/list-snapshot-revision.ts +++ b/src/cron/list-snapshot-revision.ts @@ -1,4 +1,4 @@ -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { sha256Base64Url } from "../infra/crypto-digest.js"; import type { CronJob } from "./types.js"; diff --git a/src/cron/run-diagnostics-normalize.ts b/src/cron/run-diagnostics-normalize.ts index 61b20a2cf7c6..46dad2900382 100644 --- a/src/cron/run-diagnostics-normalize.ts +++ b/src/cron/run-diagnostics-normalize.ts @@ -68,10 +68,6 @@ export function formatUnknownError(error: unknown): string { return String(error); } -export function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} - export function normalizeToolName(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; diff --git a/src/cron/run-diagnostics.ts b/src/cron/run-diagnostics.ts index ff2e13f8ac8e..32a80aae5ad8 100644 --- a/src/cron/run-diagnostics.ts +++ b/src/cron/run-diagnostics.ts @@ -1,4 +1,5 @@ /** Builds bounded, redacted diagnostics for cron run logs and UI surfaces. */ +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { isToolAllowedByPolicyName } from "../agents/tool-policy-match.js"; import { normalizeToolName as normalizePolicyToolName } from "../agents/tool-policy.js"; @@ -6,7 +7,6 @@ import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js"; import { redactSensitiveText } from "../logging/redact.js"; import { formatUnknownError, - isRecord, normalizeCronRunDiagnosticSummary, normalizeCronRunDiagnostics as normalizeCronRunDiagnosticsValue, normalizeExitCode, @@ -165,16 +165,17 @@ function createCronRunDiagnosticsFromExecDetails( finalStatus?: "ok" | "error" | "skipped"; }, ): CronRunDiagnostics | undefined { - if (!isRecord(details)) { + const record = asOptionalObjectRecord(details); + if (!record) { return undefined; } - const status = typeof details.status === "string" ? details.status : undefined; - const exitCode = normalizeExitCode(details.exitCode); + const status = typeof record.status === "string" ? record.status : undefined; + const exitCode = normalizeExitCode(record.exitCode); const relevant = status === "failed" || (typeof exitCode === "number" && exitCode !== 0); if (!relevant) { return undefined; } - const aggregated = normalizeOptionalString(details.aggregated); + const aggregated = normalizeOptionalString(record.aggregated); const message = aggregated ? tailText(aggregated, EXEC_DIAGNOSTIC_TAIL_CHARS) : typeof exitCode === "number" @@ -203,20 +204,21 @@ function createCronRunDiagnosticsFromToolPayload( payload: unknown, opts?: { nowMs?: () => number; finalStatus?: "ok" | "error" | "skipped" }, ): CronRunDiagnostics | undefined { - if (!isRecord(payload)) { + const record = asOptionalObjectRecord(payload); + if (!record) { return undefined; } - const toolName = normalizeToolName(payload.toolName) ?? normalizeToolName(payload.name); - const detailsDiagnostics = createCronRunDiagnosticsFromExecDetails(payload.details, { + const toolName = normalizeToolName(record.toolName) ?? normalizeToolName(record.name); + const detailsDiagnostics = createCronRunDiagnosticsFromExecDetails(record.details, { nowMs: opts?.nowMs, toolName, finalStatus: opts?.finalStatus, }); - const isError = payload.isError === true; - const text = typeof payload.text === "string" ? payload.text : undefined; + const isError = record.isError === true; + const text = typeof record.text === "string" ? record.text : undefined; const isNonTerminalToolWarning = opts?.finalStatus === "ok" && - getReplyPayloadMetadata(payload)?.nonTerminalToolErrorWarning === true; + getReplyPayloadMetadata(record)?.nonTerminalToolErrorWarning === true; const textDiagnostics = isError && text ? createCronRunDiagnosticsFromError("tool", text, { @@ -233,7 +235,7 @@ export function createCronRunDiagnosticsFromAgentResult( result: unknown, opts?: { nowMs?: () => number; finalStatus?: "ok" | "error" | "skipped" }, ): CronRunDiagnostics | undefined { - const record = isRecord(result) ? result : {}; + const record = asOptionalObjectRecord(result) ?? {}; const meta = record.meta && typeof record.meta === "object" ? (record.meta as Record) : {}; const diagnostics: Array = []; diff --git a/src/cron/store/payload-codec.ts b/src/cron/store/payload-codec.ts index 5c158c5fbd94..0623aaf8e3e0 100644 --- a/src/cron/store/payload-codec.ts +++ b/src/cron/store/payload-codec.ts @@ -1,11 +1,11 @@ /** SQLite column codec for cron payload variants. */ +import { safeParseJson } from "@openclaw/normalization-core"; import type { CronPayload } from "../types.js"; import { booleanToInteger, integerToBoolean, normalizeNumber, parseJsonArray, - parseJsonValue, serializeJson, } from "./scalar-codec.js"; import type { CronJobInsert, CronJobRow } from "./schema.js"; @@ -40,14 +40,14 @@ function payloadToolAllowFromRow( } function parseExternalContentSource(raw: string | null): "gmail" | "webhook" | undefined { - const parsed = raw ? parseJsonValue(raw, undefined) : undefined; + const parsed = raw ? safeParseJson(raw) : undefined; return parsed === "gmail" || parsed === "webhook" ? parsed : undefined; } function parseCommandPayloadMessage( raw: string | null, ): Omit, "kind" | "timeoutSeconds"> | null { - const parsed = raw ? parseJsonValue(raw, undefined) : undefined; + const parsed = raw ? safeParseJson(raw) : undefined; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } @@ -92,7 +92,7 @@ function parseCommandPayloadMessage( function parseScriptPayloadMessage( raw: string | null, ): Omit, "kind" | "timeoutSeconds"> | null { - const parsed = raw ? parseJsonValue(raw, undefined) : undefined; + const parsed = raw ? safeParseJson(raw) : undefined; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index 94c303af559d..b3d7207effb1 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -1,6 +1,7 @@ /** Converts cron jobs between public store shape and normalized SQLite rows. */ import type { DatabaseSync } from "node:sqlite"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { safeParseJson } from "@openclaw/normalization-core"; +import { asOptionalObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js"; @@ -12,12 +13,7 @@ import type { CronJob, CronJobState, CronPacing, CronSchedule, CronStoreFile } f import { bindDeliveryColumns, deliveryFromRow } from "./delivery-codec.js"; import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js"; import { bindPayloadColumns, payloadFromRow } from "./payload-codec.js"; -import { - booleanToInteger, - integerToBoolean, - normalizeNumber, - parseJsonObject, -} from "./scalar-codec.js"; +import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js"; import type { CronJobInsert, CronJobRow } from "./schema.js"; import { getCronStoreKysely } from "./schema.js"; import { bindStateColumns, stateFromRow } from "./state-codec.js"; @@ -252,7 +248,7 @@ function scheduleFromRow(row: CronJobRow): CronSchedule | null { }; } if (row.schedule_kind === "stream") { - const schedule = parseJsonObject>(row.job_json, {}).schedule; + const schedule = asOptionalObjectRecord(safeParseJson(row.job_json))?.schedule; if (!isRecord(schedule) || schedule.kind !== "stream" || !Array.isArray(schedule.command)) { return null; } @@ -262,7 +258,7 @@ function scheduleFromRow(row: CronJobRow): CronSchedule | null { } function pacingFromRow(row: CronJobRow): CronPacing | undefined { - const pacing = parseJsonObject>(row.job_json, {}).pacing; + const pacing = asOptionalObjectRecord(safeParseJson(row.job_json))?.pacing; if (!isRecord(pacing) || Array.isArray(pacing)) { return undefined; } @@ -273,7 +269,7 @@ function pacingFromRow(row: CronJobRow): CronPacing | undefined { } function rowToCronJob(row: CronJobRow): CronJob | null { - const jobJson = parseJsonObject>(row.job_json, {}); + const jobJson = asOptionalObjectRecord(safeParseJson(row.job_json)) ?? {}; const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined; const ownerAccountId = normalizeOptionalAccountId( typeof jsonOwner?.accountId === "string" ? jsonOwner.accountId : undefined, @@ -480,7 +476,8 @@ export function loadedCronStoreFromRows(rows: CronJobRow[]): LoadedCronStore { for (const [index, row] of rows.entries()) { const job = rowToCronJob(row); const configJob = mergeFailureDestinationProjection( - parseJsonObject>(row.job_json, job ? stripJobRuntimeFields(job) : {}), + asOptionalObjectRecord(safeParseJson(row.job_json)) ?? + (job ? stripJobRuntimeFields(job) : {}), job, ); const runtimeEntry = { diff --git a/src/cron/store/scalar-codec.ts b/src/cron/store/scalar-codec.ts index c2a931de45de..05ac7151199a 100644 --- a/src/cron/store/scalar-codec.ts +++ b/src/cron/store/scalar-codec.ts @@ -1,24 +1,6 @@ +import { safeParseJson } from "@openclaw/normalization-core"; import { normalizeSqliteNumber } from "../../infra/sqlite-number.js"; -/** 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; - return parsed && typeof parsed === "object" ? (parsed as T) : fallback; - } catch { - return fallback; - } -} - -/** 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; - } catch { - return fallback; - } -} - /** Normalizes SQLite number/bigint columns into JavaScript numbers. */ export { normalizeSqliteNumber as normalizeNumber }; @@ -43,7 +25,7 @@ export function parseJsonArray(raw: string | null): string[] | undefined { if (!raw) { return undefined; } - const parsed = parseJsonObject(raw, undefined); + const parsed = safeParseJson(raw); return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : undefined; diff --git a/src/cron/store/state-codec.ts b/src/cron/store/state-codec.ts index 77e40cb98966..6297ef6b2652 100644 --- a/src/cron/store/state-codec.ts +++ b/src/cron/store/state-codec.ts @@ -1,11 +1,8 @@ /** SQLite column codec for mutable cron runtime state. */ +import { safeParseJson } from "@openclaw/normalization-core"; +import { asRecord } from "@openclaw/normalization-core/record-coerce"; import type { CronJobState } from "../types.js"; -import { - booleanToInteger, - integerToBoolean, - normalizeNumber, - parseJsonObject, -} from "./scalar-codec.js"; +import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js"; import type { CronJobInsert, CronJobRow } from "./schema.js"; /** Maps mutable cron runtime state into normalized SQLite columns. */ @@ -49,7 +46,7 @@ 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, {}), + ...(asRecord(safeParseJson(row.state_json)) as CronJobState), ...(row.next_run_at_ms != null ? { nextRunAtMs: normalizeNumber(row.next_run_at_ms) } : {}), ...(row.running_at_ms != null ? { runningAtMs: normalizeNumber(row.running_at_ms) } : {}), ...(row.last_run_at_ms != null ? { lastRunAtMs: normalizeNumber(row.last_run_at_ms) } : {}), diff --git a/src/gateway/server-methods/migrations.ts b/src/gateway/server-methods/migrations.ts index 218ac50fc609..8be947ac20e4 100644 --- a/src/gateway/server-methods/migrations.ts +++ b/src/gateway/server-methods/migrations.ts @@ -1,5 +1,6 @@ // Gateway handlers expose reviewed, memory-only migration plans to trusted operators. import crypto from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { ErrorCodes, errorShape, @@ -11,7 +12,6 @@ import { validateMigrationsMemoryPlanParams, } from "../../../packages/gateway-protocol/src/index.js"; import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; -import { stableStringify } from "../../agents/stable-stringify.js"; import { applyProviderMemoryImport, listMemoryMigrationProviders, diff --git a/src/gateway/worker-environments/inference-store.test.ts b/src/gateway/worker-environments/inference-store.test.ts index 9b7af5e37fe1..597bc35af2b0 100644 --- a/src/gateway/worker-environments/inference-store.test.ts +++ b/src/gateway/worker-environments/inference-store.test.ts @@ -2,12 +2,12 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { stableStringify } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkerInferenceStartParams, WorkerInferenceTerminalOutcome, } from "../../../packages/gateway-protocol/src/schema/worker-inference.js"; -import { stableStringify } from "../../agents/stable-stringify.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, diff --git a/src/gateway/worker-environments/inference.ts b/src/gateway/worker-environments/inference.ts index 6b0e9f7917ff..c0a99747c413 100644 --- a/src/gateway/worker-environments/inference.ts +++ b/src/gateway/worker-environments/inference.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import { WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES, type WorkerInferenceCancelParams, @@ -14,7 +15,6 @@ import { validateWorkerInferenceTerminalFrame, validateWorkerInferenceTerminalOutcome, } from "../../../packages/gateway-protocol/src/schema/worker-inference.js"; -import { stableStringify } from "../../agents/stable-stringify.js"; import type { OpenClawConfig } from "../../config/types.js"; import { withTimeout } from "../../infra/fs-safe.js"; import { boundedJsonUtf8Bytes } from "../../infra/json-utf8-bytes.js"; diff --git a/src/gateway/worker-environments/transcript-commit.ts b/src/gateway/worker-environments/transcript-commit.ts index e93e506e21ff..da4432be0d5b 100644 --- a/src/gateway/worker-environments/transcript-commit.ts +++ b/src/gateway/worker-environments/transcript-commit.ts @@ -1,11 +1,11 @@ import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; import type { WorkerTranscriptCommitParams, WorkerTranscriptMessage, } from "../../../packages/gateway-protocol/src/schema/worker-admission.js"; import type { AgentMessage } from "../../agents/runtime/index.js"; import { SessionManager } from "../../agents/sessions/session-manager.js"; -import { stableStringify } from "../../agents/stable-stringify.js"; import { redactTranscriptMessage } from "../../agents/transcript-redact.js"; import { loadSessionEntry, diff --git a/src/infra/process-respawn.ts b/src/infra/process-respawn.ts index 2848ced63a88..2a54e905f4bb 100644 --- a/src/infra/process-respawn.ts +++ b/src/infra/process-respawn.ts @@ -1,8 +1,8 @@ // Respawns the gateway process when no supervisor handles restart. import { spawn, type ChildProcess } from "node:child_process"; -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { scheduleDetachedLaunchdRestartHandoff } from "../daemon/launchd-restart-handoff.js"; import { isContainerEnvironment } from "./container-environment.js"; +import { isTruthyEnvValue } from "./env.js"; import { formatErrorMessage } from "./errors.js"; import { triggerOpenClawRestart } from "./restart.js"; import { detectGatewayRespawnSupervisor } from "./supervisor-markers.js"; @@ -23,11 +23,6 @@ type GatewayRespawnOptions = { env?: NodeJS.ProcessEnv; }; -function isTruthy(value: string | undefined): boolean { - const normalized = normalizeOptionalLowercaseString(value); - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; -} - const PNPM_VERSIONED_OPENCLAW_ENTRY_PATTERN = /^(.*?)([\\/])node_modules\2\.pnpm\2openclaw@[^\\/]+\2node_modules\2openclaw\2.+$/; @@ -83,7 +78,7 @@ function scheduleLaunchdRestartAfterExit(): GatewayRespawnResult { export function restartGatewayProcessWithFreshPid( _opts: GatewayRespawnOptions = {}, ): GatewayRespawnResult { - if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) { + if (isTruthyEnvValue(process.env.OPENCLAW_NO_RESPAWN)) { return { mode: "disabled" }; } const supervisor = detectGatewayRespawnSupervisor(process.env); @@ -154,7 +149,7 @@ export function respawnGatewayProcessForUpdate( } return { mode: "supervised" }; } - if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) { + if (isTruthyEnvValue(process.env.OPENCLAW_NO_RESPAWN)) { return { mode: "disabled", detail: "OPENCLAW_NO_RESPAWN" }; } try { diff --git a/src/infra/state-migrations.meeting-transcripts-verify.ts b/src/infra/state-migrations.meeting-transcripts-verify.ts index 54c881a38a85..2ebddb12b605 100644 --- a/src/infra/state-migrations.meeting-transcripts-verify.ts +++ b/src/infra/state-migrations.meeting-transcripts-verify.ts @@ -1,5 +1,6 @@ // Verifies staged legacy transcript rows against the committed canonical store. import type { DatabaseSync } from "node:sqlite"; +import { stableStringify } from "@openclaw/normalization-core"; import type { TranscriptUtterance } from "../transcripts/provider-types.js"; import { transcriptSessionSelector, TranscriptsStore } from "../transcripts/store.js"; import { @@ -20,23 +21,6 @@ type StoredUtteranceRow = { utterance_id: string | null; }; -function canonicalJson(value: unknown): string { - if (value === undefined) { - return "undefined"; - } - if (Array.isArray(value)) { - return `[${value.map(canonicalJson).join(",")}]`; - } - if (value && typeof value === "object") { - const record = value as Record; - return `{${Object.keys(record) - .toSorted() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) - .join(",")}}`; - } - return JSON.stringify(value) ?? "undefined"; -} - function storedUtteranceFromRow(row: StoredUtteranceRow): TranscriptUtterance { const utterance: TranscriptUtterance = { sessionId: row.session_id, text: row.text }; if (row.utterance_id !== null) { @@ -88,7 +72,7 @@ export async function verifyImportedMeetingTranscriptSnapshots(params: { `); for (const snapshot of params.snapshots) { const session = await params.store.readSession(transcriptSessionSelector(snapshot.session)); - if (!session || canonicalJson(session) !== canonicalJson(snapshot.session)) { + if (!session || stableStringify(session) !== stableStringify(snapshot.session)) { throw new Error(`meeting transcript import verification failed: ${snapshot.relativeDir}`); } for ( @@ -110,14 +94,14 @@ export async function verifyImportedMeetingTranscriptSnapshots(params: { start, ) .map((row) => storedUtteranceFromRow(row as StoredUtteranceRow)); - if (canonicalJson(actual) !== canonicalJson(expected)) { + if (stableStringify(actual) !== stableStringify(expected)) { throw new Error(`meeting transcript import verification failed: ${snapshot.relativeDir}`); } } const summary = await params.store.readSummary(session); if ( - canonicalJson(summary.summary) !== canonicalJson(snapshot.summary) || - canonicalJson(summary.markdown?.trimEnd()) !== canonicalJson(snapshot.markdown?.trimEnd()) + stableStringify(summary.summary) !== stableStringify(snapshot.summary) || + stableStringify(summary.markdown?.trimEnd()) !== stableStringify(snapshot.markdown?.trimEnd()) ) { throw new Error(`meeting transcript summary verification failed: ${snapshot.relativeDir}`); } diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index d97ade21f861..969569cc8a20 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -192,12 +192,6 @@ function resolveDiagnosticLivenessRecordLevel( return hasBlockingWork || (event.active > 0 && hasSustainedEventLoopDelay) ? "warning" : "info"; } -function isRecord( - record: DiagnosticStabilityEventRecord | undefined, -): record is DiagnosticStabilityEventRecord { - return record !== undefined; -} - function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabilityEventRecord { const record: DiagnosticStabilityEventRecord = { seq: event.seq, @@ -585,12 +579,14 @@ function listRecords(): DiagnosticStabilityEventRecord[] { return []; } if (state.count < state.capacity) { - return state.records.slice(0, state.count).filter(isRecord); + return state.records + .slice(0, state.count) + .filter((record): record is DiagnosticStabilityEventRecord => record !== undefined); } return [ ...state.records.slice(state.nextIndex), ...state.records.slice(0, state.nextIndex), - ].filter(isRecord); + ].filter((record): record is DiagnosticStabilityEventRecord => record !== undefined); } function summarizeRecords( diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index 89e2c5af87cf..782fe95abe48 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { formatErrorMessage } from "../infra/errors.js"; import { decodeMeetingAudioBase64 } from "./audio-base64.js"; import { terminateMeetingBridgeProcess } from "./bridge-process.js"; @@ -74,12 +75,6 @@ function readStringArray(value: unknown): string[] | undefined { return result.length > 0 ? result : undefined; } -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function readString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -689,7 +684,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { throw new Error(`${options.displayName} node host received malformed params JSON.`); } } - const params = asRecord(raw); + const params = asOptionalRecord(raw) ?? {}; const action = readString(params.action); let result: unknown; switch (action) { diff --git a/src/meeting-bot/node-invoke-policy.ts b/src/meeting-bot/node-invoke-policy.ts index cfcf8b6a4da3..9afdb1d246d3 100644 --- a/src/meeting-bot/node-invoke-policy.ts +++ b/src/meeting-bot/node-invoke-policy.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawPluginNodeInvokePolicy, OpenClawPluginNodeInvokePolicyResult, @@ -29,12 +30,6 @@ type PolicyDecision = | { approved: true; params: Record } | { approved: false; result: OpenClawPluginNodeInvokePolicyResult }; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function readString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -258,7 +253,7 @@ export function createMeetingBrowserNodeInvokePolicy( if (ctx.command !== options.commandName) { return denied(options, `unsupported ${options.displayName} node command: ${ctx.command}`); } - const params = asRecord(ctx.params); + const params = asOptionalRecord(ctx.params) ?? {}; const action = readString(params.action); if (action === "setup" && options.useConfiguredSetupCommands) { const setupParams: Record = { action }; diff --git a/src/meeting-bot/plugin-entry.ts b/src/meeting-bot/plugin-entry.ts index 530ac5e2282d..f6307ebf20a5 100644 --- a/src/meeting-bot/plugin-entry.ts +++ b/src/meeting-bot/plugin-entry.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { TObject } from "typebox"; import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/schema/error-codes.js"; @@ -79,12 +80,6 @@ export type MeetingPluginEntryOptions< unknownActionMessage: string; }; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function readErrorDetails(error: unknown): unknown { return error && typeof error === "object" && "details" in error ? (error as { details?: unknown }).details @@ -251,7 +246,7 @@ export function createMeetingPluginEntryOptions< `${options.gatewayMethodPrefix}.join`, async ({ params, client, respond }: GatewayRequestHandlerOptions) => { try { - const raw = keepTrustedToolContext(asRecord(params), client); + const raw = keepTrustedToolContext(asOptionalRecord(params) ?? {}, client); respond(true, await (await ensureRuntime()).join(joinRequest(raw))); } catch (error) { sendRequestError(respond, error); @@ -262,7 +257,7 @@ export function createMeetingPluginEntryOptions< `${options.gatewayMethodPrefix}.leave`, async ({ params, client, respond }: GatewayRequestHandlerOptions) => { try { - const raw = asRecord(params); + const raw = asOptionalRecord(params) ?? {}; const agentId = trustedToolAgentId(raw, client); const sessionId = requireString(raw.sessionId, "sessionId"); const rt = await ensureRuntime(); @@ -281,7 +276,7 @@ export function createMeetingPluginEntryOptions< `${options.gatewayMethodPrefix}.status`, async ({ params, client, respond }: GatewayRequestHandlerOptions) => { try { - const raw = asRecord(params); + const raw = asOptionalRecord(params) ?? {}; const agentId = trustedToolAgentId(raw, client); const rt = await ensureRuntime(); respond( @@ -299,7 +294,7 @@ export function createMeetingPluginEntryOptions< `${options.gatewayMethodPrefix}.transcript`, async ({ params, client, respond }: GatewayRequestHandlerOptions) => { try { - const raw = asRecord(params); + const raw = asOptionalRecord(params) ?? {}; const sessionId = requireString(raw.sessionId, "sessionId"); const sinceIndex = readSinceIndex(raw); const agentId = trustedToolAgentId(raw, client); @@ -319,7 +314,7 @@ export function createMeetingPluginEntryOptions< `${options.gatewayMethodPrefix}.speak`, async ({ params, client, respond }: GatewayRequestHandlerOptions) => { try { - const raw = asRecord(params); + const raw = asOptionalRecord(params) ?? {}; const sessionId = requireString(raw.sessionId, "sessionId"); const agentId = trustedToolAgentId(raw, client); const rt = await ensureRuntime(); @@ -368,7 +363,7 @@ export function createMeetingPluginEntryOptions< method, async ({ params, client, respond }: GatewayRequestHandlerOptions) => { try { - const raw = keepTrustedToolContext(asRecord(params), client); + const raw = keepTrustedToolContext(asOptionalRecord(params) ?? {}, client); respond(true, await run(await ensureRuntime(), raw)); } catch (error) { sendRequestError(respond, error); @@ -383,7 +378,7 @@ export function createMeetingPluginEntryOptions< description: options.toolDescription, parameters: options.toolParameters, async execute(_toolCallId, params) { - const raw = asRecord(params); + const raw = asOptionalRecord(params) ?? {}; const action = raw.action as MeetingToolAction; const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey); const contextAgentId = diff --git a/src/meeting-bot/realtime-node-audio-transport.ts b/src/meeting-bot/realtime-node-audio-transport.ts index 49ac121016f8..5a9391b5dbcf 100644 --- a/src/meeting-bot/realtime-node-audio-transport.ts +++ b/src/meeting-bot/realtime-node-audio-transport.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { formatErrorMessage } from "../infra/errors.js"; import type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; import { decodeMeetingAudioBase64 } from "./audio-base64.js"; @@ -9,12 +10,6 @@ const NODE_OUTPUT_GENERATION_CAPABILITY = Symbol.for( "openclaw.internal.meeting-node-output-generation.v1", ); -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -85,7 +80,8 @@ export function createNodeMeetingRealtimeAudioTransport(params: { params: { action: "pullAudio", bridgeId: params.bridgeId, timeoutMs: 250 }, timeoutMs: 2_000, }); - const result = asRecord(asRecord(raw).payload ?? raw); + const rawRecord = asOptionalRecord(raw); + const result = asOptionalRecord(rawRecord?.payload ?? raw) ?? {}; const base64 = readString(result.base64); if (base64) { const audio = decodeMeetingAudioBase64(base64, "pullAudio"); diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 7ff4b38eb1a8..a5fbc9172557 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { isVitestRuntimeEnv } from "../infra/env.js"; +import { isTruthyEnvValue, isVitestRuntimeEnv } from "../infra/env.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { isPathInside } from "../infra/path-guards.js"; import { resolveUserPath } from "../utils.js"; @@ -39,11 +39,6 @@ function isSourceCheckoutRoot(packageRoot: string): boolean { ); } -function isTruthyEnvValue(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; -} - function shouldTrustTestBundledPluginsDirOverride(env: NodeJS.ProcessEnv): boolean { const isVitestProcess = isVitestRuntimeEnv(env) || isVitestRuntimeEnv(process.env); return ( diff --git a/src/plugins/web-search-credential-presence.ts b/src/plugins/web-search-credential-presence.ts index cf1e7eb5a44f..7ba8b3435db9 100644 --- a/src/plugins/web-search-credential-presence.ts +++ b/src/plugins/web-search-credential-presence.ts @@ -1,4 +1,5 @@ // Checks web-search credential presence from config and plugin metadata. +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -10,27 +11,24 @@ function hasConfiguredCredentialValue(value: unknown): boolean { return value !== undefined && value !== null; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - function hasConfiguredSearchCredentialCandidate(searchConfig: unknown): boolean { - if (!isRecord(searchConfig)) { + const record = asOptionalObjectRecord(searchConfig); + if (!record) { return false; } - return Object.entries(searchConfig).some( + return Object.entries(record).some( ([key, value]) => key !== "enabled" && hasConfiguredCredentialValue(value), ); } function hasConfiguredPluginWebSearchCandidate(config: OpenClawConfig): boolean { - const entries = isRecord(config.plugins?.entries) ? config.plugins.entries : undefined; + const entries = asOptionalObjectRecord(config.plugins?.entries); if (!entries) { return false; } return Object.values(entries).some((entry) => { - const pluginConfig = isRecord(entry) ? entry.config : undefined; - return isRecord(pluginConfig) && hasConfiguredSearchCredentialCandidate(pluginConfig.webSearch); + const pluginConfig = asOptionalObjectRecord(entry)?.config; + return hasConfiguredSearchCredentialCandidate(asOptionalObjectRecord(pluginConfig)?.webSearch); }); } diff --git a/src/proxy-capture/proxy-server.ts b/src/proxy-capture/proxy-server.ts index 42db7ac47d21..2da5561b5cb2 100644 --- a/src/proxy-capture/proxy-server.ts +++ b/src/proxy-capture/proxy-server.ts @@ -6,13 +6,13 @@ import { request as httpsRequest } from "node:https"; import net from "node:net"; import { StringDecoder } from "node:string_decoder"; import { URL } from "node:url"; +import { isTruthyEnvValue } from "../infra/env.js"; import { ensureDebugProxyCa } from "./ca.js"; import type { DebugProxySettings } from "./env.js"; import { redactedCaptureHeaders } from "./header-redaction.js"; import { getDebugProxyCaptureStore } from "./store.sqlite.js"; import type { CaptureEventRecord } from "./types.js"; -const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]); const DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE = "OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"; const CAPTURE_BODY_PREVIEW_BYTES = 8192; @@ -25,10 +25,6 @@ type BodyPreviewCapture = { truncated: boolean; }; -function isTruthyEnvValue(value: string | undefined): boolean { - return TRUTHY_ENV.has((value ?? "").trim().toLowerCase()); -} - function isManagedProxyActive(env: NodeJS.ProcessEnv = process.env): boolean { return isTruthyEnvValue(env["OPENCLAW_PROXY_ACTIVE"]); } diff --git a/src/secrets/runtime-owner-contract.ts b/src/secrets/runtime-owner-contract.ts index e0074edb820e..91901864e207 100644 --- a/src/secrets/runtime-owner-contract.ts +++ b/src/secrets/runtime-owner-contract.ts @@ -1,6 +1,6 @@ /** Process-local identity for the non-secret config that an owner may use with a credential. */ import { createHash } from "node:crypto"; -import { stableStringify } from "../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { coerceSecretRef } from "../config/types.secrets.js"; import { secretRefKey } from "./ref-contract.js"; diff --git a/src/skills/runtime/session-snapshot.ts b/src/skills/runtime/session-snapshot.ts index 1429fc1b6028..98f771df8d1f 100644 --- a/src/skills/runtime/session-snapshot.ts +++ b/src/skills/runtime/session-snapshot.ts @@ -1,6 +1,6 @@ // Session snapshot helpers capture and restore runtime skill state for sessions. import crypto from "node:crypto"; -import { stableStringify } from "../../agents/stable-stringify.js"; +import { stableStringify } from "@openclaw/normalization-core"; import { redactConfigObject } from "../../config/redact-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { matchesSkillFilter } from "../discovery/filter.js"; diff --git a/src/tui/tui-plugin-approvals.ts b/src/tui/tui-plugin-approvals.ts index 9d191e22eb5b..55a1f2a7bcf3 100644 --- a/src/tui/tui-plugin-approvals.ts +++ b/src/tui/tui-plugin-approvals.ts @@ -6,6 +6,7 @@ import { type OverlayHandle, type SelectItem, } from "@earendil-works/pi-tui"; +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { isApprovalStaleError } from "../infra/approval-errors.js"; import { formatErrorMessage } from "../infra/errors.js"; import { selectListTheme, theme } from "./theme/theme.js"; @@ -126,10 +127,6 @@ const DECISION_ITEMS: Record = { }, }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - function parseDecision(value: unknown): TuiApprovalDecision | null { return value === "allow-once" || value === "allow-always" || value === "deny" ? value : null; } @@ -154,13 +151,15 @@ function parseSeverity(value: unknown): TuiPluginApproval["request"]["severity"] /** Parses the gateway event/list shape used for pending plugin approvals. */ function parseTuiPluginApproval(payload: unknown): TuiPluginApproval | null { - if (!isRecord(payload) || !isRecord(payload.request)) { + const record = asOptionalObjectRecord(payload); + const request = asOptionalObjectRecord(record?.request); + if (!record || !request) { return null; } - const id = typeof payload.id === "string" ? payload.id.trim() : ""; - const title = typeof payload.request.title === "string" ? payload.request.title.trim() : ""; - const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0; - const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0; + const id = typeof record.id === "string" ? record.id.trim() : ""; + const title = typeof request.title === "string" ? request.title.trim() : ""; + const createdAtMs = typeof record.createdAtMs === "number" ? record.createdAtMs : 0; + const expiresAtMs = typeof record.expiresAtMs === "number" ? record.expiresAtMs : 0; if (!id || !title || !createdAtMs || !expiresAtMs) { return null; } @@ -168,15 +167,13 @@ function parseTuiPluginApproval(payload: unknown): TuiPluginApproval | null { id, request: { title, - description: - typeof payload.request.description === "string" ? payload.request.description : null, - pluginId: typeof payload.request.pluginId === "string" ? payload.request.pluginId : null, - severity: parseSeverity(payload.request.severity), - toolName: typeof payload.request.toolName === "string" ? payload.request.toolName : null, - allowedDecisions: parseAllowedDecisions(payload.request.allowedDecisions), - agentId: typeof payload.request.agentId === "string" ? payload.request.agentId : null, - sessionKey: - typeof payload.request.sessionKey === "string" ? payload.request.sessionKey : null, + description: typeof request.description === "string" ? request.description : null, + pluginId: typeof request.pluginId === "string" ? request.pluginId : null, + severity: parseSeverity(request.severity), + toolName: typeof request.toolName === "string" ? request.toolName : null, + allowedDecisions: parseAllowedDecisions(request.allowedDecisions), + agentId: typeof request.agentId === "string" ? request.agentId : null, + sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null, }, createdAtMs, expiresAtMs, @@ -184,10 +181,11 @@ function parseTuiPluginApproval(payload: unknown): TuiPluginApproval | null { } function parseResolvedApprovalId(payload: unknown): string | null { - if (!isRecord(payload) || typeof payload.id !== "string") { + const id = asOptionalObjectRecord(payload)?.id; + if (typeof id !== "string") { return null; } - return payload.id.trim() || null; + return id.trim() || null; } function decisionLabel(decision: TuiApprovalDecision): string { diff --git a/src/tui/tui-task-suggestions.ts b/src/tui/tui-task-suggestions.ts index 2f65966dde8f..475701a6137f 100644 --- a/src/tui/tui-task-suggestions.ts +++ b/src/tui/tui-task-suggestions.ts @@ -6,6 +6,7 @@ import { type OverlayHandle, type SelectItem, } from "@earendil-works/pi-tui"; +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import type { TaskSuggestion } from "../../packages/gateway-protocol/src/index.js"; import { formatErrorMessage } from "../infra/errors.js"; import { selectListTheme, theme } from "./theme/theme.js"; @@ -64,33 +65,30 @@ function sanitizeTaskText(text: string): string { return sanitizeRenderableText(text.replace(TASK_BIDI_CONTROL_RE, "")); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - /** Parses the task suggestion shape carried by Gateway list and event payloads. */ function parseTuiTaskSuggestion(value: unknown): TaskSuggestion | null { - if (!isRecord(value)) { + const record = asOptionalObjectRecord(value); + if (!record) { return null; } const required = ["id", "title", "prompt", "tldr", "cwd", "sessionKey"] as const; - if (required.some((field) => typeof value[field] !== "string" || !value[field].trim())) { + if (required.some((field) => typeof record[field] !== "string" || !record[field].trim())) { return null; } - if (typeof value.createdAt !== "number" || value.createdAt < 0) { + if (typeof record.createdAt !== "number" || record.createdAt < 0) { return null; } return { - id: (value.id as string).trim(), - title: (value.title as string).trim(), - prompt: (value.prompt as string).trim(), - tldr: (value.tldr as string).trim(), - cwd: (value.cwd as string).trim(), - sessionKey: (value.sessionKey as string).trim(), - ...(typeof value.agentId === "string" && value.agentId.trim() - ? { agentId: value.agentId.trim() } + id: (record.id as string).trim(), + title: (record.title as string).trim(), + prompt: (record.prompt as string).trim(), + tldr: (record.tldr as string).trim(), + cwd: (record.cwd as string).trim(), + sessionKey: (record.sessionKey as string).trim(), + ...(typeof record.agentId === "string" && record.agentId.trim() + ? { agentId: record.agentId.trim() } : {}), - createdAt: value.createdAt, + createdAt: record.createdAt, }; } @@ -410,11 +408,12 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController return { handleEvent(event: string, payload: unknown) { - if (disposed || event !== "task.suggestion" || !isRecord(payload)) { + const record = asOptionalObjectRecord(payload); + if (disposed || event !== "task.suggestion" || !record) { return; } - if (payload.action === "created") { - const suggestion = parseTuiTaskSuggestion(payload.suggestion); + if (record.action === "created") { + const suggestion = parseTuiTaskSuggestion(record.suggestion); if (suggestion) { revision += 1; hiddenIds.delete(suggestion.id); @@ -423,8 +422,8 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController } return; } - if (payload.action === "resolved" && typeof payload.taskId === "string") { - remove(payload.taskId); + if (record.action === "resolved" && typeof record.taskId === "string") { + remove(record.taskId); presentNext(); deps.requestRender(); }