新增 Web 后台与 AGC 一键开发入口
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m36s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m22s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

新增 dev:all 跨平台编排共享后端、主站、后台与 AGC。

保持 npm run dev、npm run agc 及单模块入口行为不变。

补充参数、状态身份、进程退出收束测试。

同步本地开发运维文档、项目概览和决策记录。
This commit is contained in:
2026-10-01 16:47:16 +08:00
parent 55375b1406
commit c383355301
6 changed files with 652 additions and 1 deletions
+478
View File
@@ -0,0 +1,478 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveAgcDevEndpoint } from '../apps/ai-game-creator-shell/scripts/dev-port.mjs';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '..');
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const agcDatabase = 'genarrative-game-creator-dev';
const agcDataDir = resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator/data',
);
const agcDataDirArgument = 'server-rs/.spacetimedb/ai-game-creator/data';
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const rootReadyTimeoutMs = 660_000;
const rootReadyPollMs = 500;
const childGracefulTimeoutMs = 2_500;
const childForceTimeoutMs = 2_000;
const rootOnlyOptions = new Set([
'--database',
'--spacetime-data-dir',
'--database=',
'--spacetime-data-dir=',
]);
const optionsWithValues = new Set([
'--api-host',
'--api-port',
'--bgfilter-worker-host',
'--bgfilter-worker-port',
'--web-host',
'--web-port',
'--admin-web-host',
'--admin-web-port',
'--spacetime-host',
'--spacetime-port',
'--port-range',
'--log',
'--spacetime-timeout-seconds',
'--api-timeout-seconds',
'--migration-bootstrap-secret',
]);
function isRootOnlyOption(argument) {
return [...rootOnlyOptions].some(
(option) => argument === option || argument.startsWith(option),
);
}
function validateDevAllArgs(argv = []) {
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (!argument.startsWith('-')) {
throw new Error(
`npm run dev:all 只接受 dev 选项,不接受子命令或位置参数: ${argument}`,
);
}
if (isRootOnlyOption(argument)) {
throw new Error(
'npm run dev:all 固定使用 AGC 配套 database 与 SpacetimeDB data dir,不能覆盖 --database 或 --spacetime-data-dir',
);
}
if (optionsWithValues.has(argument)) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`dev:all 选项 ${argument} 缺少值`);
}
index += 1;
}
}
}
function buildRootDevArgs(argv = []) {
validateDevAllArgs(argv);
return [
'run',
'dev',
'--',
'--database',
agcDatabase,
'--spacetime-data-dir',
agcDataDirArgument,
'--preserve-database',
'--no-interactive',
...argv,
];
}
function buildDevAllEnvironment(baseEnv = process.env, agcVitePort) {
if (!Number.isInteger(agcVitePort) || agcVitePort < 1024) {
throw new Error(`AGC Vite 端口无效: ${agcVitePort}`);
}
return {
...baseEnv,
GENARRATIVE_AGC_VITE_PORT: String(agcVitePort),
AGC_DEV_ADMIN_WEB: '0',
};
}
function readDevStackState(path = devStackStatePath) {
if (!existsSync(path)) {
return null;
}
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch {
return null;
}
}
async function isHttpReady(url, timeoutMs = 1_000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return response.status >= 200 && response.status < 500;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function areRootFrontendsReady(state) {
const urls = [
state?.services?.web?.url,
state?.services?.['admin-web']?.url,
].filter(Boolean);
if (urls.length !== 2) {
return false;
}
const results = await Promise.all(urls.map((url) => isHttpReady(url)));
return results.every(Boolean);
}
function isActiveService(service, expectedRepoRoot, expectedInstanceId) {
return Boolean(
service &&
service.status === 'running' &&
service.url &&
service.repoRoot &&
resolve(service.repoRoot) === resolve(expectedRepoRoot) &&
service.instanceId === expectedInstanceId,
);
}
function isSharedRootStackReady(
state,
{
expectedRepoRoot = repoRoot,
expectedDatabase = agcDatabase,
expectedDataDir = agcDataDir,
} = {},
) {
if (!state || state.command !== 'all') {
return false;
}
const instanceId = String(state.instanceId ?? '').trim();
if (!instanceId || state.database !== expectedDatabase) {
return false;
}
if (
!state.repoRoot ||
resolve(state.repoRoot) !== resolve(expectedRepoRoot) ||
!state.spacetimeDataDir ||
resolve(state.spacetimeDataDir) !== resolve(expectedDataDir)
) {
return false;
}
const services = state.services ?? {};
const spacetime = services.spacetime;
const requiredServices = [
services['api-server'],
services['bgfilter-worker'],
services.web,
services['admin-web'],
];
if (
!spacetime ||
!['running', 'reused'].includes(spacetime.status) ||
!spacetime.url ||
!spacetime.repoRoot ||
resolve(spacetime.repoRoot) !== resolve(expectedRepoRoot) ||
spacetime.instanceId !== instanceId
) {
return false;
}
return requiredServices.every((service) =>
isActiveService(service, expectedRepoRoot, instanceId),
);
}
function waitForChildExit(child) {
if (!child) {
return Promise.resolve({ code: null, signal: null, error: null });
}
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve({
code: child.exitCode,
signal: child.signalCode,
error: null,
});
}
return new Promise((resolveExit) => {
const onError = (error) => {
cleanup();
resolveExit({ code: null, signal: null, error });
};
const onExit = (code, signal) => {
cleanup();
resolveExit({ code, signal, error: null });
};
const cleanup = () => {
child.off('error', onError);
child.off('exit', onExit);
};
child.once('error', onError);
child.once('exit', onExit);
});
}
function spawnManaged(command, args, env) {
return spawn(command, args, {
cwd: repoRoot,
env,
stdio: 'inherit',
shell: false,
detached: process.platform !== 'win32',
windowsHide: false,
});
}
function wait(ms) {
return new Promise((resolveWait) => setTimeout(resolveWait, ms));
}
async function waitForRootStack(rootChild, options = {}) {
const timeoutMs = options.timeoutMs ?? rootReadyTimeoutMs;
const pollMs = options.pollMs ?? rootReadyPollMs;
const readState = options.readState ?? (() => readDevStackState());
const expected = options.expected ?? {};
const childExit = waitForChildExit(rootChild);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const state = readState();
if (
isSharedRootStackReady(state, expected) &&
(options.checkFrontends === false || (await areRootFrontendsReady(state)))
) {
return state;
}
const result = await Promise.race([
childExit,
wait(pollMs).then(() => null),
]);
if (result) {
const detail = result.error
? result.error.message
: result.signal
? `signal=${result.signal}`
: `code=${result.code ?? 1}`;
throw new Error(`根开发栈未就绪即退出: ${detail}`);
}
}
throw new Error('等待根开发栈五个服务就绪超时');
}
async function runTaskkill(pid) {
return new Promise((resolveKill) => {
const killer = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
shell: false,
});
const timer = setTimeout(() => {
killer.kill('SIGKILL');
resolveKill();
}, childGracefulTimeoutMs + childForceTimeoutMs);
killer.once('error', () => {
clearTimeout(timer);
resolveKill();
});
killer.once('exit', () => {
clearTimeout(timer);
resolveKill();
});
});
}
async function terminateChildTree(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
if (process.platform === 'win32') {
if (Number.isInteger(child.pid)) {
await runTaskkill(child.pid);
} else {
child.kill('SIGTERM');
}
return;
}
const processGroupId = Number.isInteger(child.pid) ? -child.pid : child.pid;
if (!Number.isInteger(processGroupId)) {
return;
}
try {
process.kill(processGroupId, 'SIGTERM');
} catch {
return;
}
const stoppedGracefully = await waitUntilChildExit(
child,
childGracefulTimeoutMs,
);
if (stoppedGracefully) {
return;
}
try {
process.kill(processGroupId, 'SIGKILL');
} catch {
return;
}
await waitUntilChildExit(child, childForceTimeoutMs);
}
async function waitUntilChildExit(child, timeoutMs) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return true;
}
const result = await Promise.race([
waitForChildExit(child),
wait(timeoutMs).then(() => null),
]);
return result !== null;
}
async function runDevAll(argv = process.argv.slice(2), dependencies = {}) {
validateDevAllArgs(argv);
const resolveEndpoint = dependencies.resolveEndpoint ?? resolveAgcDevEndpoint;
const spawnProcess = dependencies.spawnProcess ?? spawnManaged;
const stopProcessTree = dependencies.stopProcessTree ?? terminateChildTree;
const readState = dependencies.readState ?? (() => readDevStackState());
const waitForRoot = dependencies.waitForRoot ?? waitForRootStack;
let rootChild = null;
let agcChild = null;
let shuttingDown = false;
let signalExitCode = 0;
let stopPromise = null;
const signalHandlers = new Map();
const stopAll = () => {
if (stopPromise) {
return stopPromise;
}
shuttingDown = true;
stopPromise = (async () => {
await stopProcessTree(agcChild);
await stopProcessTree(rootChild);
})();
return stopPromise;
};
const handleSignal = (signal) => {
signalExitCode = signal === 'SIGTERM' ? 143 : 130;
void stopAll();
};
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => handleSignal(signal);
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
try {
const endpoint = await resolveEndpoint({
env: process.env,
strictConfigured: true,
});
const env = buildDevAllEnvironment(process.env, endpoint.port);
const rootArgs = buildRootDevArgs(argv);
console.log(
`[dev:all] 保留 AGC Vite 端口 ${endpoint.url},启动共享根开发栈`,
);
rootChild = spawnProcess(npmCommand, rootArgs, env);
const rootExit = waitForChildExit(rootChild);
await waitForRoot(rootChild, {
readState,
expected: {
expectedRepoRoot: repoRoot,
expectedDatabase: agcDatabase,
expectedDataDir: agcDataDir,
},
});
if (rootChild.exitCode !== null || rootChild.signalCode !== null) {
throw new Error('根开发栈已退出,不能启动 AGC');
}
if (shuttingDown) {
await stopAll();
return signalExitCode || 1;
}
console.log('[dev:all] 根开发栈已就绪,启动 AGC Vite 与 Tauri');
agcChild = spawnProcess(npmCommand, ['run', 'agc'], env);
const agcExit = waitForChildExit(agcChild);
const result = await Promise.race([
rootExit.then((exit) => ({ owner: 'root', exit })),
agcExit.then((exit) => ({ owner: 'agc', exit })),
]);
if (shuttingDown) {
await stopAll();
return signalExitCode || 1;
}
const detail = result.exit.error
? result.exit.error.message
: result.exit.signal
? `signal=${result.exit.signal}`
: `code=${result.exit.code ?? 0}`;
const exitCode =
result.exit.error || result.exit.signal ? 1 : (result.exit.code ?? 0);
console.error(`[dev:all] ${result.owner} 进程退出: ${detail}`);
await stopAll();
return exitCode;
} catch (error) {
console.error(
`[dev:all] ${error instanceof Error ? error.message : error}`,
);
await stopAll();
return signalExitCode || 1;
} finally {
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
}
}
export {
agcDatabase,
agcDataDir,
buildDevAllEnvironment,
buildRootDevArgs,
isSharedRootStackReady,
readDevStackState,
runDevAll,
validateDevAllArgs,
};
if (
process.argv[1] &&
resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))
) {
process.exitCode = await runDevAll();
}