Reapply "refactor: move runtime state to SQLite"

This reverts commit 694ca50e97.
This commit is contained in:
Peter Steinberger
2026-05-13 13:34:30 +01:00
parent 05db911775
commit c6ee68b751
3003 changed files with 128551 additions and 130603 deletions

View File

@@ -1,4 +1,3 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -21,9 +20,7 @@ import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-ru
import { runCommandWithTimeout } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { theme } from "../../terminal/theme.js";
import { pathExists } from "../../utils.js";
import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../completion-runtime.js";
export type UpdateCommandOptions = {
json?: boolean;
@@ -279,53 +276,6 @@ export async function resolveGlobalManager(params: {
return byPresence ?? "npm";
}
const COMPLETION_CACHE_WRITE_TIMEOUT_MS = 30_000;
const COMPLETION_CACHE_MANUAL_REFRESH_HINT =
"Shell tab-completion may be stale; refresh manually with: openclaw completion --write-state";
export async function tryWriteCompletionCache(root: string, jsonMode: boolean): Promise<void> {
const binPath = path.join(root, "openclaw.mjs");
if (!(await pathExists(binPath))) {
return;
}
const result = spawnSync(resolveNodeRunner(), [binPath, "completion", "--write-state"], {
cwd: root,
env: {
...process.env,
[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]: "1",
},
encoding: "utf-8",
timeout: COMPLETION_CACHE_WRITE_TIMEOUT_MS,
});
if (result.error) {
if (!jsonMode) {
const err = result.error as NodeJS.ErrnoException;
const reason =
err.code === "ETIMEDOUT"
? `timed out after ${COMPLETION_CACHE_WRITE_TIMEOUT_MS / 1000}s`
: String(result.error);
defaultRuntime.log(
theme.warn(
`Completion cache update failed: ${reason}. ${COMPLETION_CACHE_MANUAL_REFRESH_HINT}`,
),
);
}
return;
}
if (result.status !== 0 && !jsonMode) {
const stderr = (result.stderr ?? "").trim();
const detail = stderr ? ` (${stderr})` : "";
defaultRuntime.log(
theme.warn(
`Completion cache update failed${detail}. ${COMPLETION_CACHE_MANUAL_REFRESH_HINT}`,
),
);
}
}
export function createGlobalCommandRunner(): CommandRunner {
return async (argv, options) => {
const res = await runCommandWithTimeout(argv, options);

View File

@@ -5,10 +5,7 @@ import os from "node:os";
import path from "node:path";
import { Writable } from "node:stream";
import { confirm, isCancel } from "@clack/prompts";
import {
checkShellCompletionStatus,
ensureCompletionCacheExists,
} from "../../commands/doctor-completion.js";
import { checkShellCompletionStatus } from "../../commands/doctor-completion.js";
import { doctorCommand } from "../../commands/doctor.js";
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
@@ -38,7 +35,6 @@ import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint
import { disableCurrentOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
import {
readGatewayServiceState,
resolveGatewayService,
@@ -101,7 +97,6 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { stylePromptMessage } from "../../terminal/prompt-style.js";
import { theme } from "../../terminal/theme.js";
import { resolveUserPath } from "../../utils.js";
import { VERSION } from "../../version.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import { installCompletion } from "../completion-runtime.js";
@@ -136,7 +131,6 @@ import {
resolveTargetVersion,
resolveUpdateRoot,
runUpdateStep,
tryWriteCompletionCache,
type UpdateCommandOptions,
type UpdateFinalizeOptions,
} from "./shared.js";
@@ -758,12 +752,6 @@ type PrePackageServiceStop = {
serviceEnv?: NodeJS.ProcessEnv;
};
type ManagedServiceRootRedirect = {
root: string;
previousRoot: string;
nodeRunner?: string;
};
function formatGatewayAncestryBlockMessage(pid: number): string {
return `openclaw update detected it is running inside the gateway process tree.
Gateway PID ${pid} is an ancestor of this process, so this updater cannot safely stop or restart the gateway that owns it.
@@ -983,7 +971,6 @@ function tryResolveInvocationCwd(): string | undefined {
async function resolvePackageRuntimePreflightError(params: {
tag: string;
timeoutMs?: number;
nodeRunner?: string;
}): Promise<string | null> {
if (!canResolveRegistryVersionForPackageTarget(params.tag)) {
return null;
@@ -999,45 +986,20 @@ async function resolvePackageRuntimePreflightError(params: {
if (status.error) {
return null;
}
const runtime = await resolvePackageRuntimeForPreflight({
nodeRunner: params.nodeRunner,
timeoutMs: params.timeoutMs,
});
const satisfies = nodeVersionSatisfiesEngine(runtime.version, status.nodeEngine);
const satisfies = nodeVersionSatisfiesEngine(process.versions.node ?? null, status.nodeEngine);
if (satisfies !== false) {
return null;
}
const targetLabel = status.version ?? target;
const runtimeLabel = runtime.nodeRunner
? `Node ${runtime.version ?? "unknown"} at ${runtime.nodeRunner}`
: `Node ${runtime.version ?? "unknown"}`;
return [
`${runtimeLabel} is too old for openclaw@${targetLabel}.`,
`Node ${process.versions.node ?? "unknown"} is too old for openclaw@${targetLabel}.`,
`The requested package requires ${status.nodeEngine}.`,
runtime.nodeRunner
? "Upgrade the Node runtime that owns the managed Gateway service, then rerun `openclaw update`."
: "Upgrade Node to 22.19+ or Node 24, then rerun `openclaw update`.",
"Upgrade Node to 24 or newer, then rerun `openclaw update`.",
"Bare `npm i -g openclaw` can silently install an older compatible release.",
"After upgrading Node, use `npm i -g openclaw@latest`.",
].join("\n");
}
async function resolvePackageRuntimeForPreflight(params: {
nodeRunner?: string;
timeoutMs?: number;
}): Promise<{ version: string | null; nodeRunner?: string }> {
const nodeRunner = normalizeOptionalString(params.nodeRunner);
if (!nodeRunner) {
return { version: process.versions.node ?? null };
}
const res = await runCommandWithTimeout([nodeRunner, "--version"], {
timeoutMs: Math.min(params.timeoutMs ?? 10_000, 10_000),
}).catch(() => null);
const rawVersion = res?.code === 0 ? res.stdout.trim() : "";
const version = rawVersion.replace(/^v/u, "") || null;
return { version, nodeRunner };
}
function resolveServiceRefreshEnv(
env: NodeJS.ProcessEnv,
invocationCwd?: string,
@@ -1186,7 +1148,6 @@ async function refreshGatewayServiceEnv(params: {
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
nodeRunner?: string;
}): Promise<void> {
const args = ["gateway", "install", "--force"];
if (params.jsonMode) {
@@ -1195,14 +1156,11 @@ async function refreshGatewayServiceEnv(params: {
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (entrypoint) {
const res = await runCommandWithTimeout(
[params.nodeRunner ?? resolveNodeRunner(), entrypoint, ...args],
{
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
},
);
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
return;
}
@@ -1225,7 +1183,6 @@ async function runUpdatedInstallGatewayRestart(params: {
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
nodeRunner?: string;
}): Promise<boolean> {
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (!entrypoint) {
@@ -1238,14 +1195,11 @@ async function runUpdatedInstallGatewayRestart(params: {
if (params.jsonMode) {
args.push("--json");
}
const res = await runCommandWithTimeout(
[params.nodeRunner ?? resolveNodeRunner(), entrypoint, ...args],
{
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
},
);
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
return true;
}
@@ -1264,18 +1218,11 @@ async function tryInstallShellCompletion(opts: {
const status = await checkShellCompletionStatus(CLI_NAME);
if (status.usesSlowPattern) {
defaultRuntime.log(theme.muted("Upgrading shell completion to cached version..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
if (cacheGenerated) {
await installShellCompletionForUpdate(status.shell, true);
}
return;
}
if (status.profileInstalled && !status.cacheExists) {
defaultRuntime.log(theme.muted("Regenerating shell completion cache..."));
await ensureCompletionCacheExists(CLI_NAME);
if (status.usesRetiredCache) {
defaultRuntime.log(theme.muted("Updating shell completion profile..."));
await installCompletion(status.shell, true, CLI_NAME, {
retiredCachePath: status.retiredCachePath,
});
return;
}
@@ -1299,22 +1246,7 @@ async function tryInstallShellCompletion(opts: {
return;
}
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
if (!cacheGenerated) {
defaultRuntime.log(theme.warn("Failed to generate completion cache."));
return;
}
await installShellCompletionForUpdate(status.shell, opts.skipPrompt);
}
}
async function installShellCompletionForUpdate(shell: string, yes: boolean): Promise<void> {
try {
await installCompletion(shell, yes, CLI_NAME);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.log(theme.warn(`Shell completion refresh failed: ${message}`));
await installCompletion(status.shell, opts.skipPrompt, CLI_NAME);
}
}
@@ -1326,54 +1258,9 @@ async function tryRealpathOrResolve(value: string): Promise<string> {
}
}
function isNodeExecutable(value: string | undefined): boolean {
const base = normalizeOptionalString(value ? path.basename(value) : undefined)?.toLowerCase();
return base === "node" || base === "node.exe";
}
function resolveManagedServiceNodeRunner(
command: GatewayServiceCommandConfig | null,
): string | undefined {
const args = command?.programArguments;
if (!args?.length) {
return undefined;
}
const gatewayIndex = args.indexOf("gateway");
if (gatewayIndex <= 1) {
return undefined;
}
const runner = args[gatewayIndex - 2];
return isNodeExecutable(runner) ? runner : undefined;
}
/**
* Resolve the node binary baked into the managed gateway service unit,
* independent of any package root redirect. This detects when the user's
* current PATH-resolved node differs from the service's baked node even
* when the package root is the same.
*/
async function resolveManagedServiceNodeRunnerOverride(): Promise<string | undefined> {
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
const serviceNode = resolveManagedServiceNodeRunner(command);
if (!serviceNode) {
return undefined;
}
const currentNode = resolveNodeRunner();
const [serviceNodeReal, currentNodeReal] = await Promise.all([
tryRealpathOrResolve(serviceNode),
tryRealpathOrResolve(currentNode),
]);
if (serviceNodeReal === currentNodeReal) {
return undefined;
}
return serviceNode;
}
async function resolveManagedServicePackageUpdateRoot(params: {
root: string;
}): Promise<ManagedServiceRootRedirect | null> {
}): Promise<{ root: string; previousRoot: string } | null> {
const command = await resolveGatewayService()
.readCommand(process.env)
.catch(() => null);
@@ -1389,12 +1276,7 @@ async function resolveManagedServicePackageUpdateRoot(params: {
if (currentRootReal === serviceRootReal) {
return null;
}
const nodeRunner = resolveManagedServiceNodeRunner(command);
return {
root: serviceRoot,
previousRoot: params.root,
...(nodeRunner ? { nodeRunner } : {}),
};
return { root: serviceRoot, previousRoot: params.root };
}
async function runPackageInstallUpdate(params: {
@@ -1408,7 +1290,6 @@ async function runPackageInstallUpdate(params: {
managedServiceEnv?: NodeJS.ProcessEnv;
invocationCwd?: string;
honorPackageRoot?: boolean;
nodeRunner?: string;
}): Promise<UpdateRunResult> {
const manager = await resolveGlobalManager({
root: params.root,
@@ -1474,13 +1355,7 @@ async function runPackageInstallUpdate(params: {
const candidateHostVersion = await readPackageVersion(verifiedPackageRoot);
return await runUpdateStep({
name: `${CLI_NAME} doctor`,
argv: [
params.nodeRunner ?? resolveNodeRunner(),
entryPath,
"doctor",
"--non-interactive",
"--fix",
],
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"],
cwd: verifiedPackageRoot,
env: {
...resolvePostInstallDoctorEnv({
@@ -1955,7 +1830,6 @@ async function maybeRestartService(params: {
gatewayPort: number;
restartScriptPath?: string | null;
invocationCwd?: string;
nodeRunner?: string;
}): Promise<boolean> {
const verifyRestartedGateway = async (expectedGatewayVersion: string | undefined) => {
const restartAfterStaleCleanup = async () => {
@@ -1965,7 +1839,6 @@ async function maybeRestartService(params: {
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
});
return;
}
@@ -2079,7 +1952,6 @@ async function maybeRestartService(params: {
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
});
} catch (err) {
// Always log the refresh failure so callers can detect it (issue #56772).
@@ -2128,7 +2000,6 @@ async function maybeRestartService(params: {
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
nodeRunner: params.nodeRunner,
});
} else if (
!refreshedGatewayAlreadyHealthy &&
@@ -2431,23 +2302,6 @@ function createUpdatedConfigSnapshot(
};
}
async function maybeRepairLegacyConfigForUpdateChannel(params: {
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
jsonMode: boolean;
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
if (params.configSnapshot.valid || params.configSnapshot.legacyIssues.length === 0) {
return params.configSnapshot;
}
const { repairLegacyConfigForUpdateChannel } =
await import("../../commands/doctor/legacy-config-repair.js");
const { snapshot, repaired } = await repairLegacyConfigForUpdateChannel(params);
if (!params.jsonMode && repaired) {
defaultRuntime.log(theme.muted("Migrated legacy config before changing update channel."));
}
return snapshot;
}
async function writePostCorePluginUpdateResultFile(
filePath: string | undefined,
result: PostCorePluginUpdateResult,
@@ -2722,7 +2576,6 @@ async function continuePostCoreUpdateInFreshProcess(params: {
pluginInstallRecords: Record<string, PluginInstallRecord>;
preUpdateConfig?: PreUpdateConfigRestoreInput;
updateStartedAtMs: number;
nodeRunner?: string;
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
const entryPath = await resolveGatewayInstallEntrypoint(params.root);
if (!entryPath) {
@@ -2746,13 +2599,12 @@ async function continuePostCoreUpdateInFreshProcess(params: {
const resultPath = path.join(resultDir, "plugins.json");
const installRecordsPath = path.join(resultDir, "plugin-install-records.json");
const sourceConfigPath = path.join(resultDir, "source-config.json");
const postCoreHostVersion = await readPackageVersion(params.root);
try {
await writePostCorePluginInstallRecordsFile(installRecordsPath, params.pluginInstallRecords);
await writePostCoreSourceConfigFile(sourceConfigPath, params.preUpdateConfig);
const childStdio = resolvePostCoreUpdateChildStdio();
const child = spawn(params.nodeRunner ?? resolveNodeRunner(), argv, {
const child = spawn(resolveNodeRunner(), argv, {
stdio: childStdio,
env: {
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
@@ -2765,9 +2617,6 @@ async function continuePostCoreUpdateInFreshProcess(params: {
[POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath,
[POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV]: installRecordsPath,
[POST_CORE_UPDATE_STARTED_AT_ENV]: String(params.updateStartedAtMs),
...(postCoreHostVersion === null
? {}
: { OPENCLAW_COMPATIBILITY_HOST_VERSION: postCoreHostVersion }),
...(params.preUpdateConfig
? { [POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV]: sourceConfigPath }
: {}),
@@ -2973,12 +2822,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
return;
}
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = (await readPackageVersion(root)) ?? VERSION;
let postCoreConfigSnapshot = await readConfigFileSnapshot({
skipPluginValidation: true,
suppressFutureVersionWarning: true,
});
let postCoreConfigSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
const preUpdateSourceConfig = await readPostCorePreUpdateSourceConfig({
sourceConfigPath: process.env[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV],
currentSnapshot: postCoreConfigSnapshot,
@@ -3087,54 +2931,18 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
let fallbackToLatest = false;
let packageInstallSpec: string | null = null;
let packageAlreadyCurrent = false;
let managedServiceRootRedirect: ManagedServiceRootRedirect | null = null;
// Resolved independently of the root redirect so it covers the common case
// where the package root is the same but the user's PATH-resolved node
// differs from the node baked into the managed gateway service unit.
let managedServiceNodeRunner: string | undefined;
let managedServiceRootRedirect: { root: string; previousRoot: string } | null = null;
if (updateInstallKind === "package") {
managedServiceRootRedirect = await resolveManagedServicePackageUpdateRoot({ root });
if (managedServiceRootRedirect) {
root = managedServiceRootRedirect.root;
managedServiceNodeRunner = managedServiceRootRedirect.nodeRunner;
if (!opts.json) {
defaultRuntime.log(
theme.muted(
`Targeting managed gateway service package root: ${managedServiceRootRedirect.root}`,
),
);
defaultRuntime.log(
theme.warn(
`Shell OpenClaw root differs from the managed gateway service root: ${managedServiceRootRedirect.previousRoot}`,
),
);
defaultRuntime.log(
theme.muted(
`After the update, make sure \`${CLI_NAME}\` on PATH resolves to the managed service root or reinstall the gateway service from the shell install you want to use.`,
),
);
if (managedServiceNodeRunner) {
defaultRuntime.log(
theme.muted(`Managed gateway service Node: ${managedServiceNodeRunner}`),
);
}
}
} else {
// Roots match but the node binary may still differ (e.g. user switched
// nvm/fnm/brew node after gateway install).
managedServiceNodeRunner = await resolveManagedServiceNodeRunnerOverride();
if (managedServiceNodeRunner && !opts.json) {
defaultRuntime.log(
theme.warn(
`Current Node (${resolveNodeRunner()}) differs from the managed gateway service Node (${managedServiceNodeRunner}).`,
),
);
defaultRuntime.log(
theme.muted(
`Using the managed service Node for this update so the gateway can start after the upgrade.`,
),
);
}
}
}
@@ -3201,7 +3009,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
actions.push(`Run global package manager update with spec ${packageInstallSpec ?? tag}`);
}
actions.push("Run plugin update sync after core update");
actions.push("Refresh shell completion cache (if needed)");
actions.push("Refresh shell completion profile (if needed)");
actions.push(
shouldRestart
? "Restart gateway service and run doctor checks"
@@ -3286,7 +3094,6 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
const runtimePreflightError = await resolvePackageRuntimePreflightError({
tag,
timeoutMs,
nodeRunner: managedServiceNodeRunner,
});
if (runtimePreflightError) {
defaultRuntime.error(runtimePreflightError);
@@ -3354,9 +3161,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
jsonMode: Boolean(opts.json),
managedServiceEnv: prePackageServiceStop?.serviceEnv,
invocationCwd,
honorPackageRoot:
managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined,
nodeRunner: managedServiceNodeRunner,
honorPackageRoot: managedServiceRootRedirect !== null,
})
: await runGitUpdate({
root,
@@ -3485,7 +3290,6 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
opts,
pluginInstallRecords: preUpdatePluginInstallRecords,
updateStartedAtMs: startedAt,
nodeRunner: managedServiceNodeRunner,
preUpdateConfig: configSnapshot.valid
? {
sourceConfig: configSnapshot.sourceConfig,
@@ -3594,7 +3398,6 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
}
}
await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json));
await tryInstallShellCompletion({
jsonMode: Boolean(opts.json),
skipPrompt: Boolean(opts.yes),
@@ -3615,7 +3418,6 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
gatewayPort,
restartScriptPath,
invocationCwd,
nodeRunner: managedServiceNodeRunner,
});
if (!restartOk) {
await markControlPlaneUpdateRestartSentinelFailureBestEffort({