Skip to content

Commit b1c3f83

Browse files
authored
fix: speed up openclaw-runtime postinstall with caching (#318)
* fix: speed up openclaw-runtime postinstall with caching * fix: harden openclaw-runtime install fallback
1 parent 8111888 commit b1c3f83

9 files changed

Lines changed: 270 additions & 30 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ test-results/
2626
playwright-report/
2727
.worktrees/
2828
*.p8
29+
openclaw-runtime/.postinstall-cache.json
2930
apps/desktop/release/
3031
apps/desktop/.dist-runtime/
3132
apps/desktop/.cache/

openclaw-runtime/clean-node-modules.mjs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
1-
import { access, rm } from "node:fs/promises";
1+
import { rm } from "node:fs/promises";
22
import path from "node:path";
33
import { fileURLToPath } from "node:url";
4+
import { exists } from "./utils.mjs";
45

56
const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
67
const nodeModulesDir = path.join(runtimeDir, "node_modules");
78
const isDryRun = process.argv.includes("--dry-run");
89

9-
async function exists(targetPath) {
10-
try {
11-
await access(targetPath);
12-
return true;
13-
} catch {
14-
return false;
15-
}
16-
}
17-
1810
if (!(await exists(nodeModulesDir))) {
1911
console.log("node_modules does not exist, nothing to clean.");
2012
process.exit(0);

openclaw-runtime/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
"packageManager": "npm@11",
66
"scripts": {
77
"clean": "node ./clean-node-modules.mjs",
8-
"install:full": "npm install --no-package-lock",
9-
"install:pruned": "npm install --no-package-lock && node ./prune-runtime.mjs",
8+
"install:cached": "node ./postinstall.mjs",
9+
"install:full": "npm install --no-audit --no-fund --prefer-offline",
10+
"install:pruned": "npm install --no-audit --no-fund --prefer-offline && node ./prune-runtime.mjs",
1011
"refresh-lock": "npm install && node ./prune-runtime.mjs"
1112
},
1213
"dependencies": {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createHash } from "node:crypto";
2+
import { readFile } from "node:fs/promises";
3+
import path from "node:path";
4+
import { exists } from "./utils.mjs";
5+
6+
export const cacheInputs = [
7+
"package.json",
8+
"package-lock.json",
9+
"clean-node-modules.mjs",
10+
"postinstall.mjs",
11+
"postinstall-cache.mjs",
12+
"prune-runtime.mjs",
13+
"prune-runtime-paths.mjs",
14+
"utils.mjs",
15+
];
16+
17+
export async function computeFingerprint(runtimeDir) {
18+
const hash = createHash("sha256");
19+
hash.update(process.version);
20+
hash.update("\0");
21+
22+
for (const relativePath of cacheInputs) {
23+
const absolutePath = path.join(runtimeDir, relativePath);
24+
hash.update(relativePath);
25+
hash.update("\0");
26+
27+
if (await exists(absolutePath)) {
28+
hash.update(await readFile(absolutePath));
29+
} else {
30+
hash.update("<missing>");
31+
}
32+
33+
hash.update("\0");
34+
}
35+
36+
return hash.digest("hex");
37+
}

openclaw-runtime/postinstall.mjs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { spawn } from "node:child_process";
2+
import { readFile, writeFile } from "node:fs/promises";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import { computeFingerprint } from "./postinstall-cache.mjs";
6+
import { exists } from "./utils.mjs";
7+
8+
const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
9+
const nodeModulesDir = path.join(runtimeDir, "node_modules");
10+
const cacheFileName = ".postinstall-cache.json";
11+
const cacheFilePath = path.join(runtimeDir, cacheFileName);
12+
const lockfilePath = path.join(runtimeDir, "package-lock.json");
13+
14+
async function readCachedFingerprint() {
15+
if (!(await exists(cacheFilePath))) {
16+
return null;
17+
}
18+
19+
try {
20+
const content = await readFile(cacheFilePath, "utf8");
21+
const parsed = JSON.parse(content);
22+
return typeof parsed.fingerprint === "string" ? parsed.fingerprint : null;
23+
} catch {
24+
return null;
25+
}
26+
}
27+
28+
async function run(command, args) {
29+
await new Promise((resolve, reject) => {
30+
const child = spawn(command, args, {
31+
cwd: runtimeDir,
32+
stdio: "inherit",
33+
});
34+
35+
child.on("error", reject);
36+
child.on("exit", (code) => {
37+
if (code === 0) {
38+
resolve();
39+
return;
40+
}
41+
42+
reject(
43+
new Error(
44+
`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`,
45+
),
46+
);
47+
});
48+
});
49+
}
50+
51+
async function installRuntime() {
52+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
53+
54+
if (await exists(lockfilePath)) {
55+
try {
56+
await run(npmCommand, ["ci", "--no-audit", "--no-fund"]);
57+
return;
58+
} catch (error) {
59+
console.warn(
60+
"openclaw-runtime npm ci failed, falling back to npm install --prefer-offline.",
61+
);
62+
console.warn(error instanceof Error ? error.message : String(error));
63+
}
64+
}
65+
66+
await run(npmCommand, [
67+
"install",
68+
"--no-audit",
69+
"--no-fund",
70+
"--prefer-offline",
71+
]);
72+
}
73+
74+
try {
75+
const fingerprint = await computeFingerprint(runtimeDir);
76+
const cachedFingerprint = await readCachedFingerprint();
77+
const hasNodeModules = await exists(nodeModulesDir);
78+
79+
if (hasNodeModules && cachedFingerprint === fingerprint) {
80+
console.log("openclaw-runtime unchanged, skipping install:pruned.");
81+
process.exit(0);
82+
}
83+
84+
if (!hasNodeModules) {
85+
console.log(
86+
"openclaw-runtime node_modules missing, running install:pruned.",
87+
);
88+
} else if (cachedFingerprint === null) {
89+
console.log("openclaw-runtime cache missing, running install:pruned.");
90+
} else {
91+
console.log("openclaw-runtime inputs changed, running install:pruned.");
92+
}
93+
94+
await installRuntime();
95+
await run(process.execPath, ["./prune-runtime.mjs"]);
96+
97+
await writeFile(
98+
cacheFilePath,
99+
`${JSON.stringify(
100+
{
101+
fingerprint,
102+
updatedAt: new Date().toISOString(),
103+
},
104+
null,
105+
2,
106+
)}\n`,
107+
"utf8",
108+
);
109+
110+
console.log("openclaw-runtime cache updated.");
111+
} catch (error) {
112+
console.error("openclaw-runtime postinstall failed.");
113+
throw error;
114+
}

openclaw-runtime/prune-runtime.mjs

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,45 @@ if (pruneTargets.length === 0) {
2222

2323
let removedCount = 0;
2424

25-
for (const relativePath of pruneTargets) {
26-
const absolutePath = path.resolve(runtimeDir, relativePath);
27-
const relativeDisplayPath = path.relative(runtimeDir, absolutePath) || ".";
28-
29-
if (!absolutePath.startsWith(runtimeDir)) {
30-
throw new Error(
31-
`Refusing to prune outside runtime directory: ${relativePath}`,
32-
);
33-
}
25+
// Keep pruneTargets free of overlapping parent/child paths. This parallel removal
26+
// is safe for the current list because each target is independent.
27+
const pruneResults = await Promise.all(
28+
pruneTargets.map(async (relativePath) => {
29+
const absolutePath = path.resolve(runtimeDir, relativePath);
30+
const relativeDisplayPath = path.relative(runtimeDir, absolutePath) || ".";
31+
32+
if (!absolutePath.startsWith(runtimeDir)) {
33+
throw new Error(
34+
`Refusing to prune outside runtime directory: ${relativePath}`,
35+
);
36+
}
37+
38+
if (!(await exists(absolutePath))) {
39+
return { action: "skip", relativeDisplayPath };
40+
}
41+
42+
if (isDryRun) {
43+
return { action: "dry-run", relativeDisplayPath };
44+
}
45+
46+
await rm(absolutePath, { recursive: true, force: true });
47+
return { action: "removed", relativeDisplayPath };
48+
}),
49+
);
3450

35-
if (!(await exists(absolutePath))) {
36-
console.log(`Skip missing ${relativeDisplayPath}`);
51+
for (const result of pruneResults) {
52+
if (result.action === "skip") {
53+
console.log(`Skip missing ${result.relativeDisplayPath}`);
3754
continue;
3855
}
3956

40-
if (isDryRun) {
41-
console.log(`Would remove ${relativeDisplayPath}`);
57+
if (result.action === "dry-run") {
58+
console.log(`Would remove ${result.relativeDisplayPath}`);
4259
removedCount += 1;
4360
continue;
4461
}
4562

46-
await rm(absolutePath, { recursive: true, force: true });
47-
console.log(`Removed ${relativeDisplayPath}`);
63+
console.log(`Removed ${result.relativeDisplayPath}`);
4864
removedCount += 1;
4965
}
5066

openclaw-runtime/utils.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { access } from "node:fs/promises";
2+
3+
export async function exists(targetPath) {
4+
try {
5+
await access(targetPath);
6+
return true;
7+
} catch {
8+
return false;
9+
}
10+
}

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@
1414
"format": "biome check --write .",
1515
"test": "vitest run",
1616
"generate-types": "pnpm --filter @nexu/controller generate-openapi && pnpm --filter @nexu/web generate-sdk",
17-
"openclaw-runtime:install": "npm --prefix ./openclaw-runtime run install:pruned",
17+
"openclaw-runtime:install": "npm --prefix ./openclaw-runtime run install:cached",
1818
"openclaw-runtime:refresh-lock": "npm --prefix ./openclaw-runtime run refresh-lock",
1919
"openclaw-runtime:reinstall": "npm --prefix ./openclaw-runtime run clean && npm --prefix ./openclaw-runtime run install:pruned",
20-
"preinstall": "npm --prefix ./openclaw-runtime run clean",
21-
"postinstall": "npm --prefix ./openclaw-runtime run install:pruned",
20+
"postinstall": "npm --prefix ./openclaw-runtime run install:cached",
2221
"prepare": "cp scripts/pre-commit .git/hooks/pre-commit 2>/dev/null; chmod +x .git/hooks/pre-commit 2>/dev/null; true",
2322
"start": "./apps/desktop/dev.sh start",
2423
"stop": "./apps/desktop/dev.sh stop",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
cacheInputs,
7+
computeFingerprint,
8+
} from "../../openclaw-runtime/postinstall-cache.mjs";
9+
10+
const tempDirs = [] as string[];
11+
12+
async function createRuntimeFixture() {
13+
const runtimeDir = await mkdtemp(
14+
path.join(tmpdir(), "openclaw-runtime-cache-"),
15+
);
16+
tempDirs.push(runtimeDir);
17+
18+
for (const relativePath of cacheInputs) {
19+
const absolutePath = path.join(runtimeDir, relativePath);
20+
await mkdir(path.dirname(absolutePath), { recursive: true });
21+
await writeFile(absolutePath, `${relativePath}\n`, "utf8");
22+
}
23+
24+
await mkdir(path.join(runtimeDir, "node_modules"), { recursive: true });
25+
await writeFile(path.join(runtimeDir, "README.md"), "docs v1\n", "utf8");
26+
27+
return runtimeDir;
28+
}
29+
30+
afterEach(async () => {
31+
await Promise.all(
32+
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
33+
);
34+
});
35+
36+
describe("openclaw-runtime postinstall cache fingerprint", () => {
37+
it("ignores docs-only changes outside cache inputs", async () => {
38+
const runtimeDir = await createRuntimeFixture();
39+
const before = await computeFingerprint(runtimeDir);
40+
41+
await writeFile(path.join(runtimeDir, "README.md"), "docs v2\n", "utf8");
42+
43+
const after = await computeFingerprint(runtimeDir);
44+
expect(after).toBe(before);
45+
});
46+
47+
it("changes when a tracked install input changes", async () => {
48+
const runtimeDir = await createRuntimeFixture();
49+
const before = await computeFingerprint(runtimeDir);
50+
51+
await writeFile(
52+
path.join(runtimeDir, "prune-runtime-paths.mjs"),
53+
"export const pruneTargets = ['node_modules/foo'];\n",
54+
"utf8",
55+
);
56+
57+
const after = await computeFingerprint(runtimeDir);
58+
expect(after).not.toBe(before);
59+
});
60+
61+
it("changes when a tracked file goes missing", async () => {
62+
const runtimeDir = await createRuntimeFixture();
63+
const before = await computeFingerprint(runtimeDir);
64+
65+
await rm(path.join(runtimeDir, "postinstall.mjs"), { force: true });
66+
67+
const after = await computeFingerprint(runtimeDir);
68+
expect(after).not.toBe(before);
69+
});
70+
});

0 commit comments

Comments
 (0)