mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* fix(update): prevent stale runtime modules after Git updates Promote root and nested dist-runtime outputs through the existing candidate runtime transaction so activation and rollback keep every runtime generation aligned. Extend real-Git execution probes for runtime overlays and consolidate repeated fixture writes. * fix(update): complete source runtime artifacts on first hop Complete canonical plugin overlays and SDK aliases before target-owned plugin convergence when an older published updater omitted those outputs. Preserve exact online retries and legacy target contracts, and require current offline authority for changed physical runtime publication and rollback. Share artifact ownership with build/postbuild writers, join smoke cancellation, and keep each directory swap synchronous after its authority check. * fix(daemon): preserve proven systemd service absence Recognize the native systemd missing-unit-file response during strict non-loading inspection. Carry affirmative user-unit absence to the Linux absence owner, which separately excludes system-scope ownership before reporting an offline service. Keep failed manager inspection and other platforms conservative. This allows first-hop Git runtime artifact completion on CLI-only Linux installations without weakening publication authority. * fix(update): preserve canonical artifacts in smoke consumers Keep the built singleton smoke on canonical runtime preparation and verify its cleanup leaves a no-op. Release the QA fixture port reservation before packaged maintenance so offline publication can inspect the configured port. Align update routing and lifecycle fixtures with their real target identity and lease contracts while retaining consent, cleanup, and restart assertions. * test: rebalance plugin state type checks
1715 lines
55 KiB
TypeScript
1715 lines
55 KiB
TypeScript
#!/usr/bin/env node
|
|
// Development runner that rebuilds OpenClaw, runs runtime postbuild steps, and
|
|
// restarts the CLI when watched source or metadata changes.
|
|
import {
|
|
spawn,
|
|
spawnSync,
|
|
type SpawnOptions,
|
|
type SpawnSyncOptionsWithStringEncoding,
|
|
} from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { pathToFileURL } from "node:url";
|
|
import { collectSourceCheckoutPluginBuildEntries } from "./lib/bundled-plugin-build-entries.mjs";
|
|
import {
|
|
BUNDLED_PLUGIN_PATH_PREFIX,
|
|
BUNDLED_PLUGIN_ROOT_DIR,
|
|
} from "./lib/bundled-plugin-paths.mjs";
|
|
import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts";
|
|
import {
|
|
BUILD_STAMP_FILE,
|
|
RUNTIME_POSTBUILD_STAMP_FILE,
|
|
resolveGitHead,
|
|
writeRuntimePostBuildStamp as writeDistRuntimePostBuildStamp,
|
|
} from "./lib/local-build-metadata.mts";
|
|
import { sleep } from "./lib/sleep.mjs";
|
|
import {
|
|
discoverStaticExtensionAssets,
|
|
listStaticExtensionAssetSources,
|
|
shouldCopyStaticExtensionAssets,
|
|
} from "./lib/static-extension-assets.mts";
|
|
import {
|
|
extensionRestartMetadataFiles,
|
|
isBuildRelevantRunNodePath,
|
|
normalizeRunNodePath as normalizePath,
|
|
runNodeConfigFiles,
|
|
runNodeSourceRoots,
|
|
runNodeWatchedPaths,
|
|
} from "./run-node-watch-paths.mts";
|
|
import { listCoreRuntimePostBuildOutputs, runRuntimePostBuild } from "./runtime-postbuild.mts";
|
|
|
|
type RunNodeInjectedChild = {
|
|
kill?: (signal?: NodeJS.Signals) => boolean | void;
|
|
on(event: string, callback: (...args: never[]) => void): unknown;
|
|
pid?: number;
|
|
stderr?: Pick<NodeJS.ReadableStream, "on">;
|
|
stdout?: Pick<NodeJS.ReadableStream, "on">;
|
|
};
|
|
|
|
type RunNodeChild = RunNodeInjectedChild;
|
|
type RunNodeSpawn = (command: string, args: string[], options: SpawnOptions) => unknown;
|
|
type RunNodeSpawnSync = (
|
|
command: string,
|
|
args: string[],
|
|
options: SpawnSyncOptionsWithStringEncoding,
|
|
) => { error?: NodeJS.ErrnoException; status: number | null; stdout?: string | null };
|
|
type RunNodeWritable = {
|
|
isTTY?: boolean;
|
|
write(value: string | Uint8Array): unknown;
|
|
};
|
|
type RunNodeRuntimePostBuild = (
|
|
params?: Parameters<typeof runRuntimePostBuild>[0],
|
|
) => void | Promise<void>;
|
|
type RunNodeMainParams = {
|
|
spawn?: RunNodeSpawn;
|
|
spawnSync?: RunNodeSpawnSync;
|
|
fs?: typeof fs;
|
|
stderr?: RunNodeWritable;
|
|
stdout?: RunNodeWritable;
|
|
process?: NodeJS.Process;
|
|
signalProcess?: (pid: number, signal?: NodeJS.Signals | number) => boolean | void;
|
|
execPath?: string;
|
|
cwd?: string;
|
|
args?: string[];
|
|
env?: NodeJS.ProcessEnv;
|
|
runRuntimePostBuild?: RunNodeRuntimePostBuild;
|
|
platform?: NodeJS.Platform;
|
|
};
|
|
type RunNodeProgress = {
|
|
clearLine(): void;
|
|
render(): void;
|
|
stop(): void;
|
|
};
|
|
type RunNodeDeps = ReturnType<typeof createRunNodeDeps>;
|
|
type RunNodeRequirementDeps = {
|
|
buildStampPath: string;
|
|
configFiles: string[];
|
|
cwd: string;
|
|
distEntry: string;
|
|
distRoot: string;
|
|
env: NodeJS.ProcessEnv;
|
|
fs: typeof fs;
|
|
privateQaRequiredDistEntries?: string[];
|
|
sourceRoots: Array<{ name: string; path: string }>;
|
|
spawnSync: RunNodeSpawnSync;
|
|
};
|
|
type RunNodeRuntimeRequirementDeps = RunNodeRequirementDeps & {
|
|
runtimePostBuildStampPath: string;
|
|
};
|
|
type RunNodeOutputTee = {
|
|
write(chunk: string | Uint8Array): void;
|
|
close(): Promise<void>;
|
|
};
|
|
type RunNodeMutableState = {
|
|
outputTee: RunNodeOutputTee | null;
|
|
runNodeProgress: RunNodeProgress | undefined;
|
|
};
|
|
type RunNodeLogDeps = Pick<RunNodeDeps, "env" | "stderr"> &
|
|
Partial<Pick<RunNodeDeps, "outputTee" | "runNodeProgress">>;
|
|
type RunNodeLockDeps = Pick<RunNodeDeps, "cwd" | "env" | "fs" | "process" | "stderr"> & {
|
|
args: readonly string[];
|
|
};
|
|
type BundledPluginBuildEntry = ReturnType<
|
|
typeof collectSourceCheckoutPluginBuildEntries
|
|
>[number] & {
|
|
hasManifest: boolean;
|
|
};
|
|
type BuildRequirement = { shouldBuild: boolean; reason: keyof typeof BUILD_REASON_LABELS };
|
|
type RuntimePostBuildRequirement = {
|
|
shouldSync: boolean;
|
|
reason: keyof typeof RUNTIME_POSTBUILD_REASON_LABELS;
|
|
};
|
|
type SpawnedProcessResult = {
|
|
exitCode: number | null;
|
|
exitSignal: NodeJS.Signals | null;
|
|
forwardedSignal: NodeJS.Signals | null;
|
|
};
|
|
type RunNodeExit = number | NodeJS.Signals;
|
|
|
|
function asRunNodeChild(value: unknown): RunNodeChild {
|
|
if (!value || typeof value !== "object" || !("on" in value) || typeof value.on !== "function") {
|
|
throw new Error("spawn implementation returned an invalid child process");
|
|
}
|
|
return value as RunNodeChild;
|
|
}
|
|
|
|
export { runNodeWatchedPaths };
|
|
|
|
const runtimeBuildArgs = ["--import", "tsx", "scripts/build-all.mts", "qaRuntime"];
|
|
const RUN_NODE_SIGNAL_FORCE_KILL_AFTER_MS = 5_000;
|
|
|
|
const runtimePostBuildWatchedPaths = [
|
|
"scripts/check-built-plugin-control-plane-modules.mts",
|
|
"scripts/copy-bundled-plugin-metadata.mjs",
|
|
"scripts/copy-bundled-plugin-metadata.mts",
|
|
"scripts/copy-hook-metadata.ts",
|
|
"scripts/lib",
|
|
"scripts/lib/local-build-metadata.mts",
|
|
"scripts/lib/local-build-metadata-paths.mts",
|
|
"scripts/npm-runner.mts",
|
|
"scripts/runtime-postbuild-stamp.mts",
|
|
"scripts/runtime-postbuild-shared.mjs",
|
|
"scripts/runtime-postbuild.mjs",
|
|
"scripts/runtime-postbuild.mts",
|
|
"scripts/stage-bundled-plugin-runtime.mjs",
|
|
"scripts/stage-bundled-plugin-runtime.mts",
|
|
"scripts/windows-cmd-helpers.mjs",
|
|
"scripts/write-build-info.ts",
|
|
"scripts/write-official-channel-catalog.mjs",
|
|
"scripts/write-official-channel-catalog.mts",
|
|
BUNDLED_PLUGIN_ROOT_DIR,
|
|
];
|
|
const runtimePostBuildScriptPaths = new Set(
|
|
runtimePostBuildWatchedPaths.filter((entry) => entry.startsWith("scripts/")),
|
|
);
|
|
const runtimePostBuildStaticAssetPaths = new Set(listStaticExtensionAssetSources());
|
|
|
|
const statMtime = (filePath: string, fsImpl: typeof fs = fs) => {
|
|
try {
|
|
return fsImpl.statSync(filePath).mtimeMs;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const resolvePrivateQaRequiredDistEntries = (distRoot: string) => [
|
|
path.join(distRoot, "plugin-sdk", "qa-lab.js"),
|
|
path.join(distRoot, "plugin-sdk", "qa-runtime.js"),
|
|
];
|
|
const isExcludedSource = (filePath: string, sourceRoot: string, sourceRootName: string) => {
|
|
const relativePath = normalizePath(path.relative(sourceRoot, filePath));
|
|
// A basename starting with ".." still belongs to the source root.
|
|
if (relativePath === ".." || relativePath.startsWith("../")) {
|
|
return false;
|
|
}
|
|
return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath));
|
|
};
|
|
|
|
const findLatestMtime = (
|
|
dirPath: string,
|
|
shouldSkip: ((filePath: string) => boolean) | undefined,
|
|
deps: RunNodeRequirementDeps,
|
|
) => {
|
|
let latest: number | null = null;
|
|
const queue = [dirPath];
|
|
while (queue.length > 0) {
|
|
const current = queue.pop();
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
let entries;
|
|
try {
|
|
entries = deps.fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
queue.push(fullPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
if (shouldSkip?.(fullPath)) {
|
|
continue;
|
|
}
|
|
const mtime = statMtime(fullPath, deps.fs);
|
|
if (mtime == null) {
|
|
continue;
|
|
}
|
|
if (latest == null || mtime > latest) {
|
|
latest = mtime;
|
|
}
|
|
}
|
|
}
|
|
return latest;
|
|
};
|
|
|
|
const readGitStatus = (deps: RunNodeRequirementDeps, paths: string[] = runNodeWatchedPaths) => {
|
|
try {
|
|
const result = deps.spawnSync(
|
|
"git",
|
|
// NUL framing preserves filenames; separate delete/add records keep both rename sides.
|
|
[
|
|
"status",
|
|
"--porcelain=v1",
|
|
"-z",
|
|
"--no-renames",
|
|
"--untracked-files=normal",
|
|
"--",
|
|
...paths,
|
|
],
|
|
{
|
|
cwd: deps.cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
return null;
|
|
}
|
|
return result.stdout ?? "";
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const parseGitStatusPaths = (output: string) =>
|
|
output
|
|
.split("\0")
|
|
.map((entry) => entry.slice(3))
|
|
.filter(Boolean);
|
|
|
|
const hasDirtySourceTree = (deps: RunNodeRequirementDeps) => {
|
|
const output = readGitStatus(deps);
|
|
if (output === null) {
|
|
return null;
|
|
}
|
|
return parseGitStatusPaths(output).some(
|
|
(repoPath) =>
|
|
isBuildRelevantRunNodePath(repoPath) ||
|
|
isDirtyBundledPluginPackageEntryChangeWithoutBuiltOutputs(repoPath, deps),
|
|
);
|
|
};
|
|
|
|
const isRuntimePostBuildRelevantPath = (repoPath: string) => {
|
|
const normalizedPath = normalizePath(repoPath);
|
|
if (runtimePostBuildStaticAssetPaths.has(normalizedPath)) {
|
|
return true;
|
|
}
|
|
if (
|
|
normalizedPath.startsWith("scripts/") &&
|
|
(runtimePostBuildScriptPaths.has(normalizedPath) || normalizedPath.startsWith("scripts/lib/"))
|
|
) {
|
|
return true;
|
|
}
|
|
if (!normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
|
|
return false;
|
|
}
|
|
const pluginRelativePath = normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length);
|
|
const pluginLocalPath = pluginRelativePath.split("/").slice(1).join("/");
|
|
if (pluginLocalPath === "skills" || pluginLocalPath.startsWith("skills/")) {
|
|
return true;
|
|
}
|
|
return extensionRestartMetadataFiles.has(path.posix.basename(pluginRelativePath));
|
|
};
|
|
|
|
const hasDirtyRuntimePostBuildInputs = (deps: RunNodeRequirementDeps) => {
|
|
const output = readGitStatus(deps, runtimePostBuildWatchedPaths);
|
|
if (output === null) {
|
|
return null;
|
|
}
|
|
return parseGitStatusPaths(output).some((repoPath) => isRuntimePostBuildRelevantPath(repoPath));
|
|
};
|
|
|
|
const readJsonStamp = (filePath: string, deps: RunNodeRequirementDeps) => {
|
|
const mtime = statMtime(filePath, deps.fs);
|
|
if (mtime == null) {
|
|
return { mtime: null, head: null };
|
|
}
|
|
try {
|
|
const raw = deps.fs.readFileSync(filePath, "utf8").trim();
|
|
if (!raw.startsWith("{")) {
|
|
return { mtime, head: null };
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
const head = typeof parsed?.head === "string" && parsed.head.trim() ? parsed.head.trim() : null;
|
|
return { mtime, head };
|
|
} catch {
|
|
return { mtime, head: null };
|
|
}
|
|
};
|
|
|
|
const readBuildStamp = (deps: RunNodeRequirementDeps) => readJsonStamp(deps.buildStampPath, deps);
|
|
|
|
const readRuntimePostBuildStamp = (deps: RunNodeRuntimeRequirementDeps) => {
|
|
return readJsonStamp(deps.runtimePostBuildStampPath, deps);
|
|
};
|
|
|
|
const isImmutableGitDeployment = (deps: RunNodeRequirementDeps) => {
|
|
try {
|
|
const deployment = JSON.parse(
|
|
deps.fs.readFileSync(path.join(deps.cwd, "deployment.json"), "utf8"),
|
|
);
|
|
// Deployment ownership outranks source-checkout freshness. A mismatched
|
|
// checkout must fail closed instead of repairing manager-owned artifacts.
|
|
return (
|
|
deployment?.kind === "git" &&
|
|
typeof deployment.sourceHead === "string" &&
|
|
deployment.sourceHead.trim().length > 0
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const hasSourceMtimeChanged = (stampMtime: number, deps: RunNodeRequirementDeps) => {
|
|
let latestSourceMtime: number | null = null;
|
|
for (const sourceRoot of deps.sourceRoots) {
|
|
const sourceMtime = findLatestMtime(
|
|
sourceRoot.path,
|
|
(candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name),
|
|
deps,
|
|
);
|
|
if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) {
|
|
latestSourceMtime = sourceMtime;
|
|
}
|
|
}
|
|
return latestSourceMtime != null && latestSourceMtime > stampMtime;
|
|
};
|
|
|
|
const findLatestRuntimePostBuildInputMtime = (
|
|
absolutePath: string,
|
|
relativePath: string,
|
|
deps: RunNodeRequirementDeps,
|
|
) => {
|
|
const normalizedRelativePath = normalizePath(relativePath);
|
|
const statsMtime = statMtime(absolutePath, deps.fs);
|
|
if (statsMtime == null) {
|
|
return null;
|
|
}
|
|
let stat;
|
|
try {
|
|
stat = deps.fs.statSync(absolutePath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
return isRuntimePostBuildRelevantPath(normalizedRelativePath) ? statsMtime : null;
|
|
}
|
|
return findLatestMtime(
|
|
absolutePath,
|
|
(candidate) => {
|
|
const candidateRelativePath = path.relative(deps.cwd, candidate);
|
|
return !isRuntimePostBuildRelevantPath(candidateRelativePath);
|
|
},
|
|
deps,
|
|
);
|
|
};
|
|
|
|
const hasRuntimePostBuildInputMtimeChanged = (stampMtime: number, deps: RunNodeRequirementDeps) => {
|
|
let latestInputMtime: number | null = null;
|
|
for (const relativePath of runtimePostBuildWatchedPaths) {
|
|
const absolutePath = path.join(deps.cwd, relativePath);
|
|
const inputMtime = findLatestRuntimePostBuildInputMtime(absolutePath, relativePath, deps);
|
|
if (inputMtime != null && (latestInputMtime == null || inputMtime > latestInputMtime)) {
|
|
latestInputMtime = inputMtime;
|
|
}
|
|
}
|
|
return latestInputMtime != null && latestInputMtime > stampMtime;
|
|
};
|
|
|
|
const collectRunNodeBundledPluginBuildEntries = (deps: RunNodeRequirementDeps) => {
|
|
if (!deps.fs.existsSync(path.join(deps.cwd, BUNDLED_PLUGIN_ROOT_DIR))) {
|
|
return [];
|
|
}
|
|
return collectSourceCheckoutPluginBuildEntries({ cwd: deps.cwd, env: deps.env });
|
|
};
|
|
|
|
const resolveBuiltBundledPluginRuntimeEntryPath = (
|
|
distRoot: string,
|
|
pluginId: string,
|
|
sourceEntry: string,
|
|
runtimeExtension: string,
|
|
) =>
|
|
path.join(
|
|
distRoot,
|
|
"extensions",
|
|
pluginId,
|
|
sourceEntry.replace(/^\.\//, "").replace(/\.[^.]+$/u, runtimeExtension),
|
|
);
|
|
|
|
const listBundledPluginRuntimeEntryPaths = (
|
|
pluginEntry: BundledPluginBuildEntry,
|
|
deps: RunNodeRequirementDeps,
|
|
) => {
|
|
const distRoot = deps.distRoot;
|
|
return pluginEntry.sourceEntries
|
|
.map((sourceEntry) =>
|
|
resolveBuiltBundledPluginRuntimeEntryPath(
|
|
distRoot,
|
|
pluginEntry.id,
|
|
sourceEntry,
|
|
pluginEntry.runtimeExtension,
|
|
),
|
|
)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
};
|
|
|
|
const isDirtyBundledPluginPackageEntryChangeWithoutBuiltOutputs = (
|
|
normalizedPath: string,
|
|
deps: RunNodeRequirementDeps,
|
|
) => {
|
|
if (!normalizedPath.startsWith("extensions/") || !normalizedPath.endsWith("/package.json")) {
|
|
return false;
|
|
}
|
|
const [, pluginId] = normalizedPath.split("/");
|
|
if (!pluginId) {
|
|
return false;
|
|
}
|
|
const pluginEntry = collectRunNodeBundledPluginBuildEntries(deps).find(
|
|
(entry) => entry.id === pluginId,
|
|
);
|
|
if (!pluginEntry) {
|
|
return false;
|
|
}
|
|
return listBundledPluginRuntimeEntryPaths(pluginEntry, deps).some(
|
|
(filePath) => !deps.fs.existsSync(filePath),
|
|
);
|
|
};
|
|
|
|
const hasMissingBuiltBundledPluginRuntimeEntryOutput = (deps: RunNodeRequirementDeps) => {
|
|
return collectRunNodeBundledPluginBuildEntries(deps).some((pluginEntry) => {
|
|
const entryPaths = listBundledPluginRuntimeEntryPaths(pluginEntry, deps);
|
|
return entryPaths.some((filePath) => !deps.fs.existsSync(filePath));
|
|
});
|
|
};
|
|
|
|
const listBuiltBundledPluginEntries = (deps: RunNodeRequirementDeps) => {
|
|
return collectRunNodeBundledPluginBuildEntries(deps)
|
|
.filter((pluginEntry) =>
|
|
listBundledPluginRuntimeEntryPaths(pluginEntry, deps).some((filePath) =>
|
|
deps.fs.existsSync(filePath),
|
|
),
|
|
)
|
|
.toSorted((left, right) => left.id.localeCompare(right.id));
|
|
};
|
|
|
|
const listRequiredBundledPluginMetadataOutputs = (
|
|
pluginEntries: BundledPluginBuildEntry[],
|
|
deps: RunNodeRequirementDeps,
|
|
) =>
|
|
pluginEntries.flatMap(({ id, hasManifest, hasPackageJson }) => {
|
|
const builtPluginDir = path.join(deps.distRoot, "extensions", id);
|
|
const requiredPaths = [];
|
|
if (hasPackageJson) {
|
|
requiredPaths.push(path.join(builtPluginDir, "package.json"));
|
|
}
|
|
if (hasManifest) {
|
|
requiredPaths.push(path.join(builtPluginDir, "openclaw.plugin.json"));
|
|
}
|
|
return requiredPaths;
|
|
});
|
|
|
|
const hasMissingBundledPluginRuntimeOverlayOutput = (deps: RunNodeRequirementDeps) => {
|
|
const distExtensionsRoot = path.join(deps.distRoot, "extensions");
|
|
const runtimeExtensionsRoot = path.join(deps.cwd, "dist-runtime", "extensions");
|
|
const queue = [distExtensionsRoot];
|
|
while (queue.length > 0) {
|
|
const current = queue.pop();
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
let entries;
|
|
try {
|
|
entries = deps.fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name === "node_modules") {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(current, entry.name);
|
|
if (entry.isDirectory()) {
|
|
queue.push(entryPath);
|
|
continue;
|
|
}
|
|
if (current !== distExtensionsRoot && (entry.isFile() || entry.isSymbolicLink())) {
|
|
const runtimePath = path.join(
|
|
runtimeExtensionsRoot,
|
|
path.relative(distExtensionsRoot, entryPath),
|
|
);
|
|
if (statMtime(runtimePath, deps.fs) == null) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const isSafePluginSdkSubpathSegment = (subpath: string) =>
|
|
/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath);
|
|
|
|
const readPackageJsonPluginSdkAliasFileNames = (deps: RunNodeRequirementDeps) => {
|
|
let packageJson;
|
|
try {
|
|
packageJson = JSON.parse(deps.fs.readFileSync(path.join(deps.cwd, "package.json"), "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const packageExports = packageJson?.exports;
|
|
if (!packageExports || typeof packageExports !== "object" || Array.isArray(packageExports)) {
|
|
return null;
|
|
}
|
|
|
|
const fileNames = new Set<string>();
|
|
for (const exportKey of Object.keys(packageExports)) {
|
|
if (!exportKey.startsWith("./plugin-sdk/")) {
|
|
continue;
|
|
}
|
|
const subpath = exportKey.slice("./plugin-sdk/".length);
|
|
if (isSafePluginSdkSubpathSegment(subpath)) {
|
|
fileNames.add(`${subpath}.js`);
|
|
}
|
|
}
|
|
return fileNames.size > 0 ? fileNames : null;
|
|
};
|
|
|
|
const listRequiredOpenClawExtensionAliasOutputs = (deps: RunNodeRequirementDeps) => {
|
|
const distRoot = deps.distRoot;
|
|
const distExtensionsRoot = path.join(distRoot, "extensions");
|
|
if (!deps.fs.existsSync(distExtensionsRoot)) {
|
|
return [];
|
|
}
|
|
const pluginSdkDir = path.join(distRoot, "plugin-sdk");
|
|
let dirents;
|
|
try {
|
|
dirents = deps.fs.readdirSync(pluginSdkDir, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const exportedPluginSdkFileNames = readPackageJsonPluginSdkAliasFileNames(deps);
|
|
const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw");
|
|
return [
|
|
path.join(aliasDir, "package.json"),
|
|
...dirents
|
|
.filter((dirent) => dirent.isFile() && path.extname(dirent.name) === ".js")
|
|
.filter(
|
|
(dirent) => !exportedPluginSdkFileNames || exportedPluginSdkFileNames.has(dirent.name),
|
|
)
|
|
.map((dirent) => path.join(aliasDir, "plugin-sdk", dirent.name)),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
};
|
|
|
|
const listRequiredStaticExtensionAssetOutputs = (deps: RunNodeRequirementDeps) => {
|
|
if (!shouldCopyStaticExtensionAssets({ env: deps.env })) {
|
|
return [];
|
|
}
|
|
const distRoot = deps.distRoot;
|
|
const runtimeRoot = path.join(deps.cwd, "dist-runtime");
|
|
const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions");
|
|
const hasRuntimeOverlay = deps.fs.existsSync(runtimeExtensionsRoot);
|
|
return discoverStaticExtensionAssets({ rootDir: deps.cwd, fs: deps.fs })
|
|
.filter((asset) => deps.fs.existsSync(path.join(deps.cwd, asset.src)))
|
|
.flatMap((asset) => {
|
|
const relativeOutput = normalizePath(asset.dest).replace(/^dist\//u, "");
|
|
const outputs = [path.join(distRoot, relativeOutput)];
|
|
if (hasRuntimeOverlay) {
|
|
outputs.push(path.join(runtimeRoot, relativeOutput));
|
|
}
|
|
return outputs;
|
|
})
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
};
|
|
|
|
const listRequiredCoreRuntimePostBuildOutputs = (deps: RunNodeRequirementDeps) =>
|
|
listCoreRuntimePostBuildOutputs({ rootDir: deps.cwd, fs: deps.fs }).map((relativePath) =>
|
|
path.join(deps.cwd, normalizePath(relativePath)),
|
|
);
|
|
|
|
const hasMissingRequiredRuntimePostBuildOutput = (deps: RunNodeRequirementDeps) => {
|
|
const builtPluginEntries = listBuiltBundledPluginEntries(deps);
|
|
// Keep discovery failures ahead of missing-output checks.
|
|
const requiredOutputGroups = [
|
|
listRequiredCoreRuntimePostBuildOutputs(deps),
|
|
listRequiredOpenClawExtensionAliasOutputs(deps),
|
|
listRequiredStaticExtensionAssetOutputs(deps),
|
|
listRequiredBundledPluginMetadataOutputs(builtPluginEntries, deps),
|
|
];
|
|
return (
|
|
requiredOutputGroups.some((outputs) =>
|
|
outputs.some((filePath) => statMtime(filePath, deps.fs) == null),
|
|
) || hasMissingBundledPluginRuntimeOverlayOutput(deps)
|
|
);
|
|
};
|
|
|
|
/** Decides whether source changes require a new dev build. */
|
|
export const resolveBuildRequirement = (deps: RunNodeRequirementDeps): BuildRequirement => {
|
|
if (deps.env.OPENCLAW_FORCE_BUILD === "1") {
|
|
return { shouldBuild: true, reason: "force_build" };
|
|
}
|
|
if (
|
|
deps.env.OPENCLAW_BUILD_PRIVATE_QA === "1" &&
|
|
(deps.privateQaRequiredDistEntries ?? resolvePrivateQaRequiredDistEntries(deps.distRoot)).some(
|
|
(entry) => statMtime(entry, deps.fs) == null,
|
|
)
|
|
) {
|
|
return { shouldBuild: true, reason: "missing_private_qa_dist" };
|
|
}
|
|
const stamp = readBuildStamp(deps);
|
|
if (stamp.mtime == null) {
|
|
return { shouldBuild: true, reason: "missing_build_stamp" };
|
|
}
|
|
if (statMtime(deps.distEntry, deps.fs) == null) {
|
|
return { shouldBuild: true, reason: "missing_dist_entry" };
|
|
}
|
|
|
|
const currentHead = resolveGitHead(deps);
|
|
if (currentHead && !stamp.head) {
|
|
return { shouldBuild: true, reason: "build_stamp_missing_head" };
|
|
}
|
|
if (currentHead && stamp.head && currentHead !== stamp.head) {
|
|
return { shouldBuild: true, reason: "git_head_changed" };
|
|
}
|
|
if (currentHead) {
|
|
const dirty = hasDirtySourceTree(deps);
|
|
if (dirty === true) {
|
|
return { shouldBuild: true, reason: "dirty_watched_tree" };
|
|
}
|
|
if (dirty === false) {
|
|
if (hasMissingBuiltBundledPluginRuntimeEntryOutput(deps)) {
|
|
return { shouldBuild: true, reason: "missing_bundled_plugin_dist_entry" };
|
|
}
|
|
return { shouldBuild: false, reason: "clean" };
|
|
}
|
|
}
|
|
|
|
for (const filePath of deps.configFiles) {
|
|
const mtime = statMtime(filePath, deps.fs);
|
|
if (mtime != null && mtime > stamp.mtime) {
|
|
return { shouldBuild: true, reason: "config_newer" };
|
|
}
|
|
}
|
|
|
|
if (hasMissingBuiltBundledPluginRuntimeEntryOutput(deps)) {
|
|
return { shouldBuild: true, reason: "missing_bundled_plugin_dist_entry" };
|
|
}
|
|
|
|
if (hasSourceMtimeChanged(stamp.mtime, deps)) {
|
|
return { shouldBuild: true, reason: "source_mtime_newer" };
|
|
}
|
|
return { shouldBuild: false, reason: "clean" };
|
|
};
|
|
|
|
/** Decides whether runtime postbuild artifacts need to be regenerated. */
|
|
export const resolveRuntimePostBuildRequirement = (
|
|
deps: RunNodeRuntimeRequirementDeps,
|
|
): RuntimePostBuildRequirement => {
|
|
if (deps.env.OPENCLAW_FORCE_RUNTIME_POSTBUILD === "1") {
|
|
return { shouldSync: true, reason: "force_runtime_postbuild" };
|
|
}
|
|
|
|
const stamp = readRuntimePostBuildStamp(deps);
|
|
if (stamp.mtime == null) {
|
|
return { shouldSync: true, reason: "missing_runtime_postbuild_stamp" };
|
|
}
|
|
|
|
const buildStamp = readBuildStamp(deps);
|
|
if (buildStamp.mtime == null) {
|
|
return { shouldSync: true, reason: "missing_build_stamp" };
|
|
}
|
|
if (buildStamp.mtime > stamp.mtime) {
|
|
return { shouldSync: true, reason: "build_stamp_newer" };
|
|
}
|
|
|
|
const currentHead = resolveGitHead(deps);
|
|
if (currentHead && !stamp.head) {
|
|
return { shouldSync: true, reason: "runtime_postbuild_stamp_missing_head" };
|
|
}
|
|
if (currentHead && stamp.head && currentHead !== stamp.head) {
|
|
return { shouldSync: true, reason: "git_head_changed" };
|
|
}
|
|
if (currentHead) {
|
|
const dirty = hasDirtyRuntimePostBuildInputs(deps);
|
|
if (dirty === true) {
|
|
return { shouldSync: true, reason: "dirty_runtime_postbuild_inputs" };
|
|
}
|
|
if (dirty === false) {
|
|
if (hasMissingRequiredRuntimePostBuildOutput(deps)) {
|
|
return { shouldSync: true, reason: "missing_runtime_postbuild_output" };
|
|
}
|
|
return { shouldSync: false, reason: "clean" };
|
|
}
|
|
}
|
|
|
|
if (hasRuntimePostBuildInputMtimeChanged(stamp.mtime, deps)) {
|
|
return { shouldSync: true, reason: "runtime_postbuild_input_mtime_newer" };
|
|
}
|
|
|
|
if (hasMissingRequiredRuntimePostBuildOutput(deps)) {
|
|
return { shouldSync: true, reason: "missing_runtime_postbuild_output" };
|
|
}
|
|
|
|
return { shouldSync: false, reason: "clean" };
|
|
};
|
|
|
|
const BUILD_REASON_LABELS = {
|
|
force_build: "forced by OPENCLAW_FORCE_BUILD",
|
|
missing_build_stamp: "build stamp missing",
|
|
missing_dist_entry: "dist entry missing",
|
|
config_newer: "config newer than build stamp",
|
|
build_stamp_missing_head: "build stamp missing git head",
|
|
git_head_changed: "git head changed",
|
|
dirty_watched_tree: "dirty watched source tree",
|
|
missing_bundled_plugin_dist_entry: "bundled plugin dist entry missing",
|
|
source_mtime_newer: "source mtime newer than build stamp",
|
|
missing_private_qa_dist: "private QA dist entry missing",
|
|
clean: "clean",
|
|
};
|
|
|
|
const RUNTIME_POSTBUILD_REASON_LABELS = {
|
|
force_runtime_postbuild: "forced by OPENCLAW_FORCE_RUNTIME_POSTBUILD",
|
|
missing_runtime_postbuild_output: "required runtime postbuild output missing",
|
|
missing_runtime_postbuild_stamp: "runtime postbuild stamp missing",
|
|
missing_build_stamp: "build stamp missing",
|
|
build_stamp_newer: "build stamp newer than runtime postbuild stamp",
|
|
runtime_postbuild_stamp_missing_head: "runtime postbuild stamp missing git head",
|
|
git_head_changed: "git head changed",
|
|
dirty_runtime_postbuild_inputs: "dirty runtime postbuild inputs",
|
|
runtime_postbuild_input_mtime_newer: "runtime postbuild input mtime newer than stamp",
|
|
clean: "clean",
|
|
};
|
|
|
|
const formatBuildReason = (reason: BuildRequirement["reason"]) => BUILD_REASON_LABELS[reason];
|
|
const formatRuntimePostBuildReason = (reason: RuntimePostBuildRequirement["reason"]) =>
|
|
RUNTIME_POSTBUILD_REASON_LABELS[reason];
|
|
|
|
const refuseImmutableDeploymentMutation = async (
|
|
deps: RunNodeDeps,
|
|
artifactKind: "build" | "runtime",
|
|
reason: string,
|
|
) => {
|
|
const message =
|
|
`[openclaw] Cannot regenerate ${artifactKind} artifacts in an immutable deployment (${reason}). ` +
|
|
"Replace this deployment with a complete release, then use its installed `openclaw` command or run `node openclaw.mjs ...` from that release.\n";
|
|
deps.stderr.write(message);
|
|
deps.outputTee?.write(message);
|
|
return await closeRunNodeOutputTee(deps, 1);
|
|
};
|
|
|
|
const SIGNAL_EXIT_CODES = {
|
|
SIGHUP: 129,
|
|
SIGINT: 130,
|
|
SIGTERM: 143,
|
|
};
|
|
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
|
|
const isSignalKey = (signal: NodeJS.Signals): signal is keyof typeof SIGNAL_EXIT_CODES =>
|
|
Object.hasOwn(SIGNAL_EXIT_CODES, signal);
|
|
|
|
const getSignalExitCode = (signal: NodeJS.Signals) =>
|
|
isSignalKey(signal) ? SIGNAL_EXIT_CODES[signal] : 1;
|
|
|
|
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
|
|
const RUN_NODE_CPU_PROF_DIR_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_DIR";
|
|
const RUN_NODE_CPU_PROF_MAX_FILES_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES";
|
|
const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR";
|
|
const RUN_NODE_BUILD_LOCK_TIMEOUT_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_TIMEOUT_MS";
|
|
const RUN_NODE_BUILD_LOCK_POLL_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_POLL_MS";
|
|
const RUN_NODE_BUILD_LOCK_STALE_ENV = "OPENCLAW_RUN_NODE_BUILD_LOCK_STALE_MS";
|
|
const RUN_NODE_SKIP_DTS_BUILD_ENV = "OPENCLAW_RUN_NODE_SKIP_DTS_BUILD";
|
|
const DEFAULT_BUILD_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
const DEFAULT_BUILD_LOCK_POLL_MS = 100;
|
|
const DEFAULT_BUILD_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
|
|
const hasErrorCode = (error: unknown, code: string) =>
|
|
error instanceof Error && "code" in error && error.code === code;
|
|
|
|
const getErrorMessage = (error: unknown) =>
|
|
error instanceof Error ? error.message : "unknown error";
|
|
|
|
const parsePositiveIntegerEnv = (env: NodeJS.ProcessEnv, name: string, fallback: number) => {
|
|
const raw = env[name];
|
|
if (raw === undefined || raw === "") {
|
|
return fallback;
|
|
}
|
|
const parsed = Number(raw);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
};
|
|
|
|
const resolveRunNodeOutputLogPath = (deps: RunNodeDeps) => {
|
|
const outputLog = deps.env[RUN_NODE_OUTPUT_LOG_ENV]?.trim();
|
|
if (!outputLog) {
|
|
return null;
|
|
}
|
|
return path.resolve(deps.cwd, outputLog);
|
|
};
|
|
|
|
const createRunNodeOutputTee = (deps: RunNodeDeps): RunNodeOutputTee | null => {
|
|
const outputLogPath = resolveRunNodeOutputLogPath(deps);
|
|
if (!outputLogPath) {
|
|
return null;
|
|
}
|
|
try {
|
|
const existing = deps.fs.statSync(outputLogPath);
|
|
if (existing.isDirectory()) {
|
|
return {
|
|
write() {},
|
|
async close() {
|
|
throw new Error(`output log path is a directory: ${outputLogPath}`);
|
|
},
|
|
};
|
|
}
|
|
} catch (error) {
|
|
const errorCode = error instanceof Error && "code" in error ? error.code : undefined;
|
|
if (errorCode && errorCode !== "ENOENT") {
|
|
return {
|
|
write() {},
|
|
async close() {
|
|
throw error;
|
|
},
|
|
};
|
|
}
|
|
}
|
|
deps.fs.mkdirSync(path.dirname(outputLogPath), { recursive: true });
|
|
const stream = deps.fs.createWriteStream(outputLogPath, {
|
|
flags: "a",
|
|
mode: 0o600,
|
|
});
|
|
let streamError: Error | null = null;
|
|
const getStreamError = () => streamError;
|
|
stream.on("error", (error: Error) => {
|
|
streamError = error;
|
|
});
|
|
deps.env[RUN_NODE_OUTPUT_LOG_ENV] = outputLogPath;
|
|
return {
|
|
write(chunk: string | Uint8Array) {
|
|
if (!streamError) {
|
|
stream.write(chunk);
|
|
}
|
|
},
|
|
async close() {
|
|
const closeError = getStreamError();
|
|
if (closeError) {
|
|
throw closeError;
|
|
}
|
|
await new Promise((resolve, reject) => {
|
|
stream.once("error", reject);
|
|
stream.end(resolve);
|
|
});
|
|
const endError = getStreamError();
|
|
if (endError) {
|
|
throw endError;
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
const logRunner = (message: string, deps: RunNodeLogDeps) => {
|
|
if (deps.env.OPENCLAW_RUNNER_LOG === "0") {
|
|
return;
|
|
}
|
|
const line = `[openclaw] ${message}\n`;
|
|
deps.runNodeProgress?.clearLine();
|
|
deps.stderr.write(line);
|
|
deps.runNodeProgress?.render();
|
|
deps.outputTee?.write(line);
|
|
};
|
|
|
|
const RUN_NODE_PROGRESS_FRAMES = ["-", "\\", "|", "/"];
|
|
|
|
const shouldUseRunNodeProgress = (deps: RunNodeDeps) =>
|
|
deps.stderr?.isTTY === true &&
|
|
deps.env.OPENCLAW_RUNNER_PROGRESS !== "0" &&
|
|
deps.env.CI !== "true" &&
|
|
!deps.outputTee;
|
|
|
|
const createRunNodeProgress = (label: string, deps: RunNodeDeps) => {
|
|
if (!shouldUseRunNodeProgress(deps)) {
|
|
return null;
|
|
}
|
|
const startedAt = Date.now();
|
|
let frameIndex = 0;
|
|
let active = true;
|
|
let visible = false;
|
|
|
|
const clearLine = () => {
|
|
if (!visible) {
|
|
return;
|
|
}
|
|
deps.stderr.write("\r\x1b[2K");
|
|
visible = false;
|
|
};
|
|
const render = () => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
const elapsedSeconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
const frame = RUN_NODE_PROGRESS_FRAMES[frameIndex % RUN_NODE_PROGRESS_FRAMES.length];
|
|
frameIndex += 1;
|
|
deps.stderr.write(`\r[openclaw] ${frame} ${label} (${elapsedSeconds}s)`);
|
|
visible = true;
|
|
};
|
|
const timer = setInterval(render, 120);
|
|
timer.unref?.();
|
|
render();
|
|
|
|
return {
|
|
clearLine,
|
|
render,
|
|
stop() {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
active = false;
|
|
clearInterval(timer);
|
|
clearLine();
|
|
},
|
|
};
|
|
};
|
|
|
|
const withRunNodeProgress = async <T,>(
|
|
deps: RunNodeDeps,
|
|
label: string,
|
|
callback: () => Promise<T>,
|
|
) => {
|
|
const previousProgress = deps.runNodeProgress;
|
|
const progress = createRunNodeProgress(label, deps);
|
|
if (progress) {
|
|
deps.runNodeProgress = progress;
|
|
}
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
if (progress) {
|
|
progress.stop();
|
|
deps.runNodeProgress = previousProgress;
|
|
}
|
|
}
|
|
};
|
|
|
|
const writeRunnerStream = (
|
|
deps: RunNodeDeps,
|
|
stream: RunNodeWritable,
|
|
chunk: string | Uint8Array,
|
|
) => {
|
|
deps.runNodeProgress?.clearLine();
|
|
stream.write(chunk);
|
|
deps.runNodeProgress?.render();
|
|
};
|
|
|
|
const shouldPipeSpawnedOutput = (deps: RunNodeDeps) =>
|
|
Boolean(deps.outputTee || deps.runNodeProgress);
|
|
|
|
const sanitizeCpuProfileNamePart = (value: string | undefined) => {
|
|
const normalized = (value ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_.-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return normalized || "command";
|
|
};
|
|
|
|
const parsePositiveInteger = (value: unknown) => {
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
};
|
|
|
|
const listRunNodeCpuProfiles = (
|
|
deps: RunNodeDeps,
|
|
absoluteProfileDir: string,
|
|
commandName: string,
|
|
) => {
|
|
let entries;
|
|
try {
|
|
entries = deps.fs.readdirSync(absoluteProfileDir, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
const prefix = `openclaw-${commandName}-`;
|
|
return entries
|
|
.filter(
|
|
(entry) =>
|
|
entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith(".cpuprofile"),
|
|
)
|
|
.flatMap((entry) => {
|
|
const filePath = path.join(absoluteProfileDir, entry.name);
|
|
try {
|
|
const stat = deps.fs.statSync(filePath);
|
|
return [{ filePath, mtimeMs: stat.mtimeMs }];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})
|
|
.toSorted((left, right) => left.mtimeMs - right.mtimeMs);
|
|
};
|
|
|
|
const pruneRunNodeCpuProfiles = (
|
|
deps: RunNodeDeps,
|
|
absoluteProfileDir: string,
|
|
commandName: string,
|
|
) => {
|
|
const maxFiles = parsePositiveInteger(deps.env[RUN_NODE_CPU_PROF_MAX_FILES_ENV]);
|
|
if (!maxFiles) {
|
|
return;
|
|
}
|
|
const profiles = listRunNodeCpuProfiles(deps, absoluteProfileDir, commandName);
|
|
const deleteCount = Math.max(0, profiles.length - maxFiles + 1);
|
|
for (const profile of profiles.slice(0, deleteCount)) {
|
|
try {
|
|
deps.fs.rmSync(profile.filePath, { force: true });
|
|
} catch {
|
|
// Best-effort artifact rotation; profiling should not fail the command.
|
|
}
|
|
}
|
|
};
|
|
|
|
const resolveRunNodeCpuProfileArgs = (deps: RunNodeDeps) => {
|
|
const profileDir = deps.env[RUN_NODE_CPU_PROF_DIR_ENV]?.trim();
|
|
if (!profileDir) {
|
|
return [];
|
|
}
|
|
|
|
const absoluteProfileDir = path.resolve(deps.cwd, profileDir);
|
|
deps.fs.mkdirSync(absoluteProfileDir, { recursive: true });
|
|
deps.env[RUN_NODE_CPU_PROF_DIR_ENV] = absoluteProfileDir;
|
|
|
|
const commandName = sanitizeCpuProfileNamePart(deps.args[0]);
|
|
pruneRunNodeCpuProfiles(deps, absoluteProfileDir, commandName);
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const pid = Number.isInteger(deps.process.pid) && deps.process.pid > 0 ? deps.process.pid : "pid";
|
|
const profileName = `openclaw-${commandName}-${pid}-${timestamp}.cpuprofile`;
|
|
const profilePath = path.join(absoluteProfileDir, profileName);
|
|
const relativeProfilePath = path.relative(deps.cwd, profilePath) || profilePath;
|
|
logRunner(`Writing Node CPU profile to ${relativeProfilePath}.`, deps);
|
|
return ["--cpu-prof", `--cpu-prof-dir=${absoluteProfileDir}`, `--cpu-prof-name=${profileName}`];
|
|
};
|
|
|
|
const resolveRunNodeDiagnosticArgs = (deps: RunNodeDeps) => {
|
|
const args = [...resolveRunNodeCpuProfileArgs(deps)];
|
|
if (deps.env.OPENCLAW_TRACE_SYNC_IO === "1") {
|
|
logRunner("Enabling Node --trace-sync-io for startup I/O diagnostics.", deps);
|
|
args.push("--trace-sync-io");
|
|
}
|
|
return args;
|
|
};
|
|
|
|
const shouldUseRunNodeChildProcessGroup = (deps: RunNodeDeps) =>
|
|
deps.platform !== "win32" && !deps.process.stdin?.isTTY;
|
|
|
|
const signalSpawnedProcess = (
|
|
childProcess: RunNodeChild,
|
|
signal: NodeJS.Signals,
|
|
useProcessGroup: boolean,
|
|
deps: RunNodeDeps,
|
|
) => {
|
|
if (useProcessGroup && typeof childProcess.pid === "number") {
|
|
try {
|
|
deps.signalProcess(-childProcess.pid, signal);
|
|
return;
|
|
} catch (error) {
|
|
if (hasErrorCode(error, "ESRCH") || hasErrorCode(error, "EPERM")) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
childProcess.kill?.(signal);
|
|
} catch {
|
|
// Best-effort only. Exit handling still happens via the child "exit" event.
|
|
}
|
|
};
|
|
|
|
const waitForSpawnedProcess = async (childProcess: RunNodeChild, deps: RunNodeDeps) => {
|
|
let forwardedSignal: NodeJS.Signals | null = null;
|
|
let forceKillTimer: NodeJS.Timeout | null = null;
|
|
let cleanedForwardedSignalGroup = false;
|
|
const useProcessGroup = shouldUseRunNodeChildProcessGroup(deps);
|
|
|
|
const cleanupSignals = () => {
|
|
if (forceKillTimer) {
|
|
clearTimeout(forceKillTimer);
|
|
}
|
|
for (const [signal, handler] of signalHandlers) {
|
|
deps.process.off(signal, handler);
|
|
}
|
|
};
|
|
|
|
const forwardSignal = (signal: NodeJS.Signals) => {
|
|
if (forwardedSignal) {
|
|
return;
|
|
}
|
|
forwardedSignal = signal;
|
|
signalSpawnedProcess(childProcess, signal, useProcessGroup, deps);
|
|
forceKillTimer = setTimeout(() => {
|
|
forceKillTimer = null;
|
|
signalSpawnedProcess(childProcess, "SIGKILL", useProcessGroup, deps);
|
|
}, RUN_NODE_SIGNAL_FORCE_KILL_AFTER_MS);
|
|
};
|
|
|
|
const signalHandlers = FORWARDED_SIGNALS.map(
|
|
(signal) => [signal, () => forwardSignal(signal)] as const,
|
|
);
|
|
for (const [signal, handler] of signalHandlers) {
|
|
deps.process.on(signal, handler);
|
|
}
|
|
|
|
try {
|
|
return await new Promise<SpawnedProcessResult>((resolve) => {
|
|
let settled = false;
|
|
const settle = (res: SpawnedProcessResult) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
resolve(res);
|
|
};
|
|
const handleError = (error: Error) => {
|
|
logRunner(`Spawn failed: ${error.message}`, deps);
|
|
settle({ exitCode: 1, exitSignal: null, forwardedSignal });
|
|
};
|
|
const handleExit = (exitCode: number | null, exitSignal: NodeJS.Signals | null) => {
|
|
if ((forwardedSignal || exitSignal) && !cleanedForwardedSignalGroup) {
|
|
cleanedForwardedSignalGroup = true;
|
|
signalSpawnedProcess(childProcess, "SIGKILL", useProcessGroup, deps);
|
|
}
|
|
settle({ exitCode, exitSignal, forwardedSignal });
|
|
};
|
|
childProcess.on("error", handleError);
|
|
childProcess.on("exit", handleExit);
|
|
});
|
|
} finally {
|
|
cleanupSignals();
|
|
}
|
|
};
|
|
|
|
const getInterruptedSpawnOutcome = (
|
|
res: SpawnedProcessResult,
|
|
platform: NodeJS.Platform,
|
|
): RunNodeExit | null => {
|
|
if (res.exitSignal) {
|
|
// The child did not acknowledge completion. A numeric exit could let the
|
|
// watch parent retry after the owner of detached workers has disappeared.
|
|
return platform === "win32" ? getSignalExitCode(res.exitSignal) : res.exitSignal;
|
|
}
|
|
if (res.forwardedSignal) {
|
|
return getSignalExitCode(res.forwardedSignal);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const runNodeChild = async (deps: RunNodeDeps, args: string[]) => {
|
|
const useProcessGroup = shouldUseRunNodeChildProcessGroup(deps);
|
|
const nodeProcess = asRunNodeChild(
|
|
deps.spawn(deps.execPath, args, {
|
|
cwd: deps.cwd,
|
|
detached: useProcessGroup,
|
|
env: deps.env,
|
|
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
}),
|
|
);
|
|
pipeSpawnedOutput(nodeProcess, deps);
|
|
const res = await waitForSpawnedProcess(nodeProcess, deps);
|
|
const interrupted = getInterruptedSpawnOutcome(res, deps.platform);
|
|
if (interrupted !== null) {
|
|
return interrupted;
|
|
}
|
|
return res.exitCode ?? 1;
|
|
};
|
|
|
|
const runOpenClaw = (deps: RunNodeDeps) =>
|
|
runNodeChild(deps, [...resolveRunNodeDiagnosticArgs(deps), "openclaw.mjs", ...deps.args]);
|
|
|
|
const pipeSpawnedOutput = (
|
|
childProcess: RunNodeChild,
|
|
deps: RunNodeDeps,
|
|
options: { stdoutTarget?: "stdout" | "stderr" } = {},
|
|
) => {
|
|
const stdoutTarget = options.stdoutTarget ?? "stdout";
|
|
if (!shouldPipeSpawnedOutput(deps) && stdoutTarget !== "stderr") {
|
|
return;
|
|
}
|
|
const stderrFilter =
|
|
deps.env[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] === "1"
|
|
? createSyncIoTraceStderrFilter(deps)
|
|
: null;
|
|
const stdout: Pick<NodeJS.ReadableStream, "on"> | null | undefined = childProcess.stdout;
|
|
const stderr: Pick<NodeJS.ReadableStream, "on"> | null | undefined = childProcess.stderr;
|
|
stdout?.on("data", (chunk: string | Uint8Array) => {
|
|
const target = stdoutTarget === "stderr" ? deps.stderr : deps.stdout;
|
|
writeRunnerStream(deps, target, chunk);
|
|
deps.outputTee?.write(chunk);
|
|
});
|
|
stderr?.on("data", (chunk: string | Uint8Array) => {
|
|
deps.runNodeProgress?.clearLine();
|
|
if (stderrFilter) {
|
|
stderrFilter.write(chunk);
|
|
} else {
|
|
deps.stderr.write(chunk);
|
|
}
|
|
deps.runNodeProgress?.render();
|
|
deps.outputTee?.write(chunk);
|
|
});
|
|
stderr?.on("end", () => {
|
|
stderrFilter?.flush();
|
|
});
|
|
};
|
|
|
|
const createSyncIoTraceStderrFilter = (deps: RunNodeDeps) => {
|
|
let buffer = "";
|
|
let inSyncIoTrace = false;
|
|
|
|
const shouldSuppressLine = (line: string) => {
|
|
const text = line.replace(/\r?\n$/, "");
|
|
if (/^\(node:\d+\) WARNING: Detected use of sync API/.test(text)) {
|
|
inSyncIoTrace = true;
|
|
return true;
|
|
}
|
|
if (!inSyncIoTrace) {
|
|
return false;
|
|
}
|
|
if (text.trim() === "") {
|
|
inSyncIoTrace = false;
|
|
return true;
|
|
}
|
|
if (/^\s+at\b/.test(text)) {
|
|
return true;
|
|
}
|
|
inSyncIoTrace = false;
|
|
return false;
|
|
};
|
|
|
|
const writeLine = (line: string) => {
|
|
if (!shouldSuppressLine(line)) {
|
|
deps.stderr.write(line);
|
|
}
|
|
};
|
|
|
|
return {
|
|
write(chunk: string | Uint8Array) {
|
|
buffer += String(chunk);
|
|
while (true) {
|
|
const newlineIndex = buffer.indexOf("\n");
|
|
if (newlineIndex === -1) {
|
|
break;
|
|
}
|
|
const line = buffer.slice(0, newlineIndex + 1);
|
|
buffer = buffer.slice(newlineIndex + 1);
|
|
writeLine(line);
|
|
}
|
|
},
|
|
flush() {
|
|
if (!buffer) {
|
|
return;
|
|
}
|
|
writeLine(buffer);
|
|
buffer = "";
|
|
},
|
|
};
|
|
};
|
|
|
|
const closeRunNodeOutputTee = async (deps: RunNodeDeps, exitCode: RunNodeExit) => {
|
|
if (!deps.outputTee) {
|
|
return exitCode;
|
|
}
|
|
try {
|
|
await deps.outputTee.close();
|
|
} catch (error) {
|
|
deps.stderr.write(`[openclaw] Failed to write output log: ${getErrorMessage(error)}\n`);
|
|
return exitCode === 0 ? 1 : exitCode;
|
|
}
|
|
return exitCode;
|
|
};
|
|
|
|
const readBuildLockOwnerPid = (deps: RunNodeLockDeps, lockDir: string) => {
|
|
try {
|
|
const raw = deps.fs.readFileSync(path.join(lockDir, "owner.json"), "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
const pid = Number(parsed?.pid);
|
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const isBuildLockOwnerDead = (deps: RunNodeLockDeps, pid: number) => {
|
|
try {
|
|
deps.process.kill(pid, 0);
|
|
return false;
|
|
} catch (error) {
|
|
return hasErrorCode(error, "ESRCH");
|
|
}
|
|
};
|
|
|
|
const removeStaleBuildLock = (deps: RunNodeLockDeps, lockDir: string, staleMs: number) => {
|
|
try {
|
|
const ownerPid = readBuildLockOwnerPid(deps, lockDir);
|
|
if (ownerPid !== null && isBuildLockOwnerDead(deps, ownerPid)) {
|
|
deps.fs.rmSync(lockDir, { recursive: true, force: true });
|
|
return true;
|
|
}
|
|
const stats = deps.fs.statSync(lockDir);
|
|
if (Date.now() - stats.mtimeMs < staleMs) {
|
|
return false;
|
|
}
|
|
deps.fs.rmSync(lockDir, { recursive: true, force: true });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Acquires the dev-build lock used to serialize local rebuilds. */
|
|
export const acquireRunNodeBuildLock = async (deps: RunNodeLockDeps): Promise<() => void> => {
|
|
const lockRoot = path.join(deps.cwd, ".artifacts");
|
|
const lockDir = path.join(lockRoot, "run-node-build.lock");
|
|
const timeoutMs = parsePositiveIntegerEnv(
|
|
deps.env,
|
|
RUN_NODE_BUILD_LOCK_TIMEOUT_ENV,
|
|
DEFAULT_BUILD_LOCK_TIMEOUT_MS,
|
|
);
|
|
const pollMs = parsePositiveIntegerEnv(
|
|
deps.env,
|
|
RUN_NODE_BUILD_LOCK_POLL_ENV,
|
|
DEFAULT_BUILD_LOCK_POLL_MS,
|
|
);
|
|
const staleMs = parsePositiveIntegerEnv(
|
|
deps.env,
|
|
RUN_NODE_BUILD_LOCK_STALE_ENV,
|
|
DEFAULT_BUILD_LOCK_STALE_MS,
|
|
);
|
|
const startedAt = Date.now();
|
|
let waitLogBudget = 1;
|
|
const consumeWaitLog = () => waitLogBudget-- > 0;
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
deps.fs.mkdirSync(lockRoot, { recursive: true });
|
|
deps.fs.mkdirSync(lockDir);
|
|
try {
|
|
deps.fs.writeFileSync(
|
|
path.join(lockDir, "owner.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
pid: deps.process.pid,
|
|
startedAt: new Date().toISOString(),
|
|
args: deps.args,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
} catch {
|
|
// Owner metadata is diagnostic only; the directory itself is the lock.
|
|
}
|
|
let released = false;
|
|
const removeLockDir = () => {
|
|
if (released) {
|
|
return;
|
|
}
|
|
released = true;
|
|
try {
|
|
deps.fs.rmSync(lockDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Best-effort cleanup; a follow-up waiter will fall back to staleness
|
|
// detection if the directory is still present.
|
|
}
|
|
};
|
|
const onSignal = () => removeLockDir();
|
|
const onExit = () => removeLockDir();
|
|
for (const signal of FORWARDED_SIGNALS) {
|
|
deps.process.on(signal, onSignal);
|
|
}
|
|
deps.process.on("exit", onExit);
|
|
return () => {
|
|
for (const signal of FORWARDED_SIGNALS) {
|
|
deps.process.off(signal, onSignal);
|
|
}
|
|
deps.process.off("exit", onExit);
|
|
removeLockDir();
|
|
};
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "EEXIST")) {
|
|
throw error;
|
|
}
|
|
if (removeStaleBuildLock(deps, lockDir, staleMs)) {
|
|
continue;
|
|
}
|
|
if (consumeWaitLog()) {
|
|
logRunner("Waiting for TypeScript/runtime artifact lock.", deps);
|
|
}
|
|
await sleep(pollMs);
|
|
}
|
|
}
|
|
|
|
throw new Error(`timed out waiting for ${path.relative(deps.cwd, lockDir)}`);
|
|
};
|
|
|
|
const withRunNodeBuildLock = async <T,>(deps: RunNodeDeps, callback: () => Promise<T>) => {
|
|
const release = await acquireRunNodeBuildLock(deps);
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
release();
|
|
}
|
|
};
|
|
|
|
const syncRuntimeArtifacts = async (deps: RunNodeDeps) => {
|
|
try {
|
|
await deps.runRuntimePostBuild({ cwd: deps.cwd, env: deps.env });
|
|
} catch (error) {
|
|
logRunner(`Failed to write runtime build artifacts: ${getErrorMessage(error)}`, deps);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const writeRuntimePostBuildStamp = (deps: RunNodeDeps) => {
|
|
try {
|
|
writeDistRuntimePostBuildStamp({
|
|
cwd: deps.cwd,
|
|
fs: deps.fs,
|
|
spawnSync: deps.spawnSync,
|
|
});
|
|
} catch (error) {
|
|
logRunner(`Failed to write runtime postbuild stamp: ${getErrorMessage(error)}`, deps);
|
|
}
|
|
};
|
|
|
|
const syncRuntimeArtifactsAndStamp = async (deps: RunNodeDeps) =>
|
|
withDistArtifactOwnership(deps.cwd, async () => {
|
|
if (!resolveRuntimePostBuildRequirement(deps).shouldSync) {
|
|
return true;
|
|
}
|
|
const synced = await syncRuntimeArtifacts(deps);
|
|
if (synced) {
|
|
writeRuntimePostBuildStamp(deps);
|
|
}
|
|
return synced;
|
|
});
|
|
|
|
const shouldSkipWatchRuntimeSync = (deps: RunNodeDeps, requirement: RuntimePostBuildRequirement) =>
|
|
deps.env.OPENCLAW_WATCH_MODE === "1" &&
|
|
requirement.reason === "missing_runtime_postbuild_stamp" &&
|
|
hasDirtyRuntimePostBuildInputs(deps) !== true &&
|
|
!hasMissingRequiredRuntimePostBuildOutput(deps);
|
|
|
|
const isGatewayClientCommand = (args: string[]) =>
|
|
args[0] === "dashboard" ||
|
|
(args[0] === "gateway" && (args[1] === "call" || args[1] === "status")) ||
|
|
(args[0] === "agent" && !args.includes("--local"));
|
|
|
|
const shouldFastPathExistingDistForGatewayClient = (deps: RunNodeDeps) =>
|
|
isGatewayClientCommand(deps.args) &&
|
|
deps.env.OPENCLAW_FORCE_BUILD !== "1" &&
|
|
statMtime(deps.distEntry, deps.fs) != null &&
|
|
canUseStampedGatewayClientDist(deps);
|
|
|
|
const canUseStampedGatewayClientDist = (deps: RunNodeDeps) => {
|
|
const currentHead = resolveGitHead(deps);
|
|
if (!currentHead) {
|
|
return false;
|
|
}
|
|
const buildStamp = readBuildStamp(deps);
|
|
if (buildStamp.mtime == null || buildStamp.head !== currentHead) {
|
|
return false;
|
|
}
|
|
for (const filePath of deps.configFiles) {
|
|
const mtime = statMtime(filePath, deps.fs);
|
|
if (mtime != null && mtime > buildStamp.mtime) {
|
|
return false;
|
|
}
|
|
}
|
|
if (hasMissingBuiltBundledPluginRuntimeEntryOutput(deps)) {
|
|
return false;
|
|
}
|
|
const runtimeStamp = readRuntimePostBuildStamp(deps);
|
|
if (
|
|
runtimeStamp.mtime == null ||
|
|
runtimeStamp.mtime < buildStamp.mtime ||
|
|
runtimeStamp.head !== currentHead ||
|
|
deps.env.OPENCLAW_FORCE_RUNTIME_POSTBUILD === "1"
|
|
) {
|
|
return false;
|
|
}
|
|
return !resolveRuntimePostBuildRequirement(deps).shouldSync;
|
|
};
|
|
|
|
type QaReportScript = "qa-parity-report.ts" | "qa-coverage-report.ts";
|
|
|
|
const resolveQaReportSourceScript = (deps: RunNodeDeps, buildRequirement: BuildRequirement) => {
|
|
const sourceEntrypoint = path.join(deps.cwd, "extensions", "qa-lab", "src", "cli.runtime.ts");
|
|
if (
|
|
buildRequirement.reason !== "missing_private_qa_dist" ||
|
|
deps.args[0] !== "qa" ||
|
|
deps.env.OPENCLAW_FORCE_BUILD === "1" ||
|
|
statMtime(sourceEntrypoint, deps.fs) == null
|
|
) {
|
|
return null;
|
|
}
|
|
return deps.args[1] === "parity-report"
|
|
? "qa-parity-report.ts"
|
|
: deps.args[1] === "coverage"
|
|
? "qa-coverage-report.ts"
|
|
: null;
|
|
};
|
|
|
|
const runQaReportFromSource = (deps: RunNodeDeps, script: QaReportScript) => {
|
|
const sourceEntrypoint = path.join(deps.cwd, "scripts", script);
|
|
return runNodeChild(deps, ["--import", "tsx", sourceEntrypoint, ...deps.args.slice(2)]);
|
|
};
|
|
|
|
function createRunNodeDeps(params: RunNodeMainParams) {
|
|
const cwd = params.cwd ?? process.cwd();
|
|
const distRoot = path.join(cwd, "dist");
|
|
const env = params.env ? { ...params.env } : { ...process.env };
|
|
// Select this checkout's plugins over tracked installs without changing source/dist loading.
|
|
env.OPENCLAW_DEV_SOURCE_ROOT ??= cwd;
|
|
const mutableState: RunNodeMutableState = {
|
|
outputTee: null,
|
|
runNodeProgress: undefined,
|
|
};
|
|
return {
|
|
spawn: params.spawn ?? spawn,
|
|
spawnSync: params.spawnSync ?? spawnSync,
|
|
fs: params.fs ?? fs,
|
|
stderr: params.stderr ?? process.stderr,
|
|
stdout: params.stdout ?? process.stdout,
|
|
process: params.process ?? process,
|
|
execPath: params.execPath ?? process.execPath,
|
|
cwd,
|
|
args: params.args ?? process.argv.slice(2),
|
|
env,
|
|
platform: params.platform ?? process.platform,
|
|
signalProcess:
|
|
params.signalProcess ??
|
|
((pid: number, signal?: NodeJS.Signals | number) => process.kill(pid, signal)),
|
|
runRuntimePostBuild: params.runRuntimePostBuild ?? runRuntimePostBuild,
|
|
distRoot,
|
|
distEntry: path.join(distRoot, "/entry.js"),
|
|
buildStampPath: path.join(distRoot, BUILD_STAMP_FILE),
|
|
runtimePostBuildStampPath: path.join(distRoot, RUNTIME_POSTBUILD_STAMP_FILE),
|
|
sourceRoots: runNodeSourceRoots.map((name) => ({ name, path: path.join(cwd, name) })),
|
|
configFiles: runNodeConfigFiles.map((filePath) => path.join(cwd, filePath)),
|
|
privateQaRequiredDistEntries: resolvePrivateQaRequiredDistEntries(distRoot),
|
|
...mutableState,
|
|
};
|
|
}
|
|
|
|
/** Runs the dev build/watch loop and keeps the child CLI in sync with changes. */
|
|
export async function runNodeMain(params: RunNodeMainParams = {}): Promise<RunNodeExit> {
|
|
const deps = createRunNodeDeps(params);
|
|
if (deps.args[0] === "qa") {
|
|
deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1";
|
|
deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
|
|
deps.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ??= "0";
|
|
}
|
|
deps.outputTee = createRunNodeOutputTee(deps);
|
|
|
|
try {
|
|
let exitCode: RunNodeExit = 1;
|
|
if (shouldFastPathExistingDistForGatewayClient(deps)) {
|
|
exitCode = await runOpenClaw(deps);
|
|
return await closeRunNodeOutputTee(deps, exitCode);
|
|
}
|
|
const buildRequirement = resolveBuildRequirement(deps);
|
|
const immutableDeployment = isImmutableGitDeployment(deps);
|
|
if (immutableDeployment && buildRequirement.shouldBuild) {
|
|
return await refuseImmutableDeploymentMutation(
|
|
deps,
|
|
"build",
|
|
formatBuildReason(buildRequirement.reason),
|
|
);
|
|
}
|
|
const qaReportScript = resolveQaReportSourceScript(deps, buildRequirement);
|
|
if (qaReportScript) {
|
|
const reportName = qaReportScript === "qa-parity-report.ts" ? "parity" : "coverage";
|
|
logRunner(
|
|
`Running QA ${reportName} report from source without rebuilding private QA dist.`,
|
|
deps,
|
|
);
|
|
exitCode = await runQaReportFromSource(deps, qaReportScript);
|
|
return await closeRunNodeOutputTee(deps, exitCode);
|
|
}
|
|
if (!buildRequirement.shouldBuild) {
|
|
const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps);
|
|
if (immutableDeployment && runtimePostBuildRequirement.shouldSync) {
|
|
return await refuseImmutableDeploymentMutation(
|
|
deps,
|
|
"runtime",
|
|
formatRuntimePostBuildReason(runtimePostBuildRequirement.reason),
|
|
);
|
|
}
|
|
if (
|
|
runtimePostBuildRequirement.shouldSync &&
|
|
!shouldSkipWatchRuntimeSync(deps, runtimePostBuildRequirement)
|
|
) {
|
|
const synced = await withRunNodeBuildLock(deps, async () => {
|
|
const lockedRuntimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps);
|
|
if (!lockedRuntimePostBuildRequirement.shouldSync) {
|
|
return true;
|
|
}
|
|
logRunner(
|
|
`Syncing runtime artifacts (${lockedRuntimePostBuildRequirement.reason} - ${formatRuntimePostBuildReason(lockedRuntimePostBuildRequirement.reason)}).`,
|
|
deps,
|
|
);
|
|
return await syncRuntimeArtifactsAndStamp(deps);
|
|
});
|
|
if (!synced) {
|
|
return await closeRunNodeOutputTee(deps, 1);
|
|
}
|
|
}
|
|
exitCode = await runOpenClaw(deps);
|
|
return await closeRunNodeOutputTee(deps, exitCode);
|
|
}
|
|
|
|
const buildExitCode = await withRunNodeBuildLock(deps, async () => {
|
|
if (shouldFastPathExistingDistForGatewayClient(deps)) {
|
|
return 0;
|
|
}
|
|
const lockedBuildRequirement = resolveBuildRequirement(deps);
|
|
if (!lockedBuildRequirement.shouldBuild) {
|
|
const runtimePostBuildRequirement = resolveRuntimePostBuildRequirement(deps);
|
|
if (!runtimePostBuildRequirement.shouldSync) {
|
|
return 0;
|
|
}
|
|
logRunner(
|
|
`Syncing runtime artifacts (${runtimePostBuildRequirement.reason} - ${formatRuntimePostBuildReason(runtimePostBuildRequirement.reason)}).`,
|
|
deps,
|
|
);
|
|
return (await syncRuntimeArtifactsAndStamp(deps)) ? 0 : 1;
|
|
}
|
|
|
|
logRunner(
|
|
`Building TypeScript (dist is stale: ${lockedBuildRequirement.reason} - ${formatBuildReason(lockedBuildRequirement.reason)}).`,
|
|
deps,
|
|
);
|
|
return await withRunNodeProgress(deps, "Building local CLI artifacts", async () => {
|
|
const build = asRunNodeChild(
|
|
deps.spawn(deps.execPath, runtimeBuildArgs, {
|
|
cwd: deps.cwd,
|
|
detached: shouldUseRunNodeChildProcessGroup(deps),
|
|
env: {
|
|
...deps.env,
|
|
[RUN_NODE_SKIP_DTS_BUILD_ENV]: deps.env[RUN_NODE_SKIP_DTS_BUILD_ENV] ?? "1",
|
|
},
|
|
stdio: ["inherit", "pipe", "pipe"],
|
|
}),
|
|
);
|
|
pipeSpawnedOutput(build, deps, { stdoutTarget: "stderr" });
|
|
const result = await waitForSpawnedProcess(build, deps);
|
|
return getInterruptedSpawnOutcome(result, deps.platform) ?? result.exitCode ?? 1;
|
|
});
|
|
});
|
|
if (buildExitCode !== 0) {
|
|
return await closeRunNodeOutputTee(deps, buildExitCode);
|
|
}
|
|
exitCode = await runOpenClaw(deps);
|
|
return await closeRunNodeOutputTee(deps, exitCode);
|
|
} catch (error) {
|
|
await closeRunNodeOutputTee(deps, 1);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
void runNodeMain()
|
|
.then((outcome) => {
|
|
if (typeof outcome === "string") {
|
|
process.kill(process.pid, outcome);
|
|
return;
|
|
}
|
|
process.exit(outcome);
|
|
})
|
|
.catch((err: unknown) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
}
|