Files
Genarrative/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
T
suzmii 4f3f0f24ff 接入AGC后台模型目录与对话模型选择
新增后台 AGC 模型目录、别名、启停和默认项管理

客户端设置页恢复原状,对话框右下角按别名选择模型

服务端按稳定模型标识映射并校验实际模型白名单

修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查

补充迁移、文档、启动与模型选择测试
2026-09-05 19:10:49 +08:00

704 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
agcVitePortEnvKey,
readAgcDevEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const defaultApiTarget =
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev';
const backendSpacetimeDataDir = resolve(
repoRoot,
'server-rs/.spacetimedb/ai-game-creator/data',
);
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const childLifecycles = new WeakMap();
function readJson(path) {
if (!existsSync(path)) {
return null;
}
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch {
return null;
}
}
function httpGetText(url, timeout = 1000) {
return new Promise((resolveRequest) => {
const request = http.get(url, { timeout }, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
if (body.length < 4096) {
body += chunk;
}
});
response.on('end', () => {
resolveRequest({
statusCode: response.statusCode ?? 0,
body,
});
});
});
request.on('timeout', () => {
request.destroy();
resolveRequest(null);
});
request.on('error', () => resolveRequest(null));
});
}
async function isHttpReady(url) {
const response = await httpGetText(url);
return Boolean(
response && response.statusCode >= 200 && response.statusCode < 300,
);
}
function resolveBackendTargetsFromState(
state,
{
requireAgcBackend = false,
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
fallbackApiTarget = defaultApiTarget,
} = {},
) {
const apiServer = state?.services?.['api-server'];
const spacetime = state?.services?.spacetime;
const bgfilterWorker = state?.services?.['bgfilter-worker'];
const isActive = (service) =>
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
const database = typeof state?.database === 'string' ? state.database : '';
const spacetimeDataDir =
typeof state?.spacetimeDataDir === 'string'
? resolve(state.spacetimeDataDir)
: '';
const hasMatchingDatabase = database === expectedDatabase;
const hasMatchingDataDir =
Boolean(spacetimeDataDir) &&
spacetimeDataDir === resolve(expectedSpacetimeDataDir);
const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir;
const canReuseState = !requireAgcBackend || hasMatchingBackend;
const apiUrl =
canReuseState && isActive(apiServer) && apiServer.url
? apiServer.url
: requireAgcBackend
? ''
: fallbackApiTarget;
const spacetimeUrl =
canReuseState && isActive(spacetime) && spacetime.url
? spacetime.url
: requireAgcBackend
? ''
: 'http://127.0.0.1:3101';
const bgfilterWorkerUrl =
canReuseState && isActive(bgfilterWorker) && bgfilterWorker.url
? bgfilterWorker.url
: '';
return {
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
database,
spacetimeDataDir,
hasMatchingDatabase,
hasMatchingDataDir,
hasMatchingBackend,
};
}
function readBackendTargets({ requireAgcBackend = false } = {}) {
return resolveBackendTargetsFromState(readJson(devStackStatePath), {
requireAgcBackend,
});
}
async function isBackendReady({
state = readJson(devStackStatePath),
isReady = isHttpReady,
} = {}) {
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
});
return (
hasMatchingBackend &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
Boolean(bgfilterWorkerUrl) &&
(await isReady(`${apiUrl}/healthz`)) &&
(await isReady(`${spacetimeUrl}/v1/ping`)) &&
(await isReady(`${bgfilterWorkerUrl}/readyz`))
);
}
async function readExistingViteServer(endpoint = readAgcDevEndpoint()) {
return httpGetText(endpoint.url);
}
function isVitePortListening(endpoint = readAgcDevEndpoint()) {
return new Promise((resolveRequest) => {
const socket = net.connect({ host: endpoint.host, port: endpoint.port });
socket.once('connect', () => {
socket.destroy();
resolveRequest(true);
});
socket.once('error', () => resolveRequest(false));
socket.setTimeout(1000, () => {
socket.destroy();
resolveRequest(true);
});
});
}
function isAiGameCreatorServer(response) {
return (
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
response.body.includes('<title>陶泥儿</title>') &&
response.body.includes('/src/main.tsx')
);
}
async function readExistingViteMarker(endpoint = readAgcDevEndpoint()) {
const response = await httpGetText(endpoint.markerUrl, 2000);
if (!response || response.statusCode !== 200) {
return null;
}
try {
return JSON.parse(response.body);
} catch {
return null;
}
}
async function preflightExistingVite({
endpoint = readAgcDevEndpoint(),
readServer = readExistingViteServer,
portListening = isVitePortListening,
readMarker = readExistingViteMarker,
} = {}) {
const existing = await readServer(endpoint);
if (!existing) {
if (await portListening(endpoint)) {
throw new Error(
`${endpoint.url} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
);
}
return { status: 'available', apiTarget: '' };
}
if (!isAiGameCreatorServer(existing)) {
throw new Error(
`${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
const marker = await readMarker(endpoint);
const markerApiTarget =
marker?.schemaVersion === 1 &&
marker?.app === 'ai-game-creator-shell' &&
typeof marker?.apiTarget === 'string'
? marker.apiTarget
: '';
const actualTarget = markerApiTarget || 'unknown';
throw new Error(
`${endpoint.url} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
);
}
function spawnChild(command, args, options, spawnImpl = spawn) {
const isPosix = process.platform !== 'win32';
const useShell = options.shell ?? !isPosix;
const child = spawnImpl(command, args, {
...options,
shell: useShell,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: isPosix,
stdio: 'inherit',
});
const lifecycle = {
failure: null,
promise: null,
// detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后
// child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。
processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null,
};
lifecycle.promise = new Promise((resolveLifecycle) => {
child.once('error', (error) => {
lifecycle.failure = { type: 'error', error };
resolveLifecycle(lifecycle.failure);
});
child.once('exit', (code, signal) => {
if (!lifecycle.failure) {
lifecycle.failure = { type: 'exit', code, signal };
}
resolveLifecycle(lifecycle.failure);
});
});
childLifecycles.set(child, lifecycle);
return child;
}
function readChildFailure(child) {
return childLifecycles.get(child)?.failure ?? null;
}
function waitForChildTermination(child) {
const lifecycle = childLifecycles.get(child);
if (!lifecycle) {
return Promise.resolve({
type: 'error',
error: new Error('子进程未注册生命周期监听'),
});
}
return lifecycle.promise;
}
function formatChildFailure(failure) {
if (failure?.type === 'error') {
return failure.error instanceof Error
? failure.error.message
: String(failure.error);
}
return failure?.signal
? `signal=${failure.signal}`
: `code=${failure?.code ?? 0}`;
}
function stopChild(child, signal = 'SIGTERM') {
if (!child) {
return;
}
if (process.platform !== 'win32') {
const processGroupId = childLifecycles.get(child)?.processGroupId;
if (Number.isInteger(processGroupId)) {
try {
process.kill(-processGroupId, signal);
return;
} catch (error) {
if (error?.code === 'ESRCH') {
return;
}
// leader 尚存活时保留 direct child fallbackleader 已退出则仍以
// 负 PGID kill 的失败为准,不能误以为 descendants 已清理。
if (child.exitCode != null || child.signalCode != null) {
return;
}
}
}
}
if (child.exitCode != null || child.signalCode != null) {
return;
}
try {
child.kill(signal);
} catch {
// ignore cleanup races
}
}
function readLinuxProcessGroupAlive(
processGroupId,
{ readdirImpl = readdirSync, readFileImpl = readFileSync } = {},
) {
let processIds;
try {
processIds = readdirImpl('/proc');
} catch {
return null;
}
for (const processId of processIds) {
if (!/^\d+$/.test(processId)) {
continue;
}
let stat;
try {
stat = readFileImpl(`/proc/${processId}/stat`, 'utf8');
} catch {
continue;
}
const commandEnd = stat.lastIndexOf(') ');
if (commandEnd < 0) {
continue;
}
const [state, , processGroup] = stat
.slice(commandEnd + 2)
.trim()
.split(/\s+/);
if (
Number(processGroup) === processGroupId &&
state !== 'Z' &&
state !== 'X'
) {
return true;
}
}
return false;
}
function isProcessGroupAlive(
processGroupId,
{
platform = process.platform,
killImpl = process.kill,
readLinuxGroupAlive = readLinuxProcessGroupAlive,
} = {},
) {
if (!Number.isInteger(processGroupId)) {
return false;
}
try {
killImpl(-processGroupId, 0);
} catch (error) {
return error?.code !== 'ESRCH';
}
if (platform === 'linux') {
const linuxGroupAlive = readLinuxGroupAlive(processGroupId);
if (typeof linuxGroupAlive === 'boolean') {
return linuxGroupAlive;
}
}
return true;
}
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return true;
}
await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs));
}
return check();
}
function runWindowsTaskkill(
processId,
{ spawnImpl = spawn, timeoutMs = 5000 } = {},
) {
return new Promise((resolveRequest) => {
const taskkill = spawnImpl(
'taskkill.exe',
['/PID', String(processId), '/T', '/F'],
{
shell: false,
stdio: 'ignore',
windowsHide: true,
},
);
let settled = false;
const timeout = setTimeout(() => {
try {
taskkill.kill('SIGKILL');
} catch {
// ignore taskkill timeout races
}
finish({ timedOut: true, code: null, error: null });
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolveRequest(result);
};
taskkill.once('error', (error) =>
finish({ timedOut: false, code: null, error }),
);
taskkill.once('exit', (code) =>
finish({ timedOut: false, code: code ?? 0, error: null }),
);
});
}
async function terminateChildTree(
child,
{
platform = process.platform,
gracefulTimeoutMs = 2500,
forceTimeoutMs = 2000,
killImpl = process.kill,
taskkillImpl = runWindowsTaskkill,
} = {},
) {
if (!child) {
return { stopped: true, forced: false };
}
if (platform === 'win32') {
if (!Number.isInteger(child.pid)) {
stopChild(child, 'SIGTERM');
return { stopped: true, forced: false };
}
const result = await taskkillImpl(child.pid);
return {
stopped:
!result?.timedOut &&
!result?.error &&
[0, 128].includes(result?.code ?? 0),
forced: true,
result,
};
}
const processGroupId = childLifecycles.get(child)?.processGroupId;
if (!Number.isInteger(processGroupId)) {
stopChild(child, 'SIGTERM');
const lifecycle = childLifecycles.get(child);
if (lifecycle) {
await Promise.race([
lifecycle.promise,
new Promise((resolveWait) =>
setTimeout(resolveWait, gracefulTimeoutMs),
),
]);
}
if (child.exitCode == null && child.signalCode == null) {
stopChild(child, 'SIGKILL');
return { stopped: false, forced: true };
}
return { stopped: true, forced: false };
}
stopChild(child, 'SIGTERM');
if (
await waitUntil(
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
gracefulTimeoutMs,
)
) {
return { stopped: true, forced: false };
}
try {
killImpl(-processGroupId, 'SIGKILL');
} catch (error) {
if (error?.code !== 'ESRCH') {
return { stopped: false, forced: true, error };
}
}
const stopped = await waitUntil(
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
forceTimeoutMs,
);
return { stopped, forced: true };
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isBackendReady()) {
return readBackendTargets();
}
const failure = readChildFailure(backendChild);
if (failure) {
throw new Error(`配套后端启动失败: ${formatChildFailure(failure)}`);
}
await Promise.race([
new Promise((resolveWait) => setTimeout(resolveWait, 1000)),
waitForChildTermination(backendChild),
]);
}
throw new Error('等待配套后端和数据库启动超时');
}
async function ensureBackend({
onBackendChild = () => {},
checkBackendReady = isBackendReady,
resolveTargets = readBackendTargets,
spawnBackend = () =>
spawnChild(
npm,
[
'--prefix',
'../..',
'run',
'agc:backend',
'--',
'--database',
backendDatabase,
'--spacetime-data-dir',
backendSpacetimeDataDir,
'--preserve-database',
'--no-interactive',
],
{ cwd: appRoot },
),
waitUntilReady = waitForBackendReady,
} = {}) {
if (await checkBackendReady()) {
const targets = resolveTargets();
console.log(`[ai-game-creator-shell] reuse backend ${targets.apiUrl}`);
return { backendChild: null, targets };
}
console.log('[ai-game-creator-shell] starting backend stack');
const backendChild = spawnBackend();
try {
onBackendChild(backendChild);
const targets = await waitUntilReady(backendChild);
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
return { backendChild, targets };
} catch (error) {
stopChild(backendChild);
throw error;
}
}
async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
const { apiUrl } = readBackendTargets();
if (apiUrl !== apiTarget) {
throw new Error(
`dev-stack state API target ${apiUrl} does not match paired backend ${apiTarget}.`,
);
}
const existing = await readExistingViteServer(endpoint);
if (existing) {
if (isAiGameCreatorServer(existing)) {
throw new Error(
`${endpoint.url} is already running and cannot be safely reused. Stop it before starting Tauri dev.`,
);
}
throw new Error(
`${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
return spawnChild(
npm,
[
'--prefix',
'../..',
'exec',
'vite',
'--',
'--config',
'vite.config.ts',
'--port',
String(endpoint.port),
],
{ cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
);
}
async function main() {
let backendChild = null;
let viteChild = null;
let shutdownSignal = '';
const signalHandlers = new Map();
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => {
shutdownSignal = signal;
stopChild(viteChild, signal);
stopChild(backendChild, signal);
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
try {
const endpoint = await resolveAgcDevEndpoint({ strictConfigured: true });
process.env[agcVitePortEnvKey] = String(endpoint.port);
await preflightExistingVite({ endpoint });
const backend = await ensureBackend({
onBackendChild(child) {
backendChild = child;
if (shutdownSignal) {
stopChild(child, shutdownSignal);
}
},
});
backendChild = backend.backendChild;
if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
}
viteChild = await startVite(backend.targets.apiUrl, endpoint);
if (shutdownSignal) {
stopChild(viteChild, shutdownSignal);
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
}
const children = [backendChild, viteChild].filter(Boolean);
if (children.length === 0) {
return 0;
}
const failure = await Promise.race(
children.map((child) => waitForChildTermination(child)),
);
stopChild(viteChild);
stopChild(backendChild);
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
} catch (error) {
stopChild(viteChild);
stopChild(backendChild);
console.error(
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
} finally {
await Promise.all([
terminateChildTree(viteChild),
terminateChildTree(backendChild),
]);
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
}
}
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
export {
ensureBackend,
formatChildFailure,
isAiGameCreatorServer,
isBackendReady,
isDirectModuleExecution,
isProcessGroupAlive,
preflightExistingVite,
readChildFailure,
readExistingViteServer,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
stopChild,
terminateChildTree,
waitForBackendReady,
waitForChildTermination,
};
if (isDirectModuleExecution()) {
process.exitCode = await main();
}