mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(memory-host-sdk): resolve stable execPath for worker fork to survive Homebrew Node upgrades (#99318)
* fix(memory): survive Homebrew Node upgrades Co-authored-by: 袁龙辉0668001277 <yuan.longhui@xydigit.com> * test(whatsapp): isolate last-route coverage * fix(deepinfra): preserve offline model compatibility * test(memory): isolate migration cleanup lifecycle * test: stabilize aggregate extension gates * ci: retrigger pull request workflow * test: preserve inherited Node options * test(cli): tolerate cold hosted startup --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -227,7 +227,7 @@ describe("hasDeepInfraApiKey", () => {
|
||||
|
||||
describe("discoverDeepInfraModels (chat-only shim)", () => {
|
||||
it("returns static catalog in test environment", async () => {
|
||||
const models = await discoverDeepInfraModels();
|
||||
const models = await discoverDeepInfraModels({ env: { VITEST: "true" } });
|
||||
const modelIds = models.map((m) => m.id);
|
||||
const streamingUsageIncompatibleModelIds = models
|
||||
.filter((m) => !m.compat?.supportsUsageInStreaming)
|
||||
|
||||
@@ -411,11 +411,11 @@ export async function discoverDeepInfraSurfaces(options?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
agentDir?: string;
|
||||
}): Promise<DeepInfraDiscoveredCatalog> {
|
||||
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
|
||||
const env = options?.env ?? process.env;
|
||||
if (env.NODE_ENV === "test" || env.VITEST) {
|
||||
return manifestFallbackCatalog();
|
||||
}
|
||||
|
||||
const env = options?.env ?? process.env;
|
||||
const hasKey = options?.hasApiKey ?? hasDeepInfraApiKey({ env, agentDir: options?.agentDir });
|
||||
if (!hasKey) {
|
||||
return manifestFallbackCatalog();
|
||||
@@ -468,6 +468,11 @@ export async function discoverDeepInfraModels(options?: {
|
||||
agentDir?: string;
|
||||
}): Promise<ModelDefinitionConfig[]> {
|
||||
const catalog = await discoverDeepInfraSurfaces(options);
|
||||
if (!catalog.live) {
|
||||
// Keep manifest-owned chat compatibility metadata intact. The generic
|
||||
// surface projection intentionally carries only cross-surface fields.
|
||||
return DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition);
|
||||
}
|
||||
const chatModels = catalog.chat.length > 0 ? catalog.chat : [...catalog.chat, ...catalog.vlm];
|
||||
if (chatModels.length === 0) {
|
||||
// True empty (no manifest entries either) — keep behavior stable.
|
||||
|
||||
@@ -54,7 +54,8 @@ describe("Kimi implicit provider (#22409)", () => {
|
||||
headers: {
|
||||
"User-Agent": "claude-code/0.1.0",
|
||||
},
|
||||
models: [
|
||||
// Credential-aware catalog assembly may prioritize the configured default.
|
||||
models: expect.arrayContaining([
|
||||
{
|
||||
id: "kimi-for-coding",
|
||||
name: "Kimi Code",
|
||||
@@ -111,9 +112,10 @@ describe("Kimi implicit provider (#22409)", () => {
|
||||
contextWindow: 262144,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
],
|
||||
]),
|
||||
apiKey: "test-key",
|
||||
});
|
||||
expect(provider.models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("ignores retired kimi-coding provider overrides", async () => {
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import "./test-runtime-mocks.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import type { MemoryIndexManager } from "./manager.js";
|
||||
import { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js";
|
||||
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
@@ -35,7 +34,7 @@ describe("memory legacy migration cleanup", () => {
|
||||
afterEach(async () => {
|
||||
await manager?.close();
|
||||
manager = undefined;
|
||||
await closeAllMemorySearchManagers();
|
||||
await closeAllMemoryIndexManagers();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
if (originalStateDir === undefined) {
|
||||
@@ -160,11 +159,11 @@ describe("memory legacy migration cleanup", () => {
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
const cfg = createConfig({ provider: "none", vectorEnabled: false });
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
if (!result.manager) {
|
||||
throw new Error(result.error ?? "memory manager missing");
|
||||
const result = await MemoryIndexManager.get({ cfg, agentId: "main" });
|
||||
if (!result) {
|
||||
throw new Error("memory manager missing");
|
||||
}
|
||||
manager = result.manager as unknown as MemoryIndexManager;
|
||||
manager = result;
|
||||
expect(manager.status().fts?.available).toBe(true);
|
||||
expect(Reflect.get(manager, "sessionsFullRetryDirty")).toBe(false);
|
||||
|
||||
@@ -248,22 +247,25 @@ describe("memory legacy migration cleanup", () => {
|
||||
} finally {
|
||||
observerDb.close();
|
||||
}
|
||||
await closeAllMemorySearchManagers();
|
||||
manager = undefined;
|
||||
|
||||
const reloadResult = await getMemorySearchManager({
|
||||
cfg: createConfig({
|
||||
extensionPath: vectorExtensionPath,
|
||||
provider: "openai",
|
||||
vectorEnabled: true,
|
||||
}),
|
||||
agentId: "main",
|
||||
// Exercise the later vector-enabled load directly. Recreating the public
|
||||
// manager here also tests unrelated provider/cache retirement lifecycles.
|
||||
const vectorState = Reflect.get(manager, "vector") as {
|
||||
available: boolean | null;
|
||||
enabled: boolean;
|
||||
extensionPath?: string;
|
||||
};
|
||||
const vectorDb = new DatabaseSync(dbPath, { allowExtension: true });
|
||||
const managerLoaded = await loadSqliteVecExtension({
|
||||
db: vectorDb,
|
||||
extensionPath: vectorExtensionPath,
|
||||
});
|
||||
if (!reloadResult.manager) {
|
||||
throw new Error(reloadResult.error ?? "reloaded memory manager missing");
|
||||
}
|
||||
manager = reloadResult.manager as unknown as MemoryIndexManager;
|
||||
const reloadedDb = Reflect.get(manager, "db") as DatabaseSync;
|
||||
expect(managerLoaded.ok, managerLoaded.error).toBe(true);
|
||||
db.close();
|
||||
Reflect.set(manager, "db", vectorDb);
|
||||
vectorState.enabled = true;
|
||||
vectorState.available = true;
|
||||
vectorState.extensionPath = vectorExtensionPath;
|
||||
Reflect.set(manager, "vectorReady", null);
|
||||
await expect(
|
||||
(
|
||||
manager as unknown as {
|
||||
@@ -271,12 +273,14 @@ describe("memory legacy migration cleanup", () => {
|
||||
}
|
||||
).loadVectorExtension(),
|
||||
).resolves.toBe(false);
|
||||
expect(reloadedDb.prepare("SELECT vec_version() AS version").get()).toEqual({
|
||||
expect(vectorDb.prepare("SELECT vec_version() AS version").get()).toEqual({
|
||||
version: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
reloadedDb.prepare("SELECT COUNT(*) AS count FROM memory_index_chunks_vec").get(),
|
||||
).toEqual({ count: 2 });
|
||||
expect(vectorDb.prepare("SELECT COUNT(*) AS count FROM memory_index_chunks_vec").get()).toEqual(
|
||||
{
|
||||
count: 2,
|
||||
},
|
||||
);
|
||||
expect(Reflect.get(manager, "memoryFullRetryDirty")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,19 @@ import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js";
|
||||
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
|
||||
|
||||
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
const runChannelInboundEventMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ dispatched: false }) as never),
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
|
||||
"openclaw/plugin-sdk/channel-inbound",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
runChannelInboundEvent: runChannelInboundEventMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./auto-reply/monitor/last-route.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./auto-reply/monitor/last-route.js")>(
|
||||
@@ -107,6 +120,7 @@ describe("web auto-reply last-route", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
updateLastRouteInBackgroundMock.mockClear();
|
||||
runChannelInboundEventMock.mockClear();
|
||||
});
|
||||
|
||||
it("updates last-route for direct chats without senderE164", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const forkMock = vi.hoisted(() => vi.fn());
|
||||
const accessMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
@@ -12,10 +13,75 @@ vi.mock("node:child_process", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual, access: accessMock },
|
||||
access: accessMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { createLocalEmbeddingWorkerProvider } from "./embeddings-worker.js";
|
||||
|
||||
beforeEach(() => {
|
||||
forkMock.mockReset();
|
||||
accessMock.mockReset().mockRejectedValue(new Error("missing"));
|
||||
});
|
||||
|
||||
it("forks workers through a stable Homebrew Node path", async () => {
|
||||
const originalExecPath = process.execPath;
|
||||
Object.defineProperty(process, "execPath", {
|
||||
configurable: true,
|
||||
value: "/opt/homebrew/Cellar/node/26.5.0/bin/node",
|
||||
});
|
||||
accessMock.mockImplementation(async (candidate: string) => {
|
||||
if (candidate === "/opt/homebrew/opt/node/bin/node") {
|
||||
return;
|
||||
}
|
||||
throw new Error("missing");
|
||||
});
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
connected: true,
|
||||
exitCode: null as number | null,
|
||||
signalCode: null as NodeJS.Signals | null,
|
||||
disconnect: vi.fn(function (this: { connected: boolean }) {
|
||||
this.connected = false;
|
||||
}),
|
||||
kill: vi.fn(function (this: EventEmitter, signal: NodeJS.Signals) {
|
||||
queueMicrotask(() => this.emit("close", null, signal));
|
||||
return true;
|
||||
}),
|
||||
send: vi.fn(function (
|
||||
this: EventEmitter,
|
||||
message: { id: number },
|
||||
callback: (err?: Error | null) => void,
|
||||
) {
|
||||
callback();
|
||||
queueMicrotask(() => this.emit("message", { id: message.id, ok: true }));
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
forkMock.mockReturnValue(child);
|
||||
|
||||
try {
|
||||
const provider = await createLocalEmbeddingWorkerProvider(
|
||||
{ config: {} as never, provider: "local", model: "", fallback: "none" },
|
||||
{ workerScriptPath: "/mock/worker.cjs" },
|
||||
);
|
||||
|
||||
expect(forkMock).toHaveBeenCalledWith(
|
||||
"/mock/worker.cjs",
|
||||
[],
|
||||
expect.objectContaining({ execPath: "/opt/homebrew/opt/node/bin/node" }),
|
||||
);
|
||||
await expect(provider.close?.()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
Object.defineProperty(process, "execPath", {
|
||||
configurable: true,
|
||||
value: originalExecPath,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an active worker alive when a queued embedding request is aborted", async () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Memory Host SDK module implements embeddings worker behavior.
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
|
||||
import { stableHomebrewNodePathCandidates } from "@openclaw/normalization-core/stable-node-path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { DEFAULT_LOCAL_MODEL } from "./embedding-defaults.js";
|
||||
import {
|
||||
@@ -233,6 +235,18 @@ function resolveWorkerExecArgv(): string[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
async function resolveWorkerExecPath(nodePath: string): Promise<string> {
|
||||
for (const candidate of stableHomebrewNodePathCandidates(nodePath)) {
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try the next Homebrew-managed stable path.
|
||||
}
|
||||
}
|
||||
return nodePath;
|
||||
}
|
||||
|
||||
/** IPC client that serializes local embedding calls through one child process. */
|
||||
class LocalEmbeddingWorkerClient {
|
||||
private child: ChildProcess | null = null;
|
||||
@@ -243,7 +257,10 @@ class LocalEmbeddingWorkerClient {
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private lastRuntimeFacts: LocalEmbeddingRuntimeFacts | undefined;
|
||||
|
||||
constructor(private readonly scriptPath: string) {}
|
||||
constructor(
|
||||
private readonly scriptPath: string,
|
||||
private readonly execPath: string,
|
||||
) {}
|
||||
|
||||
/** Start or reuse the child worker and initialize its provider. */
|
||||
async initialize(options: EmbeddingProviderOptions): Promise<void> {
|
||||
@@ -340,6 +357,7 @@ class LocalEmbeddingWorkerClient {
|
||||
}
|
||||
|
||||
const child = fork(this.scriptPath, [], {
|
||||
execPath: this.execPath,
|
||||
execArgv: resolveWorkerExecArgv(),
|
||||
serialization: "json",
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
@@ -653,8 +671,12 @@ async function createLocalEmbeddingWorkerProviderOnce(
|
||||
): Promise<EmbeddingProvider> {
|
||||
const modelPath = normalizeOptionalString(options.local?.modelPath) || DEFAULT_LOCAL_MODEL;
|
||||
const workerOptions = serializeLocalEmbeddingOptions(options, runtimeOptions);
|
||||
// Resolve before constructing the client so worker restarts stay synchronous.
|
||||
// The stable Homebrew symlink can retarget without changing this stored path.
|
||||
const workerExecPath = await resolveWorkerExecPath(process.execPath);
|
||||
const client = new LocalEmbeddingWorkerClient(
|
||||
runtimeOptions?.workerScriptPath ?? resolveDefaultWorkerScriptPath(),
|
||||
workerExecPath,
|
||||
);
|
||||
try {
|
||||
await client.initialize(workerOptions);
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
"import": "./dist/string-normalization.mjs",
|
||||
"default": "./dist/string-normalization.mjs"
|
||||
},
|
||||
"./stable-node-path": {
|
||||
"types": "./dist/stable-node-path.d.mts",
|
||||
"import": "./dist/stable-node-path.mjs",
|
||||
"default": "./dist/stable-node-path.mjs"
|
||||
},
|
||||
"./utf16-slice": {
|
||||
"types": "./dist/utf16-slice.d.mts",
|
||||
"import": "./dist/utf16-slice.mjs",
|
||||
@@ -76,7 +81,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "1.13.9"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "./expect.js";
|
||||
|
||||
/**
|
||||
* Returns stable Homebrew paths for a versioned Cellar Node executable.
|
||||
* Availability remains caller-owned so packages can reuse the path contract
|
||||
* without importing another package's filesystem/runtime layer.
|
||||
*/
|
||||
export function stableHomebrewNodePathCandidates(nodePath: string): string[] {
|
||||
const cellarMatch = nodePath.match(
|
||||
/^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/,
|
||||
);
|
||||
if (!cellarMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prefix = expectDefined(cellarMatch[1], "cellar match capture group 1");
|
||||
const formula = expectDefined(cellarMatch[2], "cellar match capture group 2");
|
||||
const pathModule = nodePath.includes("\\") ? path.win32 : path.posix;
|
||||
const candidates = [pathModule.join(prefix, "opt", formula, "bin", "node")];
|
||||
if (formula === "node") {
|
||||
candidates.push(pathModule.join(prefix, "bin", "node"));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
applyDefaultMultiSpecVitestCachePaths,
|
||||
applyDefaultVitestNoOutputTimeout,
|
||||
applyFullExtensionsHeapBudget,
|
||||
applyParallelVitestCachePaths,
|
||||
buildFullSuiteVitestRunPlans,
|
||||
createVitestPreflightPnpmArgs,
|
||||
@@ -299,7 +300,12 @@ async function main() {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const runSpecs = applyDefaultMultiSpecVitestCachePaths(
|
||||
applyDefaultVitestNoOutputTimeout(rawRunSpecs, { env: baseEnv }),
|
||||
applyDefaultVitestNoOutputTimeout(
|
||||
applyFullExtensionsHeapBudget(rawRunSpecs, { env: baseEnv }),
|
||||
{
|
||||
env: baseEnv,
|
||||
},
|
||||
),
|
||||
{ cwd: process.cwd(), env: baseEnv },
|
||||
);
|
||||
|
||||
|
||||
@@ -76,6 +76,11 @@ export function resolveParallelFullSuiteConcurrency(
|
||||
hostInfo?: VitestHostInfo,
|
||||
): number;
|
||||
|
||||
export function applyFullExtensionsHeapBudget<T extends { config: string; env: NodeJS.ProcessEnv }>(
|
||||
specs: T[],
|
||||
params?: { env?: Record<string, string | undefined> },
|
||||
): Array<Omit<T, "env"> & { env: NodeJS.ProcessEnv }>;
|
||||
|
||||
export function resolveChangedTargetArgs(
|
||||
args: string[],
|
||||
cwd?: string,
|
||||
|
||||
@@ -4850,6 +4850,45 @@ function hasConservativeVitestWorkerBudget(env) {
|
||||
return workerBudget !== null && workerBudget <= 1;
|
||||
}
|
||||
|
||||
const FULL_EXTENSIONS_CONFIG = "test/vitest/vitest.full-extensions.config.ts";
|
||||
const FULL_EXTENSIONS_MIN_HEAP_MB = 8192;
|
||||
|
||||
function ensureMaxOldSpaceSize(nodeOptions, minimumMb) {
|
||||
const normalized = nodeOptions?.trim() ?? "";
|
||||
const matches = Array.from(
|
||||
normalized.matchAll(/(^|\s)--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)(?=\s|$)/gu),
|
||||
);
|
||||
const match = matches.at(-1);
|
||||
if (!match) {
|
||||
return [normalized, `--max-old-space-size=${minimumMb}`].filter(Boolean).join(" ");
|
||||
}
|
||||
const currentMb = Number(match[2]);
|
||||
if (Number.isSafeInteger(currentMb) && currentMb >= minimumMb) {
|
||||
return normalized;
|
||||
}
|
||||
const start = match.index;
|
||||
const replacement = match[0].replace(/\d+$/u, String(minimumMb));
|
||||
return `${normalized.slice(0, start)}${replacement}${normalized.slice(start + match[0].length)}`;
|
||||
}
|
||||
|
||||
export function applyFullExtensionsHeapBudget(specs, params = {}) {
|
||||
const baseEnv = params.env ?? {};
|
||||
return specs.map((spec) =>
|
||||
spec.config === FULL_EXTENSIONS_CONFIG
|
||||
? {
|
||||
...spec,
|
||||
env: {
|
||||
...spec.env,
|
||||
NODE_OPTIONS: ensureMaxOldSpaceSize(
|
||||
spec.env?.NODE_OPTIONS ?? baseEnv.NODE_OPTIONS,
|
||||
FULL_EXTENSIONS_MIN_HEAP_MB,
|
||||
),
|
||||
},
|
||||
}
|
||||
: spec,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveParallelFullSuiteConcurrency(specCount, envInput, hostInfo) {
|
||||
let env = envInput;
|
||||
env ??= process.env;
|
||||
|
||||
@@ -13,8 +13,9 @@ import { registerSubCliByName } from "./program/register.subclis.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
// Fork CI uses shared hosted runners where cold TSX startup can exceed 45 seconds.
|
||||
const CHILD_PROCESS_TIMEOUT_MS = 75_000;
|
||||
// This is a deadlock guard, not a startup SLO. Fork CI can take over a minute
|
||||
// to cold-load the CLI graph on shared hosted runners, while still exiting correctly.
|
||||
const CHILD_PROCESS_TIMEOUT_MS = 120_000;
|
||||
const LAZY_GROUP_HELP_CASES = [
|
||||
{ group: "backup", usageCommand: "backup", registry: "core" },
|
||||
{ group: "capability", usageCommand: "infer|capability", registry: "subcli" },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Resolves Homebrew Node binary paths to stable symlink targets.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { stableHomebrewNodePathCandidates } from "@openclaw/normalization-core/stable-node-path";
|
||||
|
||||
/**
|
||||
* Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node)
|
||||
@@ -11,35 +10,13 @@ import { expectDefined } from "@openclaw/normalization-core";
|
||||
* - Versioned formula "node@22": <prefix>/opt/node@22/bin/node (keg-only)
|
||||
*/
|
||||
export async function resolveStableNodePath(nodePath: string): Promise<string> {
|
||||
const cellarMatch = nodePath.match(
|
||||
/^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/,
|
||||
);
|
||||
if (!cellarMatch) {
|
||||
return nodePath;
|
||||
}
|
||||
const prefix = expectDefined(cellarMatch[1], "cellar match capture group 1"); // e.g. /opt/homebrew
|
||||
const formula = expectDefined(cellarMatch[2], "cellar match capture group 2"); // e.g. "node" or "node@22"
|
||||
const pathModule = nodePath.includes("\\") ? path.win32 : path.posix;
|
||||
|
||||
// Try the Homebrew opt symlink first — works for both default and versioned formulas.
|
||||
const optPath = pathModule.join(prefix, "opt", formula, "bin", "node");
|
||||
try {
|
||||
await fs.access(optPath);
|
||||
return optPath;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
// For the default "node" formula, also try the direct bin symlink.
|
||||
if (formula === "node") {
|
||||
const binPath = pathModule.join(prefix, "bin", "node");
|
||||
for (const candidate of stableHomebrewNodePathCandidates(nodePath)) {
|
||||
try {
|
||||
await fs.access(binPath);
|
||||
return binPath;
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// fall through
|
||||
// Try the next Homebrew-managed stable path.
|
||||
}
|
||||
}
|
||||
|
||||
return nodePath;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS,
|
||||
applyDefaultMultiSpecVitestCachePaths,
|
||||
applyDefaultVitestNoOutputTimeout,
|
||||
applyFullExtensionsHeapBudget,
|
||||
applyParallelVitestCachePaths,
|
||||
buildFullSuiteVitestRunPlans,
|
||||
buildVitestArgs,
|
||||
@@ -4640,6 +4641,57 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives only the aggregate extension shard an 8 GiB heap floor", () => {
|
||||
const specs = applyFullExtensionsHeapBudget([
|
||||
{
|
||||
config: "test/vitest/vitest.full-extensions.config.ts",
|
||||
env: { NODE_OPTIONS: "--trace-warnings --max-old-space-size=4096" },
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.full-core-runtime.config.ts",
|
||||
env: { NODE_OPTIONS: "--max-old-space-size=4096" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(specs[0]?.env.NODE_OPTIONS).toBe("--trace-warnings --max-old-space-size=8192");
|
||||
expect(specs[1]?.env.NODE_OPTIONS).toBe("--max-old-space-size=4096");
|
||||
});
|
||||
|
||||
it("preserves a larger aggregate extension heap override", () => {
|
||||
const specs = applyFullExtensionsHeapBudget([
|
||||
{
|
||||
config: "test/vitest/vitest.full-extensions.config.ts",
|
||||
env: { NODE_OPTIONS: "--max_old_space_size 12288 --trace-warnings" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(specs[0]?.env.NODE_OPTIONS).toBe("--max_old_space_size 12288 --trace-warnings");
|
||||
});
|
||||
|
||||
it("preserves inherited Node options when the spec has no override", () => {
|
||||
const specs = applyFullExtensionsHeapBudget(
|
||||
[{ config: "test/vitest/vitest.full-extensions.config.ts", env: {} }],
|
||||
{
|
||||
env: {
|
||||
NODE_OPTIONS: "--require ./test-hook.cjs --max-old-space-size=12288",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(specs[0]?.env.NODE_OPTIONS).toBe("--require ./test-hook.cjs --max-old-space-size=12288");
|
||||
});
|
||||
|
||||
it("raises the effective last aggregate extension heap override", () => {
|
||||
const specs = applyFullExtensionsHeapBudget([
|
||||
{
|
||||
config: "test/vitest/vitest.full-extensions.config.ts",
|
||||
env: { NODE_OPTIONS: "--max-old-space-size=12288 --max_old_space_size=4096" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(specs[0]?.env.NODE_OPTIONS).toBe("--max-old-space-size=12288 --max_old_space_size=8192");
|
||||
});
|
||||
|
||||
it("keeps explicit parallel overrides ahead of the host-aware profile", () => {
|
||||
expect(
|
||||
resolveParallelFullSuiteConcurrency(
|
||||
|
||||
@@ -482,6 +482,16 @@ export const sharedVitestConfig = {
|
||||
find: "@openclaw/normalization-core/result",
|
||||
replacement: path.join(repoRoot, "packages", "normalization-core", "src", "result.ts"),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/normalization-core/stable-node-path",
|
||||
replacement: path.join(
|
||||
repoRoot,
|
||||
"packages",
|
||||
"normalization-core",
|
||||
"src",
|
||||
"stable-node-path.ts",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: "@openclaw/normalization-core/string-coerce",
|
||||
replacement: path.join(
|
||||
|
||||
@@ -164,6 +164,9 @@
|
||||
"@openclaw/normalization-core/result": [
|
||||
"./packages/normalization-core/src/result.ts"
|
||||
],
|
||||
"@openclaw/normalization-core/stable-node-path": [
|
||||
"./packages/normalization-core/src/stable-node-path.ts"
|
||||
],
|
||||
"@openclaw/normalization-core/string-coerce": [
|
||||
"./packages/normalization-core/src/string-coerce.ts"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user