mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 17:50:47 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
541
openclaw/scripts/run-node.mjs
Normal file
541
openclaw/scripts/run-node.mjs
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } 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 { resolveGitHead, writeBuildStamp as writeDistBuildStamp } from "./build-stamp.mjs";
|
||||
import {
|
||||
BUNDLED_PLUGIN_PATH_PREFIX,
|
||||
BUNDLED_PLUGIN_ROOT_DIR,
|
||||
} from "./lib/bundled-plugin-paths.mjs";
|
||||
import { runRuntimePostBuild } from "./runtime-postbuild.mjs";
|
||||
|
||||
const buildScript = "scripts/tsdown-build.mjs";
|
||||
const compilerArgs = [buildScript, "--no-clean"];
|
||||
|
||||
const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR];
|
||||
const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
|
||||
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
|
||||
const ignoredRunNodeRepoPaths = new Set([
|
||||
"src/canvas-host/a2ui/.bundle.hash",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
]);
|
||||
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
|
||||
const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
|
||||
|
||||
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
|
||||
|
||||
const isIgnoredSourcePath = (relativePath) => {
|
||||
const normalizedPath = normalizePath(relativePath);
|
||||
return (
|
||||
normalizedPath.endsWith(".test.ts") ||
|
||||
normalizedPath.endsWith(".test.tsx") ||
|
||||
normalizedPath.endsWith("test-helpers.ts")
|
||||
);
|
||||
};
|
||||
|
||||
const isBuildRelevantSourcePath = (relativePath) => {
|
||||
const normalizedPath = normalizePath(relativePath);
|
||||
return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath);
|
||||
};
|
||||
|
||||
const isRestartRelevantExtensionPath = (relativePath) => {
|
||||
const normalizedPath = normalizePath(relativePath);
|
||||
if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) {
|
||||
return true;
|
||||
}
|
||||
return isBuildRelevantSourcePath(normalizedPath);
|
||||
};
|
||||
|
||||
const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => {
|
||||
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
|
||||
if (ignoredRunNodeRepoPaths.has(normalizedPath)) {
|
||||
return false;
|
||||
}
|
||||
if (runNodeConfigFiles.includes(normalizedPath)) {
|
||||
return true;
|
||||
}
|
||||
if (normalizedPath.startsWith("src/")) {
|
||||
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
|
||||
}
|
||||
if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
|
||||
return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isBuildRelevantRunNodePath = (repoPath) =>
|
||||
isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath);
|
||||
|
||||
export const isRestartRelevantRunNodePath = (repoPath) =>
|
||||
isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath);
|
||||
|
||||
const statMtime = (filePath, fsImpl = fs) => {
|
||||
try {
|
||||
return fsImpl.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePrivateQaRequiredDistEntries = (distRoot) => [
|
||||
path.join(distRoot, "plugin-sdk", "qa-lab.js"),
|
||||
path.join(distRoot, "plugin-sdk", "qa-runtime.js"),
|
||||
];
|
||||
|
||||
const isExcludedSource = (filePath, sourceRoot, sourceRootName) => {
|
||||
const relativePath = normalizePath(path.relative(sourceRoot, filePath));
|
||||
if (relativePath.startsWith("..")) {
|
||||
return false;
|
||||
}
|
||||
return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath));
|
||||
};
|
||||
|
||||
const findLatestMtime = (dirPath, shouldSkip, deps) => {
|
||||
let latest = 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) => {
|
||||
try {
|
||||
const result = deps.spawnSync(
|
||||
"git",
|
||||
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
|
||||
{
|
||||
cwd: deps.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
return result.stdout ?? "";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseGitStatusPaths = (output) =>
|
||||
output
|
||||
.split("\n")
|
||||
.flatMap((line) => line.slice(3).split(" -> "))
|
||||
.map((entry) => normalizePath(entry.trim()))
|
||||
.filter(Boolean);
|
||||
|
||||
const hasDirtySourceTree = (deps) => {
|
||||
const output = readGitStatus(deps);
|
||||
if (output === null) {
|
||||
return null;
|
||||
}
|
||||
return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath));
|
||||
};
|
||||
|
||||
const readBuildStamp = (deps) => {
|
||||
const mtime = statMtime(deps.buildStampPath, deps.fs);
|
||||
if (mtime == null) {
|
||||
return { mtime: null, head: null };
|
||||
}
|
||||
try {
|
||||
const raw = deps.fs.readFileSync(deps.buildStampPath, "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 hasSourceMtimeChanged = (stampMtime, deps) => {
|
||||
let latestSourceMtime = 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;
|
||||
};
|
||||
|
||||
export const resolveBuildRequirement = (deps) => {
|
||||
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" };
|
||||
}
|
||||
|
||||
for (const filePath of deps.configFiles) {
|
||||
const mtime = statMtime(filePath, deps.fs);
|
||||
if (mtime != null && mtime > stamp.mtime) {
|
||||
return { shouldBuild: true, reason: "config_newer" };
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return { shouldBuild: false, reason: "clean" };
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSourceMtimeChanged(stamp.mtime, deps)) {
|
||||
return { shouldBuild: true, reason: "source_mtime_newer" };
|
||||
}
|
||||
return { shouldBuild: 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",
|
||||
source_mtime_newer: "source mtime newer than build stamp",
|
||||
missing_private_qa_dist: "private QA dist entry missing",
|
||||
clean: "clean",
|
||||
};
|
||||
|
||||
const formatBuildReason = (reason) => BUILD_REASON_LABELS[reason] ?? reason;
|
||||
|
||||
const SIGNAL_EXIT_CODES = {
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
};
|
||||
|
||||
const isSignalKey = (signal) => Object.hasOwn(SIGNAL_EXIT_CODES, signal);
|
||||
|
||||
const getSignalExitCode = (signal) => (isSignalKey(signal) ? SIGNAL_EXIT_CODES[signal] : 1);
|
||||
|
||||
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
|
||||
|
||||
const resolveRunNodeOutputLogPath = (deps) => {
|
||||
const outputLog = deps.env[RUN_NODE_OUTPUT_LOG_ENV]?.trim();
|
||||
if (!outputLog) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(deps.cwd, outputLog);
|
||||
};
|
||||
|
||||
const createRunNodeOutputTee = (deps) => {
|
||||
const outputLogPath = resolveRunNodeOutputLogPath(deps);
|
||||
if (!outputLogPath) {
|
||||
return null;
|
||||
}
|
||||
deps.fs.mkdirSync(path.dirname(outputLogPath), { recursive: true });
|
||||
const stream = deps.fs.createWriteStream(outputLogPath, {
|
||||
flags: "a",
|
||||
mode: 0o600,
|
||||
});
|
||||
let streamError = null;
|
||||
stream.on("error", (error) => {
|
||||
streamError = error;
|
||||
});
|
||||
deps.env[RUN_NODE_OUTPUT_LOG_ENV] = outputLogPath;
|
||||
return {
|
||||
outputLogPath,
|
||||
write(chunk) {
|
||||
if (!streamError) {
|
||||
stream.write(chunk);
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.once("error", reject);
|
||||
stream.end(resolve);
|
||||
});
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const logRunner = (message, deps) => {
|
||||
if (deps.env.OPENCLAW_RUNNER_LOG === "0") {
|
||||
return;
|
||||
}
|
||||
const line = `[openclaw] ${message}\n`;
|
||||
deps.stderr.write(line);
|
||||
deps.outputTee?.write(line);
|
||||
};
|
||||
|
||||
const waitForSpawnedProcess = async (childProcess, deps) => {
|
||||
let forwardedSignal = null;
|
||||
let onSigInt;
|
||||
let onSigTerm;
|
||||
|
||||
const cleanupSignals = () => {
|
||||
if (onSigInt) {
|
||||
deps.process.off("SIGINT", onSigInt);
|
||||
}
|
||||
if (onSigTerm) {
|
||||
deps.process.off("SIGTERM", onSigTerm);
|
||||
}
|
||||
};
|
||||
|
||||
const forwardSignal = (signal) => {
|
||||
if (forwardedSignal) {
|
||||
return;
|
||||
}
|
||||
forwardedSignal = signal;
|
||||
try {
|
||||
childProcess.kill?.(signal);
|
||||
} catch {
|
||||
// Best-effort only. Exit handling still happens via the child "exit" event.
|
||||
}
|
||||
};
|
||||
|
||||
onSigInt = () => {
|
||||
forwardSignal("SIGINT");
|
||||
};
|
||||
onSigTerm = () => {
|
||||
forwardSignal("SIGTERM");
|
||||
};
|
||||
|
||||
deps.process.on("SIGINT", onSigInt);
|
||||
deps.process.on("SIGTERM", onSigTerm);
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
childProcess.on("exit", (exitCode, exitSignal) => {
|
||||
resolve({ exitCode, exitSignal, forwardedSignal });
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
cleanupSignals();
|
||||
}
|
||||
};
|
||||
|
||||
const getInterruptedSpawnExitCode = (res) => {
|
||||
if (res.exitSignal) {
|
||||
return getSignalExitCode(res.exitSignal);
|
||||
}
|
||||
if (res.forwardedSignal) {
|
||||
return getSignalExitCode(res.forwardedSignal);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const runOpenClaw = async (deps) => {
|
||||
const nodeProcess = deps.spawn(deps.execPath, ["openclaw.mjs", ...deps.args], {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
pipeSpawnedOutput(nodeProcess, deps);
|
||||
const res = await waitForSpawnedProcess(nodeProcess, deps);
|
||||
const interruptedExitCode = getInterruptedSpawnExitCode(res);
|
||||
if (interruptedExitCode !== null) {
|
||||
return interruptedExitCode;
|
||||
}
|
||||
return res.exitCode ?? 1;
|
||||
};
|
||||
|
||||
const pipeSpawnedOutput = (childProcess, deps) => {
|
||||
if (!deps.outputTee) {
|
||||
return;
|
||||
}
|
||||
childProcess.stdout?.on("data", (chunk) => {
|
||||
deps.stdout.write(chunk);
|
||||
deps.outputTee.write(chunk);
|
||||
});
|
||||
childProcess.stderr?.on("data", (chunk) => {
|
||||
deps.stderr.write(chunk);
|
||||
deps.outputTee.write(chunk);
|
||||
});
|
||||
};
|
||||
|
||||
const closeRunNodeOutputTee = async (deps, exitCode) => {
|
||||
if (!deps.outputTee) {
|
||||
return exitCode;
|
||||
}
|
||||
try {
|
||||
await deps.outputTee.close();
|
||||
} catch (error) {
|
||||
deps.stderr.write(
|
||||
`[openclaw] Failed to write output log: ${error?.message ?? "unknown error"}\n`,
|
||||
);
|
||||
return exitCode === 0 ? 1 : exitCode;
|
||||
}
|
||||
return exitCode;
|
||||
};
|
||||
|
||||
const syncRuntimeArtifacts = (deps) => {
|
||||
try {
|
||||
deps.runRuntimePostBuild({ cwd: deps.cwd });
|
||||
} catch (error) {
|
||||
logRunner(
|
||||
`Failed to write runtime build artifacts: ${error?.message ?? "unknown error"}`,
|
||||
deps,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const writeBuildStamp = (deps) => {
|
||||
try {
|
||||
writeDistBuildStamp({
|
||||
cwd: deps.cwd,
|
||||
fs: deps.fs,
|
||||
spawnSync: deps.spawnSync,
|
||||
});
|
||||
} catch (error) {
|
||||
// Best-effort stamp; still allow the runner to start.
|
||||
logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`, deps);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldSkipCleanWatchRuntimeSync = (deps) => deps.env.OPENCLAW_WATCH_MODE === "1";
|
||||
|
||||
export async function runNodeMain(params = {}) {
|
||||
const deps = {
|
||||
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: params.cwd ?? process.cwd(),
|
||||
args: params.args ?? process.argv.slice(2),
|
||||
env: params.env ? { ...params.env } : { ...process.env },
|
||||
runRuntimePostBuild: params.runRuntimePostBuild ?? runRuntimePostBuild,
|
||||
};
|
||||
|
||||
deps.distRoot = path.join(deps.cwd, "dist");
|
||||
deps.distEntry = path.join(deps.distRoot, "/entry.js");
|
||||
deps.buildStampPath = path.join(deps.distRoot, ".buildstamp");
|
||||
deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({
|
||||
name: sourceRoot,
|
||||
path: path.join(deps.cwd, sourceRoot),
|
||||
}));
|
||||
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
|
||||
deps.privateQaRequiredDistEntries = resolvePrivateQaRequiredDistEntries(deps.distRoot);
|
||||
if (deps.args[0] === "qa") {
|
||||
deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1";
|
||||
deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
|
||||
}
|
||||
deps.outputTee = createRunNodeOutputTee(deps);
|
||||
|
||||
try {
|
||||
let exitCode = 1;
|
||||
const buildRequirement = resolveBuildRequirement(deps);
|
||||
if (!buildRequirement.shouldBuild) {
|
||||
if (!shouldSkipCleanWatchRuntimeSync(deps) && !syncRuntimeArtifacts(deps)) {
|
||||
return await closeRunNodeOutputTee(deps, 1);
|
||||
}
|
||||
exitCode = await runOpenClaw(deps);
|
||||
return await closeRunNodeOutputTee(deps, exitCode);
|
||||
}
|
||||
|
||||
logRunner(
|
||||
`Building TypeScript (dist is stale: ${buildRequirement.reason} - ${formatBuildReason(buildRequirement.reason)}).`,
|
||||
deps,
|
||||
);
|
||||
const buildCmd = deps.execPath;
|
||||
const buildArgs = compilerArgs;
|
||||
const build = deps.spawn(buildCmd, buildArgs, {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
pipeSpawnedOutput(build, deps);
|
||||
|
||||
const buildRes = await waitForSpawnedProcess(build, deps);
|
||||
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);
|
||||
if (interruptedExitCode !== null) {
|
||||
return await closeRunNodeOutputTee(deps, interruptedExitCode);
|
||||
}
|
||||
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
|
||||
return await closeRunNodeOutputTee(deps, buildRes.exitCode);
|
||||
}
|
||||
if (!syncRuntimeArtifacts(deps)) {
|
||||
return await closeRunNodeOutputTee(deps, 1);
|
||||
}
|
||||
writeBuildStamp(deps);
|
||||
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((code) => process.exit(code))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue