fix: avoid full runtime dependency restaging

This commit is contained in:
Peter Steinberger
2026-04-28 00:15:10 +01:00
parent d462d1faf2
commit 62f8cff33a
4 changed files with 184 additions and 16 deletions

View File

@@ -445,6 +445,10 @@ describe("installBundledRuntimeDeps", () => {
expect(JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))).toEqual({
name: "openclaw-runtime-deps-install",
private: true,
dependencies: {
"@grammyjs/runner": "^2.0.3",
grammy: "1.37.0",
},
});
writeInstalledPackage(cwd, "@grammyjs/runner", "2.0.3");
return {
@@ -460,6 +464,7 @@ describe("installBundledRuntimeDeps", () => {
installBundledRuntimeDeps({
installRoot,
missingSpecs: ["@grammyjs/runner@^2.0.3"],
installSpecs: ["@grammyjs/runner@^2.0.3", "grammy@1.37.0"],
env: {
HOME: parentRoot,
},
@@ -467,13 +472,47 @@ describe("installBundledRuntimeDeps", () => {
expect(spawnSyncMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.not.arrayContaining(["grammy@1.37.0"]),
expect.objectContaining({
cwd: installRoot,
}),
);
});
it("repairs external install roots by installing only missing specs while retaining staged deps", async () => {
const installRoot = makeTempDir();
writeInstalledPackage(installRoot, "alpha-runtime", "1.0.0");
spawnMock.mockImplementation((_command, args, options) => {
const cwd = String(options?.cwd ?? "");
expect(args.slice(-3)).toEqual(["install", "--ignore-scripts", "beta-runtime@2.0.0"]);
expect(JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))).toEqual({
name: "openclaw-runtime-deps-install",
private: true,
dependencies: {
"alpha-runtime": "1.0.0",
"beta-runtime": "2.0.0",
},
});
writeInstalledPackage(cwd, "beta-runtime", "2.0.0");
const child = new EventEmitter() as ReturnType<typeof spawn>;
Object.assign(child, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
queueMicrotask(() => child.emit("close", 0, null));
return child;
});
await repairBundledRuntimeDepsInstallRootAsync({
installRoot,
missingSpecs: ["beta-runtime@2.0.0"],
installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"],
env: {},
});
expect(spawnMock).toHaveBeenCalledOnce();
});
it("warns but still installs bundled runtime deps when disk space looks low", () => {
const installRoot = makeTempDir();
const warn = vi.fn();
@@ -541,6 +580,9 @@ describe("installBundledRuntimeDeps", () => {
).toEqual({
name: "openclaw-runtime-deps-install",
private: true,
dependencies: {
tokenjuice: "0.6.1",
},
});
expect(
JSON.parse(
@@ -562,6 +604,60 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("installs the full retained set when plugin-root staging replaces node_modules", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
"alpha-runtime": "1.0.0",
"beta-runtime": "2.0.0",
},
}),
);
writeInstalledPackage(pluginRoot, "alpha-runtime", "1.0.0");
spawnSyncMock.mockImplementation((_command, args, options) => {
const cwd = String(options?.cwd ?? "");
expect(cwd).toBe(path.join(pluginRoot, ".openclaw-install-stage"));
expect((args ?? []).slice(-4)).toEqual([
"install",
"--ignore-scripts",
"alpha-runtime@1.0.0",
"beta-runtime@2.0.0",
]);
writeInstalledPackage(cwd, "alpha-runtime", "1.0.0");
writeInstalledPackage(cwd, "beta-runtime", "2.0.0");
return {
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
};
});
expect(
ensureBundledPluginRuntimeDeps({
env: {},
pluginId: "local-plugin",
pluginRoot,
}),
).toEqual({
installedSpecs: ["beta-runtime@2.0.0"],
retainSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"],
});
expect(spawnSyncMock).toHaveBeenCalledOnce();
expect(
JSON.parse(
fs.readFileSync(
path.join(pluginRoot, "node_modules", "alpha-runtime", "package.json"),
"utf8",
),
),
).toEqual({ name: "alpha-runtime", version: "1.0.0" });
});
it("uses an OpenClaw-owned npm cache for runtime dependency installs", () => {
const installRoot = makeTempDir();
spawnSyncMock.mockImplementation((_command, _args, options) => {
@@ -595,6 +691,9 @@ describe("installBundledRuntimeDeps", () => {
expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({
name: "openclaw-runtime-deps-install",
private: true,
dependencies: {
tokenjuice: "0.6.1",
},
});
expect(spawnSyncMock).toHaveBeenCalledWith(
expect.any(String),

View File

@@ -1757,16 +1757,33 @@ function shouldCleanBundledRuntimeDepsInstallExecutionRoot(params: {
return installExecutionRoot.startsWith(`${installRoot}${path.sep}`);
}
function ensureNpmInstallExecutionManifest(installExecutionRoot: string): void {
function createNpmInstallExecutionManifest(installSpecs: readonly string[]): JsonObject {
const dependencies: Record<string, string> = {};
for (const spec of installSpecs) {
const dep = parseInstallableRuntimeDepSpec(spec);
dependencies[dep.name] = dep.version;
}
const sortedDependencies = Object.fromEntries(
Object.entries(dependencies).toSorted(([left], [right]) => left.localeCompare(right)),
);
return {
name: "openclaw-runtime-deps-install",
private: true,
...(Object.keys(sortedDependencies).length > 0 ? { dependencies: sortedDependencies } : {}),
};
}
function ensureNpmInstallExecutionManifest(
installExecutionRoot: string,
installSpecs: readonly string[] = [],
): void {
const manifestPath = path.join(installExecutionRoot, "package.json");
if (fs.existsSync(manifestPath)) {
const manifest = createNpmInstallExecutionManifest(installSpecs);
const nextContents = `${JSON.stringify(manifest, null, 2)}\n`;
if (fs.existsSync(manifestPath) && fs.readFileSync(manifestPath, "utf8") === nextContents) {
return;
}
fs.writeFileSync(
manifestPath,
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
fs.writeFileSync(manifestPath, nextContents, "utf8");
}
function formatBundledRuntimeDepsInstallError(result: {
@@ -1885,6 +1902,7 @@ export function installBundledRuntimeDeps(params: {
installExecutionRoot?: string;
linkNodeModulesFromExecutionRoot?: boolean;
missingSpecs: string[];
installSpecs?: string[];
env: NodeJS.ProcessEnv;
warn?: (message: string) => void;
}): void {
@@ -1911,7 +1929,15 @@ export function installBundledRuntimeDeps(params: {
// doctor repair path installs directly in the external stage dir; without a
// manifest, npm can honor a user's global prefix config and write under
// $HOME/node_modules instead of our managed stage.
ensureNpmInstallExecutionManifest(installExecutionRoot);
//
// The manifest also declares retained staged deps. npm may prune packages
// that are present in node_modules but absent from package dependencies
// while installing a new explicit spec, so keep retained deps in the
// manifest and pass only actually missing specs as install args.
ensureNpmInstallExecutionManifest(
installExecutionRoot,
params.installSpecs ?? params.missingSpecs,
);
const installEnv = createBundledRuntimeDepsInstallEnv(params.env, {
cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"),
});
@@ -1955,6 +1981,7 @@ export async function installBundledRuntimeDepsAsync(params: {
installExecutionRoot?: string;
linkNodeModulesFromExecutionRoot?: boolean;
missingSpecs: string[];
installSpecs?: string[];
env: NodeJS.ProcessEnv;
warn?: (message: string) => void;
onProgress?: (message: string) => void;
@@ -1978,7 +2005,10 @@ export async function installBundledRuntimeDepsAsync(params: {
if (diskWarning) {
params.warn?.(diskWarning);
}
ensureNpmInstallExecutionManifest(installExecutionRoot);
ensureNpmInstallExecutionManifest(
installExecutionRoot,
params.installSpecs ?? params.missingSpecs,
);
const installEnv = createBundledRuntimeDepsInstallEnv(params.env, {
cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"),
});
@@ -2035,7 +2065,8 @@ export function repairBundledRuntimeDepsInstallRoot(params: {
((installParams) =>
installBundledRuntimeDeps({
installRoot: installParams.installRoot,
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
missingSpecs: installParams.missingSpecs,
installSpecs: installParams.installSpecs,
env: params.env,
warn: params.warn,
}));
@@ -2129,7 +2160,8 @@ export async function repairBundledRuntimeDepsInstallRootAsync(params: {
((installParams) =>
installBundledRuntimeDepsAsync({
installRoot: installParams.installRoot,
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
missingSpecs: installParams.missingSpecs,
installSpecs: installParams.installSpecs,
env: params.env,
warn: params.warn,
onProgress: params.onProgress,
@@ -2268,14 +2300,22 @@ export function ensureBundledPluginRuntimeDeps(params: {
const install =
params.installDeps ??
((installParams) =>
installBundledRuntimeDeps({
((installParams) => {
const isolatedExecutionRoot =
installParams.installExecutionRoot &&
path.resolve(installParams.installExecutionRoot) !==
path.resolve(installParams.installRoot);
return installBundledRuntimeDeps({
installRoot: installParams.installRoot,
installExecutionRoot: installParams.installExecutionRoot,
linkNodeModulesFromExecutionRoot: installParams.linkNodeModulesFromExecutionRoot,
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
missingSpecs: isolatedExecutionRoot
? (installParams.installSpecs ?? installParams.missingSpecs)
: installParams.missingSpecs,
installSpecs: installParams.installSpecs,
env: params.env,
}));
});
});
const finishActivity = beginBundledRuntimeDepsInstall({
installRoot,
missingSpecs,