File size: 7,909 Bytes
eb3f11e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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;
}