openclaw / src /cli /update-cli /schema-preflight.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
7.91 kB
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,
// Include default on-disk stores that update-time Doctor can later touch,
// without resolving configured candidates into writable migration owners.
configuredAgentDatabaseTargets: [],
// Inspection keeps registered paths without adopting their migration ownership.
configuredAgentDatabaseCandidatePaths,
});
}
export async function captureTargetDatabaseSchemaContext(
env: NodeJS.ProcessEnv,
options?: { legacyConfigPlan?: LegacyConfigUpdatePlan },
) {
// Discover stores without plugins, recovery, observations, or caller environment changes.
const inspectionEnv = cloneEnvWithPlatformSemantics(env);
const readEnv = cloneEnvWithPlatformSemantics(env);
const { snapshot, writeOptions } = await createConfigIO({
env: inspectionEnv,
observe: false,
pluginValidation: "core-only",
}).readConfigFileSnapshotForWrite();
// A Doctor-validated projection is usable only for its exact authored source.
// Keep the original snapshot intact for checkpointing and later revalidation;
// a caller's plan must not authorize a different managed service's config.
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(
// Validator messages can contain config values, including misplaced secrets.
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 {
// Native traversal must see the original locator before any lexical
// normalization: link/../file can name a different database from resolve().
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}`;
}
/** Inspect the union of caller/service stores without granting migration ownership. */
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;
}