Files
memind/scripts/build-portal-runtime.mjs
T
john 41a7747c09 fix(guard): make agent-run pause idempotent to stop portal restart loop
Repeat guard checks no longer kickstart Portal when code runs are already disabled, preventing WeChat background replies from being killed every minute. Adds resume script and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-04 13:41:17 +08:00

480 lines
18 KiB
JavaScript
Executable File

#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
const runtimeRoot = path.join(root, '.runtime', 'portal');
const distDir = path.join(root, 'dist');
const publicDir = path.join(root, 'public');
const nodeModulesDir = path.join(root, 'node_modules');
const schemaFile = path.join(root, 'schema.sql');
const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild');
const skipBuild = process.argv.includes('--skip-build');
const skipNodeModules = process.argv.includes('--skip-node-modules');
const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24';
const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules';
const externalPackages = [
'@img/sharp-darwin-arm64',
'@img/sharp-libvips-darwin-arm64',
'@node-rs/argon2',
'@resvg/resvg-js',
'chromium-bidi',
'chromium-bidi/*',
'debug',
'express',
'fsevents',
'http-proxy-middleware',
'jsonrepair',
'mysql2',
'mysql2/promise',
'playwright',
'playwright-core',
'qrcode',
'redis',
'sharp',
'undici',
];
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: root,
stdio: 'inherit',
env: process.env,
...options,
});
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code ?? 1}`));
});
child.on('error', reject);
});
}
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function remove(targetPath) {
await fs.rm(targetPath, { recursive: true, force: true });
}
async function copyDir(source, target) {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.cp(source, target, { recursive: true, dereference: false });
}
async function writeFile(targetPath, content) {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, content, 'utf8');
}
async function buildFrontend() {
if (skipBuild) return;
console.log('==> 构建 Portal 前端 dist');
await run('npm', ['run', 'build']);
}
async function bundleServer() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 Portal 后端为单文件 runtime');
const args = [
'server.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/server.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function bundleSandboxMcp() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 sandbox MCP 为单文件 runtime');
const args = [
'mindspace-sandbox-mcp.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/mindspace-sandbox-mcp.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function bundleAgentRunWorker() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 agent run worker 为单文件 runtime');
const args = [
'scripts/agent-run-worker.mjs',
'--bundle',
'--platform=node',
'--format=esm',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/scripts/agent-run-worker.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function copyRuntimeAssets() {
console.log('==> 拷贝运行时静态资源');
await remove(runtimeRoot);
await fs.mkdir(runtimeRoot, { recursive: true });
if (!(await exists(distDir))) {
throw new Error(`缺少 dist 目录: ${distDir}`);
}
await copyDir(distDir, path.join(runtimeRoot, 'dist'));
if (await exists(publicDir)) {
await copyDir(publicDir, path.join(runtimeRoot, 'public'));
}
if (await exists(schemaFile)) {
await fs.copyFile(schemaFile, path.join(runtimeRoot, 'schema.sql'));
}
const skillsDir = path.join(root, 'skills');
if (await exists(skillsDir)) {
console.log('==> 拷贝平台 skills 目录');
await copyDir(skillsDir, path.join(runtimeRoot, 'skills'));
}
}
async function copyNodeModules() {
if (skipNodeModules) return;
if (!(await exists(nodeModulesDir))) {
throw new Error(`缺少 node_modules 目录: ${nodeModulesDir}`);
}
const resolvedNodeModulesDir = await fs.realpath(nodeModulesDir);
console.log('==> 拷贝生产运行依赖 node_modules');
await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules'));
await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir);
}
async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) {
console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径');
const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`;
async function visit(dir) {
const entries = await fs.readdir(dir);
for (const entryName of entries) {
const fullPath = path.join(dir, entryName);
const stats = await fs.lstat(fullPath);
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(fullPath);
const normalizedTarget = path.normalize(linkTarget);
if (!normalizedTarget.startsWith(localNodeModulesRoot)) continue;
const relativeInsideNodeModules = normalizedTarget.slice(localNodeModulesRoot.length);
const runtimeTarget = path.join(runtimeNodeModulesDir, relativeInsideNodeModules);
const replacement = path.relative(path.dirname(fullPath), runtimeTarget);
await fs.unlink(fullPath);
await fs.symlink(replacement, fullPath);
continue;
}
if (stats.isDirectory()) {
await visit(fullPath);
}
}
}
await visit(runtimeNodeModulesDir);
}
async function copyMindspacePublicLinkTools() {
console.log('==> 拷贝 MindSpace 公开页链接检查工具');
await fs.copyFile(
path.join(root, 'mindspace-public-links.mjs'),
path.join(runtimeRoot, 'mindspace-public-links.mjs'),
);
await fs.mkdir(path.join(runtimeRoot, 'scripts'), { recursive: true });
await fs.copyFile(
path.join(root, 'scripts', 'check-mindspace-public-links.mjs'),
path.join(runtimeRoot, 'scripts', 'check-mindspace-public-links.mjs'),
);
}
async function writeMetadata() {
const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
const runtimeDependencies = Object.fromEntries(
externalPackages
.filter((pkg) => packageJson.dependencies?.[pkg])
.map((pkg) => [pkg, packageJson.dependencies[pkg]]),
);
const runtimePackageJson = {
name: `${packageJson.name}-portal-runtime`,
private: true,
type: 'module',
engines: {
node: '>=22',
},
dependencies: runtimeDependencies,
};
await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`);
const tunnelScript = path.join(root, 'scripts', 'memind-portal-tunnel.sh');
const runtimeScriptsDir = path.join(runtimeRoot, 'scripts');
await fs.mkdir(runtimeScriptsDir, { recursive: true });
if (await exists(tunnelScript)) {
await fs.copyFile(tunnelScript, path.join(runtimeScriptsDir, 'memind-portal-tunnel.sh'));
await fs.chmod(path.join(runtimeScriptsDir, 'memind-portal-tunnel.sh'), 0o755);
} else {
throw new Error(`缺少隧道脚本: ${tunnelScript}`);
}
await writeFile(
path.join(runtimeScriptsDir, 'run-memind-portal-prod.sh'),
[
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
'ROOT="$(cd "$(dirname "$0")/.." && pwd)"',
'cd "$ROOT"',
'',
'if [[ -f "${ROOT}/.env" ]]; then',
' set -a',
' # shellcheck disable=SC1091',
' source "${ROOT}/.env"',
' set +a',
'fi',
'',
'export NODE_ENV=production',
'export H5_PORT="${H5_PORT:-8081}"',
'export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"',
'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"',
'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"',
'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"',
'',
'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"',
'if [[ ! -x "${NODE_BIN}" ]]; then',
' NODE_BIN="$(command -v node)"',
'fi',
'',
'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"',
'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"',
'',
'exec "${NODE_BIN}" "${ROOT}/server.mjs"',
'',
].join('\n'),
);
await fs.copyFile(
path.join(root, 'scripts', 'load-env.mjs'),
path.join(runtimeRoot, 'scripts', 'load-env.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'wechat-mp-menu.mjs'),
path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-stream-runtime.mjs'),
path.join(runtimeRoot, 'scripts', 'check-stream-runtime.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-worker-drain.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-worker-metrics.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-worker-metrics.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-worker-heartbeat.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-worker-heartbeat.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'runtime-slo-report.mjs'),
path.join(runtimeRoot, 'scripts', 'runtime-slo-report.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-metrics-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-metrics-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-heartbeat-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-heartbeat-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-slo-report-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-slo-report-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-runtime-slo-soak-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-runtime-slo-soak-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-agent-run-worker-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-agent-run-worker-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-tool-runtime.mjs'),
path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-agent-code-run-entry.mjs'),
path.join(runtimeRoot, 'scripts', 'check-agent-code-run-entry.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'check-agent-run-worker.mjs'),
path.join(runtimeRoot, 'scripts', 'check-agent-run-worker.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'agent-run-guard.mjs'),
path.join(runtimeRoot, 'scripts', 'agent-run-guard.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'agent-run-guard-lib.mjs'),
path.join(runtimeRoot, 'scripts', 'agent-run-guard-lib.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'install-agent-run-guard-agent.sh'),
path.join(runtimeRoot, 'scripts', 'install-agent-run-guard-agent.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'resume-agent-run-prod.sh'),
path.join(runtimeRoot, 'scripts', 'resume-agent-run-prod.sh'),
);
await writeFile(
path.join(runtimeRoot, 'RUNBOOK.txt'),
[
'Portal runtime artifact',
'',
'This directory is meant to run on production without the source tree.',
'Required persisted items to inherit on the host:',
' .env',
' MindSpace/',
' data/',
' users/',
' .tailscale/',
' public/plaza-covers/',
' logs/',
'',
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)',
'',
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.',
'',
`Runtime install mode: ${runtimeInstallMode}`,
`Bundle target: ${runtimeNodeTarget}`,
'',
'Platform agent skills (synced into user workspaces):',
' skills/',
'Key runtime differences must stay in .env, not in the artifact:',
' DATABASE_URL / MYSQL_*',
' H5_PUBLIC_BASE_URL',
' TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1',
' H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT',
'',
'Deployment and operations transport:',
' H5 public domain: m.tkmind.cn',
' Current public path: m.tkmind.cn -> 105 nginx -> 103 Portal :8081',
' Test-only path: mm.tkmind.cn -> 105 nginx -> local/103 test routing when explicitly enabled.',
' Production H5 and WeChat links should use m.tkmind.cn unless a test rollout explicitly overrides H5_PUBLIC_BASE_URL.',
'',
'Streaming runtime operations:',
' node scripts/check-stream-runtime.mjs',
' node scripts/runtime-worker-drain.mjs status',
' node scripts/runtime-worker-drain.mjs reconcile',
' node scripts/runtime-worker-drain.mjs reconcile --apply',
' node scripts/runtime-worker-metrics.mjs status',
' node scripts/runtime-worker-metrics.mjs sample',
' bash scripts/install-runtime-metrics-agent.sh',
' node scripts/runtime-worker-heartbeat.mjs once',
' node scripts/runtime-worker-heartbeat.mjs serve',
' bash scripts/install-runtime-heartbeat-agent.sh',
' node scripts/runtime-slo-report.mjs',
' node scripts/runtime-slo-report.mjs --write-report',
' node scripts/runtime-slo-report.mjs --write-report --prune --retention-days 30',
' bash scripts/install-runtime-slo-report-agent.sh',
' bash scripts/install-runtime-slo-soak-agent.sh',
' node scripts/runtime-worker-drain.mjs drain goosed-3',
' node scripts/runtime-worker-drain.mjs undrain goosed-3',
' node scripts/check-tool-runtime.mjs',
' node scripts/check-agent-code-run-entry.mjs',
' curl -sk https://m.tkmind.cn/api/runtime/status # includes toolRuntime.queue',
' node scripts/agent-run-worker.mjs --status',
' node scripts/agent-run-worker.mjs --once',
' bash scripts/install-agent-run-worker-agent.sh # installs disabled by default',
' node scripts/check-agent-run-worker.mjs # read-only LaunchAgent/queue check',
' node scripts/agent-run-guard.mjs # dry-run auto-pause guard check',
' node scripts/agent-run-guard.mjs --apply # first-time pause only; repeat checks are no-ops',
' bash scripts/install-agent-run-guard-agent.sh # installs guard LaunchAgent',
' bash scripts/resume-agent-run-prod.sh # manual recovery after guard pause',
'',
].join('\n'),
);
}
async function bundleWechatMp() {
console.log('==> 打包 WeChat MP runtime bundle');
await run('node', ['scripts/build-wechat-mp-runtime.mjs']);
}
async function main() {
await buildFrontend();
await copyRuntimeAssets();
await bundleServer();
await bundleWechatMp();
await bundleSandboxMcp();
await bundleAgentRunWorker();
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
await copyNodeModules();
}
await copyMindspacePublicLinkTools();
await writeMetadata();
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-stream-runtime.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-metrics.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-heartbeat.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-slo-report.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'agent-run-worker.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-metrics-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-heartbeat-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-slo-report-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-slo-soak-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-agent-run-worker-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'agent-run-guard.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-agent-run-guard-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'resume-agent-run-prod.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-agent-run-worker.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'memind-portal-tunnel.sh'), 0o755);
console.log('');
console.log(`Portal runtime 已生成: ${runtimeRoot}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});