| import fs from "node:fs"; |
| import path from "node:path"; |
| import { isDeepStrictEqual } from "node:util"; |
| import type { LegacyConfigUpdatePlan } from "../../commands/doctor/legacy-config-repair.js"; |
| import { cloneEnvWithPlatformSemantics } from "../../config/env-vars.js"; |
| import { createConfigIO } from "../../config/io.js"; |
| import { formatConfigIssueLines } from "../../config/issue-format.js"; |
| import { resolveConfiguredAgentDatabaseCandidatePaths } from "../../config/sessions/targets.js"; |
| import type { OpenClawConfig } from "../../config/types.openclaw.js"; |
| import { formatErrorMessage } from "../../infra/errors.js"; |
| import { |
| OPENCLAW_DATABASE_SCHEMA_DOCS_URL, |
| preflightOpenClawDatabaseSchemas, |
| type IncompatibleOpenClawDatabase, |
| type IndeterminateOpenClawDatabase, |
| type OpenClawDatabaseSchemaPreflight, |
| } from "../../state/openclaw-database-preflight.js"; |
| import type { OpenClawSchemaVersions } from "../../state/openclaw-schema-versions.js"; |
| import { UpdatePreMutationError } from "./shared.js"; |
|
|
| type TargetDatabaseSchemaContext = { |
| config: OpenClawConfig; |
| env: NodeJS.ProcessEnv; |
| }; |
|
|
| export function formatSchemaRefusalLines( |
| schemas: { |
| incompatible: readonly IncompatibleOpenClawDatabase[]; |
| indeterminate: readonly IndeterminateOpenClawDatabase[]; |
| }, |
| dryRun = false, |
| ): string[] { |
| const prefix = dryRun ? "Would refuse update" : "Update refused"; |
| return [ |
| ...schemas.incompatible.map((database) => { |
| const agent = database.agentId ? ` (agent ${database.agentId})` : ""; |
| return `${prefix}: ${database.kind} database${agent} ${database.path} has schema ${database.foundVersion}; target supports ${database.supportedVersion}; writer build ${database.writerAppVersion ?? "unknown"}.`; |
| }), |
| ...schemas.indeterminate.map( |
| (database) => |
| `${prefix}: could not inspect ${database.kind} database ${database.path}: ${database.reason}; retry once the gateway releases it.`, |
| ), |
| OPENCLAW_DATABASE_SCHEMA_DOCS_URL, |
| "Installing manually via npm bypasses this guard; back up first and verify compatibility.", |
| ]; |
| } |
|
|
| async function checkTargetDatabaseSchemas( |
| supportedVersions: OpenClawSchemaVersions, |
| context: TargetDatabaseSchemaContext, |
| ): Promise<OpenClawDatabaseSchemaPreflight> { |
| let configuredAgentDatabaseCandidatePaths: string[]; |
| try { |
| configuredAgentDatabaseCandidatePaths = resolveConfiguredAgentDatabaseCandidatePaths( |
| context.config, |
| { env: context.env }, |
| ); |
| } catch (error) { |
| throw new UpdatePreMutationError( |
| "database-schema-preflight", |
| `Update refused: could not inspect configured database paths: ${formatErrorMessage(error)}`, |
| ); |
| } |
| return preflightOpenClawDatabaseSchemas({ |
| env: context.env, |
| supportedVersions, |
| |
| |
| configuredAgentDatabaseTargets: [], |
| |
| configuredAgentDatabaseCandidatePaths, |
| }); |
| } |
|
|
| export async function captureTargetDatabaseSchemaContext( |
| env: NodeJS.ProcessEnv, |
| options?: { legacyConfigPlan?: LegacyConfigUpdatePlan }, |
| ) { |
| |
| const inspectionEnv = cloneEnvWithPlatformSemantics(env); |
| const readEnv = cloneEnvWithPlatformSemantics(env); |
| const { snapshot, writeOptions } = await createConfigIO({ |
| env: inspectionEnv, |
| observe: false, |
| pluginValidation: "core-only", |
| }).readConfigFileSnapshotForWrite(); |
| |
| |
| |
| const planned = options?.legacyConfigPlan; |
| const before = planned?.snapshot; |
| const legacyConfigPlan = |
| before && |
| before.path === snapshot.path && |
| before.exists === snapshot.exists && |
| before.raw === snapshot.raw && |
| before.hash === snapshot.hash && |
| isDeepStrictEqual(before.includedPaths ?? [], snapshot.includedPaths ?? []) && |
| isDeepStrictEqual(before.includeProvenance ?? [], snapshot.includeProvenance ?? []) && |
| isDeepStrictEqual(before.sourceConfig, snapshot.sourceConfig) && |
| isDeepStrictEqual( |
| planned.includeIdentity.includeFileHashesForWrite ?? {}, |
| writeOptions.includeFileHashesForWrite ?? {}, |
| ) && |
| isDeepStrictEqual( |
| planned.includeIdentity.includeFileTargetsForWrite ?? {}, |
| writeOptions.includeFileTargetsForWrite ?? {}, |
| ) |
| ? planned |
| : undefined; |
| if (before?.path === snapshot.path && !legacyConfigPlan) { |
| throw new UpdatePreMutationError( |
| "database-schema-preflight", |
| `Update refused: planned configuration changed at ${snapshot.path}. Retry against the current source.`, |
| ); |
| } |
| if ((!snapshot.valid && !legacyConfigPlan) || snapshot.readError) { |
| throw new UpdatePreMutationError( |
| "invalid-config", |
| [ |
| `Update refused: configuration is invalid or unreadable at ${snapshot.path}.`, |
| ...formatConfigIssueLines( |
| |
| snapshot.issues.map(({ path: issuePath, pathSegments }) => ({ |
| path: issuePath, |
| pathSegments, |
| message: "Invalid configuration field", |
| })), |
| "-", |
| { normalizeRoot: true }, |
| ), |
| "Run `openclaw doctor --fix` to repair retired or unrecognized configuration fields, then correct any remaining errors before retrying.", |
| ].join("\n"), |
| ); |
| } |
| return { |
| env: inspectionEnv, |
| config: legacyConfigPlan?.config ?? snapshot.sourceConfig ?? snapshot.config, |
| configSnapshot: snapshot, |
| readEnv, |
| ...(legacyConfigPlan ? { legacyConfigPlan } : {}), |
| }; |
| } |
|
|
| function canonicalDatabaseIdentity(database: { kind: "agent" | "state"; path: string }): string { |
| let canonical: string; |
| try { |
| |
| |
| canonical = fs.realpathSync.native(database.path); |
| } catch { |
| canonical = path.resolve(database.path); |
| } |
| const comparable = process.platform === "win32" ? canonical.toLowerCase() : canonical; |
| return `${database.kind}\0${comparable}`; |
| } |
|
|
| |
| export async function checkTargetDatabaseSchemasForContexts( |
| supportedVersions: OpenClawSchemaVersions | undefined, |
| contexts: readonly TargetDatabaseSchemaContext[], |
| ): Promise<OpenClawDatabaseSchemaPreflight> { |
| if (!supportedVersions) { |
| return { incompatible: [], indeterminate: [] }; |
| } |
| const incompatible = new Map<string, IncompatibleOpenClawDatabase>(); |
| const indeterminate = new Map<string, IndeterminateOpenClawDatabase>(); |
| for (const context of contexts) { |
| const result = await checkTargetDatabaseSchemas(supportedVersions, context); |
| for (const database of result.incompatible) { |
| const identity = canonicalDatabaseIdentity(database); |
| incompatible.set(identity, incompatible.get(identity) ?? database); |
| indeterminate.delete(identity); |
| } |
| for (const database of result.indeterminate) { |
| const identity = canonicalDatabaseIdentity(database); |
| if (!incompatible.has(identity) && !indeterminate.has(identity)) { |
| indeterminate.set(identity, database); |
| } |
| } |
| } |
| return { incompatible: [...incompatible.values()], indeterminate: [...indeterminate.values()] }; |
| } |
|
|
| export function hasSchemaRefusal(schemas: OpenClawDatabaseSchemaPreflight): boolean { |
| return schemas.incompatible.length > 0 || schemas.indeterminate.length > 0; |
| } |
|
|