mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 05:31:11 +00:00
fix(cli): exit cleanly on migration refusal (#110207)
Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
This commit is contained in:
122
src/commands/doctor-config-preflight.process.test.ts
Normal file
122
src/commands/doctor-config-preflight.process.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Process regression for typed gateway startup-migration refusal and lease cleanup.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasActiveStartupMigrationLease } from "../infra/startup-migration-checkpoint.js";
|
||||
|
||||
const STARTUP_REFUSAL =
|
||||
"OpenClaw startup migrations did not complete cleanly; refusing to report the gateway ready.";
|
||||
|
||||
function seedPluginStateConflict(stateDir: string): void {
|
||||
const sharedPath = path.join(stateDir, "state", "openclaw.sqlite");
|
||||
const sidecarPath = path.join(stateDir, "plugin-state", "state.sqlite");
|
||||
fs.mkdirSync(path.dirname(sharedPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
|
||||
|
||||
const shared = new DatabaseSync(sharedPath);
|
||||
try {
|
||||
shared.exec(`
|
||||
CREATE TABLE plugin_state_entries (
|
||||
plugin_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
entry_key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
PRIMARY KEY (plugin_id, namespace, entry_key)
|
||||
);
|
||||
`);
|
||||
shared
|
||||
.prepare(`
|
||||
INSERT INTO plugin_state_entries (
|
||||
plugin_id, namespace, entry_key, value_json, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run("discord", "components", "interaction:1", '{"ok":false}', 2_000, null);
|
||||
} finally {
|
||||
shared.close();
|
||||
}
|
||||
|
||||
const sidecar = new DatabaseSync(sidecarPath);
|
||||
try {
|
||||
sidecar.exec(`
|
||||
CREATE TABLE plugin_state_entries (
|
||||
plugin_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
entry_key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
PRIMARY KEY (plugin_id, namespace, entry_key)
|
||||
);
|
||||
`);
|
||||
sidecar
|
||||
.prepare(`
|
||||
INSERT INTO plugin_state_entries (
|
||||
plugin_id, namespace, entry_key, value_json, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
// Older or equal sidecar rows can be archived; a newer divergent row must stay unresolved.
|
||||
.run("discord", "components", "interaction:1", '{"ok":true}', 3_000, null);
|
||||
} finally {
|
||||
sidecar.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway startup-migration refusal", () => {
|
||||
it("exits cleanly after reporting the refusal once and releasing its lease", async () => {
|
||||
const temporaryRoot = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), "openclaw-startup-migration-exit-"),
|
||||
);
|
||||
const root = await fs.promises.realpath(temporaryRoot);
|
||||
const stateDir = path.join(root, "state");
|
||||
const configPath = path.join(root, "openclaw.json");
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_TEST_FAST: "1",
|
||||
NO_COLOR: "1",
|
||||
};
|
||||
delete env.NODE_ENV;
|
||||
delete env.OPENCLAW_HOME;
|
||||
delete env.VITEST;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ gateway: { mode: "local", auth: { mode: "none" } } }),
|
||||
);
|
||||
seedPluginStateConflict(stateDir);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", path.resolve("src/entry.ts"), "gateway", "run", "--allow-unconfigured"],
|
||||
{
|
||||
cwd: path.resolve("."),
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
const output = `${result.stderr}\n${result.stdout}`;
|
||||
|
||||
expect(result.error, output).toBeUndefined();
|
||||
expect(result.status, output).toBe(1);
|
||||
expect(result.signal, output).toBeNull();
|
||||
expect(result.stderr).toContain(STARTUP_REFUSAL);
|
||||
expect(result.stderr.split(STARTUP_REFUSAL)).toHaveLength(2);
|
||||
expect(result.stderr).not.toContain("[openclaw] Could not start the CLI.");
|
||||
expect(hasActiveStartupMigrationLease({ env })).toBe(false);
|
||||
} finally {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type { ConfigFileSnapshot, LegacyConfigIssue } from "../config/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import type { StartupMigrationLease } from "../infra/startup-migration-checkpoint.js";
|
||||
import { ExitError } from "../runtime.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js";
|
||||
@@ -215,6 +216,12 @@ function formatStartupPluginVerificationFailure(
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function throwStartupMigrationRefusal(message: string): never {
|
||||
// ExitError bypasses entry.ts's generic failure formatter, so report the owned reason here.
|
||||
console.error(message);
|
||||
throw new ExitError(1, message);
|
||||
}
|
||||
|
||||
function throwStartupMigrationGuardRejected(): never {
|
||||
throw new Error(
|
||||
"OpenClaw startup migrations were skipped because the selected config changed during startup; refusing to report the gateway ready. Retry startup so the new config can be validated.",
|
||||
@@ -476,7 +483,7 @@ export async function runDoctorConfigPreflight(
|
||||
: new Error("OpenClaw startup migration lease heartbeat failed.");
|
||||
}
|
||||
if (startupMigrationWarnings.length > 0) {
|
||||
throw new Error(
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupMigrationFailure({
|
||||
warnings: startupMigrationWarnings,
|
||||
blockers: [],
|
||||
@@ -484,7 +491,7 @@ export async function runDoctorConfigPreflight(
|
||||
);
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
throw new Error(
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupMigrationFailure({
|
||||
warnings: [],
|
||||
blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'],
|
||||
@@ -496,7 +503,9 @@ export async function runDoctorConfigPreflight(
|
||||
env: process.env,
|
||||
});
|
||||
if (pluginVerificationDiagnostic) {
|
||||
throw new Error(formatStartupPluginVerificationFailure(pluginVerificationDiagnostic));
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupPluginVerificationFailure(pluginVerificationDiagnostic),
|
||||
);
|
||||
}
|
||||
startupCheckpoint?.recordSuccessfulStartupMigrations({
|
||||
env: startupMigrationEnv,
|
||||
|
||||
Reference in New Issue
Block a user