mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix(qa): bound Mantis lane commands * fix(qa): use inactivity watchdog for Mantis commands * fix(qa): use stage deadlines for Mantis commands * fix(qa): propagate Mantis command cancellation * fix(qa): stabilize Mantis abort harness * fix(mantis): preserve abort termination * fix(qa): clean temporary Mantis worktrees * test(qa): cover Mantis cleanup failures * fix(qa): preserve Mantis cleanup failures * test(qa): stabilize Mantis process readiness * test(qa): harden Mantis process tree fixtures * fix(qa): normalize Mantis scenario deadline * test(qa): use supported process output capture * test(qa): split Mantis process runtime coverage * test(qa): clear Mantis process wait timers * refactor(qa): split Mantis command runtime * fix(qa): keep Mantis command stage internal * fix(qa): protect Mantis worktree cleanup ownership * refactor(qa): split Mantis evidence artifact handling * fix(qa): remove quarantined Mantis worktrees * fix(qa): support legacy Git in Mantis cleanup * fix(qa): satisfy Mantis runtime checks * test(ci): shrink Mantis assertion baseline * fix(qa): abort Mantis runs on CLI signals * fix(qa): retain Mantis signal ownership during cleanup * test(qa): exercise repeated Mantis interrupts * test(qa-lab): pass signal to interrupt listener * fix(qa): preserve Mantis cleanup after interruption * fix(qa): settle Mantis cleanup before artifact reads * fix(qa): preserve Mantis evidence across failed reruns * fix(qa): publish Mantis comparison as one generation * fix(qa): bind Mantis publication to owned directories * fix(qa): keep Mantis cleanup Git-owned * fix(qa): bind Mantis cleanup to owned worktrees * fix(qa): narrow Mantis worktree recovery * fix(qa): keep Mantis recovery state atomic * fix(qa): preserve Mantis failure diagnostics * fix(qa): satisfy Mantis failure diagnostics gates * docs(qa): clarify Mantis output container boundary * fix(qa): remove stale Mantis ownership helper * fix(qa): remove stale legacy worktree fixture * fix(qa): preserve stable Mantis artifact paths * fix(qa): make Mantis publication lock waits cancelable * fix(qa): preserve fs-safe lock retry policy * fix(qa): keep Mantis artifacts at documented paths * fix(qa): keep failed Mantis reruns internally consistent * fix(qa): scope shutdown grace to Mantis runs * fix(qa): retain Mantis backups after restore failure * fix: restore process and lock safety contracts * fix: preserve signal exit type through launcher * fix(test): keep launcher fixtures outside production roots Move the extracted launcher fixture to test/scripts and preserve its original imports and lifetime. This fixes the production TypeScript and channel import boundaries without exclusions or compiler weakening. Core production and infra test types, 13 focused launcher cases, focused lint, line ratchets and independent P0-P2 review passed. * test: align release inventory with bundled publication deferral Retain the exact CI-tested main parent and its policy excluding logbook, memory-wiki, and onepassword from publication until release. Correct the stale exact-inventory expectations to 95 npm and 91 ClawHub packages. The full package-set assertions and publication policy remain unchanged. The previously failing focused inventory test, format/lint, assertion ratchet and independent P0-P2 correction review passed. Co-authored-by: xingzhou <275915537+zhangguiping-xydt@users.noreply.github.com> --------- Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com> Co-authored-by: xingzhou <275915537+zhangguiping-xydt@users.noreply.github.com>
1622 lines
52 KiB
TypeScript
1622 lines
52 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 { getCommandArgsWithRootOptions } from "../src/infra/cli-root-options.ts";
|
|
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 {
|
|
collectRunNodeBundledPluginBuildEntries,
|
|
hasDirtySourceTree,
|
|
hasDirtyRuntimePostBuildInputs,
|
|
isRuntimePostBuildRelevantPath,
|
|
listBundledPluginRuntimeEntryPaths,
|
|
runtimePostBuildWatchedPaths,
|
|
type BundledPluginBuildEntry,
|
|
} from "./lib/run-node-input-state.mts";
|
|
import { sleep } from "./lib/sleep.mjs";
|
|
import {
|
|
discoverStaticExtensionAssets,
|
|
shouldCopyStaticExtensionAssets,
|
|
} from "./lib/static-extension-assets.mts";
|
|
import {
|
|
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;
|
|
off?(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 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_DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
const RUN_NODE_MAX_SHUTDOWN_GRACE_MS = 5 * 60_000;
|
|
const RUN_NODE_SHUTDOWN_GRACE_MESSAGE_TYPE = "openclaw:shutdown-grace";
|
|
|
|
function resolveRunNodeShutdownGraceMessage(message: unknown): number | undefined {
|
|
if (
|
|
!message ||
|
|
typeof message !== "object" ||
|
|
!("type" in message) ||
|
|
message.type !== RUN_NODE_SHUTDOWN_GRACE_MESSAGE_TYPE ||
|
|
!("graceMs" in message) ||
|
|
typeof message.graceMs !== "number" ||
|
|
!Number.isSafeInteger(message.graceMs) ||
|
|
message.graceMs <= 0 ||
|
|
message.graceMs > RUN_NODE_MAX_SHUTDOWN_GRACE_MS
|
|
) {
|
|
return undefined;
|
|
}
|
|
return Math.max(RUN_NODE_DEFAULT_SHUTDOWN_GRACE_MS, message.graceMs);
|
|
}
|
|
|
|
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 readJsonStamp = (filePath: string, deps: RunNodeRequirementDeps) => {
|
|
const mtime = statMtime(filePath, deps.fs);
|
|
if (mtime == null) {
|
|
return { mtime: null, head: null, inputsClean: null };
|
|
}
|
|
try {
|
|
const raw = deps.fs.readFileSync(filePath, "utf8").trim();
|
|
if (!raw.startsWith("{")) {
|
|
return { mtime, head: null, inputsClean: null };
|
|
}
|
|
const parsed = JSON.parse(raw);
|
|
const head = typeof parsed?.head === "string" && parsed.head.trim() ? parsed.head.trim() : null;
|
|
return {
|
|
mtime,
|
|
head,
|
|
inputsClean: typeof parsed?.inputsClean === "boolean" ? parsed.inputsClean : null,
|
|
};
|
|
} catch {
|
|
return { mtime, head: null, inputsClean: 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 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" };
|
|
}
|
|
if (stamp.inputsClean !== true) {
|
|
return { shouldBuild: true, reason: "build_inputs_unverified" };
|
|
}
|
|
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,
|
|
options: { requireCleanInputs?: boolean } = {},
|
|
): 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" };
|
|
}
|
|
if (options.requireCleanInputs !== false && stamp.inputsClean !== true) {
|
|
return { shouldSync: true, reason: "runtime_inputs_unverified" };
|
|
}
|
|
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",
|
|
build_inputs_unverified: "build inputs were not verified clean",
|
|
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",
|
|
runtime_inputs_unverified: "runtime postbuild inputs were not verified clean",
|
|
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,
|
|
acceptShutdownGrace = false,
|
|
) => {
|
|
let forwardedSignal: NodeJS.Signals | null = null;
|
|
let forceKillTimer: NodeJS.Timeout | null = null;
|
|
let cleanedForwardedSignalGroup = false;
|
|
let shutdownGraceMs = RUN_NODE_DEFAULT_SHUTDOWN_GRACE_MS;
|
|
const useProcessGroup = shouldUseRunNodeChildProcessGroup(deps);
|
|
|
|
const onMessage = (message: unknown) => {
|
|
// The child declares lifecycle needs before interruption; freezing this at
|
|
// the first signal prevents late messages from extending shutdown forever.
|
|
if (forwardedSignal) {
|
|
return;
|
|
}
|
|
const requestedGraceMs = resolveRunNodeShutdownGraceMessage(message);
|
|
if (requestedGraceMs !== undefined) {
|
|
shutdownGraceMs = requestedGraceMs;
|
|
}
|
|
};
|
|
|
|
const cleanupSignals = () => {
|
|
if (forceKillTimer) {
|
|
clearTimeout(forceKillTimer);
|
|
}
|
|
for (const [signal, handler] of signalHandlers) {
|
|
deps.process.off(signal, handler);
|
|
}
|
|
childProcess.off?.("message", onMessage);
|
|
};
|
|
|
|
const forwardSignal = (signal: NodeJS.Signals) => {
|
|
if (forwardedSignal) {
|
|
return;
|
|
}
|
|
forwardedSignal = signal;
|
|
signalSpawnedProcess(childProcess, signal, useProcessGroup, deps);
|
|
forceKillTimer = setTimeout(() => {
|
|
forceKillTimer = null;
|
|
cleanedForwardedSignalGroup = true;
|
|
signalSpawnedProcess(childProcess, "SIGKILL", useProcessGroup, deps);
|
|
}, shutdownGraceMs);
|
|
};
|
|
|
|
if (acceptShutdownGrace) {
|
|
childProcess.on("message", onMessage);
|
|
}
|
|
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);
|
|
// The parent route grants lifecycle IPC; generic children must not extend
|
|
// the launcher's five-second force-kill boundary with a shaped message.
|
|
const acceptShutdownGrace =
|
|
getCommandArgsWithRootOptions([deps.execPath, "openclaw.mjs", ...deps.args], {
|
|
commandPath: ["qa", "mantis", "run"],
|
|
mode: "command-path",
|
|
}) !== null;
|
|
const nodeProcess = asRunNodeChild(
|
|
deps.spawn(deps.execPath, args, {
|
|
cwd: deps.cwd,
|
|
detached: useProcessGroup,
|
|
env: deps.env,
|
|
stdio: deps.outputTee
|
|
? acceptShutdownGrace
|
|
? ["inherit", "pipe", "pipe", "ipc"]
|
|
: ["inherit", "pipe", "pipe"]
|
|
: acceptShutdownGrace
|
|
? ["inherit", "inherit", "inherit", "ipc"]
|
|
: "inherit",
|
|
}),
|
|
);
|
|
pipeSpawnedOutput(nodeProcess, deps);
|
|
const res = await waitForSpawnedProcess(nodeProcess, deps, acceptShutdownGrace);
|
|
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,
|
|
env: deps.env,
|
|
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;
|
|
}
|
|
// Remote clients intentionally use existing dist. Retain metadata/output checks
|
|
// without treating producer source cleanliness as a client rebuild requirement.
|
|
return !resolveRuntimePostBuildRequirement(deps, { requireCleanInputs: false }).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);
|
|
});
|
|
}
|