From 25c375e47f5613709f54bb85cc792d7383718c6d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Jul 2026 11:43:36 +0200 Subject: [PATCH] refactor: reuse canonical record guards (#115810) * docs(agents): standardize canonical record guards * refactor(model-catalog): use canonical record guard * refactor(scripts): use canonical record guard * refactor(ui): use canonical record guard * refactor(codex): use canonical record guard * refactor(agents): use canonical record guard * refactor(sessions): use canonical record guard * refactor(doctor): use canonical record guard * refactor(infra): use canonical record guard --- AGENTS.md | 1 + .../codex/src/app-server/settled-turn-projection.ts | 5 +---- packages/model-catalog-core/src/configured-model-refs.ts | 6 +----- .../model-catalog-core/src/model-catalog-normalize.ts | 6 +----- scripts/e2e/parallels/npm-update-smoke.ts | 5 +---- src/agents/plugin-model-catalog-repair.ts | 5 +---- src/commands/doctor-retired-phone-control.ts | 5 +---- src/config/sessions/transcript-tree.ts | 6 ++---- src/infra/state-migrations.media-persistence.ts | 5 +---- ui/src/app/question-prompt.ts | 5 +---- ui/src/lib/workboard/normalization-utils.ts | 8 ++++---- 11 files changed, 15 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0df7e4951cdb..9d44eb0af055 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Skills own workflows; root owns hard policy and routing. - Tests may use observed examples, but prod literals need a short contract reason. - Compatibility is opt-in. "Shipped" means reachable from a release Git tag; main/GitHub/PR/unreleased code is not shipped. - Refactor default: one canonical path. Delete the old path unless user explicitly wants compat or the shipped public contract is obvious and cited. +- Reuse the canonical non-array record guard instead of adding local `isRecord` copies. Core, UI, scripts with workspace package resolution, and packages that already depend on normalization-core import `@openclaw/normalization-core/record-coerce`; plugins import `openclaw/plugin-sdk/string-coerce-runtime`. Keep a local guard only when the semantics intentionally differ or the file must remain dependency-free, browser-serialized, generated, or runnable outside workspace package resolution. - Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers. - State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime. - Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data. diff --git a/extensions/codex/src/app-server/settled-turn-projection.ts b/extensions/codex/src/app-server/settled-turn-projection.ts index c71364d86333..0a9a0649455b 100644 --- a/extensions/codex/src/app-server/settled-turn-projection.ts +++ b/extensions/codex/src/app-server/settled-turn-projection.ts @@ -1,5 +1,6 @@ import { Buffer } from "node:buffer"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { JsonValue } from "./protocol.js"; import { readUpstreamUserText } from "./upstream-prompt-provenance.js"; @@ -17,10 +18,6 @@ type ProjectedMessageGroup = { bytes: number; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" ? value.trim() || undefined : undefined; } diff --git a/packages/model-catalog-core/src/configured-model-refs.ts b/packages/model-catalog-core/src/configured-model-refs.ts index 664dca6bab8a..0fe20de723bb 100644 --- a/packages/model-catalog-core/src/configured-model-refs.ts +++ b/packages/model-catalog-core/src/configured-model-refs.ts @@ -1,9 +1,5 @@ // Collects configured model references from OpenClaw config-shaped objects. - -/** Narrow unknown values to plain records. */ -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { isRecord } from "@openclaw/normalization-core/record-coerce"; /** One configured model reference plus its config path. */ export type ConfiguredModelRef = { diff --git a/packages/model-catalog-core/src/model-catalog-normalize.ts b/packages/model-catalog-core/src/model-catalog-normalize.ts index 143cce9f7c42..f91e3703ecc4 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.ts @@ -1,4 +1,5 @@ // Model Catalog Core helper module supports model catalog normalize behavior. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeOptionalTrimmedStringList, @@ -43,11 +44,6 @@ const MODEL_CATALOG_API_SET = new Set(MODEL_CATALOG_APIS); const DEFAULT_MODEL_INPUT: ModelCatalogInput[] = ["text"]; const DEFAULT_MODEL_STATUS: ModelCatalogStatus = "available"; -/** Narrow unknown catalog payloads to plain records. */ -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - /** Reject object keys that can mutate prototypes when copied into records. */ function isBlockedObjectKey(key: string): boolean { return key === "__proto__" || key === "prototype" || key === "constructor"; diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index f0137125d000..dea377a1a4d8 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -11,6 +11,7 @@ import { clampTimerTimeoutMs, finiteSecondsToTimerSafeMilliseconds, } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import prettyMilliseconds from "pretty-ms"; import { die, @@ -568,10 +569,6 @@ function readString(value: unknown): string { return typeof value === "string" ? value : ""; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - export function parseRegistryPackageMetadata(raw: string): { gitHead: string; tarball: string; diff --git a/src/agents/plugin-model-catalog-repair.ts b/src/agents/plugin-model-catalog-repair.ts index 01201be23af0..daeeff1efcee 100644 --- a/src/agents/plugin-model-catalog-repair.ts +++ b/src/agents/plugin-model-catalog-repair.ts @@ -1,4 +1,5 @@ /** Pure repair rules for OpenClaw-generated plugin model catalogs. */ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; export const PLUGIN_MODEL_CATALOG_GENERATED_BY = "openclaw-plugin-model-catalog-v1"; @@ -7,10 +8,6 @@ type PluginModelCatalogRepair = { removedModelCount: number; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function hasCatalogApi(value: unknown): boolean { return typeof value === "string" && value.length > 0; } diff --git a/src/commands/doctor-retired-phone-control.ts b/src/commands/doctor-retired-phone-control.ts index 35f1a437e8d3..4f7ec60785dc 100644 --- a/src/commands/doctor-retired-phone-control.ts +++ b/src/commands/doctor-retired-phone-control.ts @@ -1,6 +1,7 @@ // Doctor migration for config and state left by the retired Phone Control lease model. import fs from "node:fs/promises"; import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createPluginStateKeyedStore } from "../plugin-state/plugin-state-store.js"; @@ -77,10 +78,6 @@ async function inspectStatePath(filePath: string, label: string): Promise { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isStringArray(value: unknown): boolean { return ( Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim() !== "") diff --git a/src/config/sessions/transcript-tree.ts b/src/config/sessions/transcript-tree.ts index ae8dabba93dc..81f8faa7a8db 100644 --- a/src/config/sessions/transcript-tree.ts +++ b/src/config/sessions/transcript-tree.ts @@ -1,4 +1,6 @@ // Transcript tree helpers keep append-only leaf controls consistent across readers. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; + type TranscriptRecord = Record; type SessionTranscriptTreeEntry = { @@ -25,10 +27,6 @@ export type SessionTranscriptTree = { hasInvalidLeafControl: boolean; }; -function isRecord(value: unknown): value is TranscriptRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } diff --git a/src/infra/state-migrations.media-persistence.ts b/src/infra/state-migrations.media-persistence.ts index bee87ac30b1b..4228d9957b73 100644 --- a/src/infra/state-migrations.media-persistence.ts +++ b/src/infra/state-migrations.media-persistence.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { decodeSessionArchiveBytes, encodeSessionArchiveContent, @@ -84,10 +85,6 @@ type ArchiveSourceSnapshot = { size: number; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function transformTranscriptEvent(event: TranscriptEvent): { changed: boolean; event: TranscriptEvent; diff --git a/ui/src/app/question-prompt.ts b/ui/src/app/question-prompt.ts index dd06e8e865f8..bd363cf8b5e5 100644 --- a/ui/src/app/question-prompt.ts +++ b/ui/src/app/question-prompt.ts @@ -1,4 +1,5 @@ // Control UI module owns transient operator question state. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { Question, QuestionAnswers, @@ -55,10 +56,6 @@ type QuestionAnswerValues = Record; const REFRESH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000] as const; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function readNonEmptyString(value: unknown): string | null { if (typeof value !== "string") { return null; diff --git a/ui/src/lib/workboard/normalization-utils.ts b/ui/src/lib/workboard/normalization-utils.ts index e56e301331df..fd064a5c5811 100644 --- a/ui/src/lib/workboard/normalization-utils.ts +++ b/ui/src/lib/workboard/normalization-utils.ts @@ -1,3 +1,7 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; + +export { isRecord }; + export function formatError(error: unknown): string { if (error instanceof Error && error.message.trim()) { return error.message; @@ -10,7 +14,3 @@ export function formatError(error: unknown): string { } return "Unknown workboard error."; } - -export function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -}