新增 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
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:
@@ -1,4 +1,10 @@
|
||||
# 决策记录
|
||||
## 2026-10-01 Web、后台与 AGC 一键联调
|
||||
|
||||
- 背景:Web、管理后台和 AGC 同时开发时,分别启动入口容易产生两套 API/worker/SpacetimeDB,以及重复后台 Vite。
|
||||
- 决策:新增 `npm run dev:all`,保持 `npm run dev` 现有主站完整栈语义不变;一键入口固定使用 AGC 的 database/data dir,先启动根完整栈,待五个服务就绪后由 AGC 复用该后端,再启动 AGC Vite 与 Tauri,并关闭 AGC 自带后台。
|
||||
- 影响范围:根开发脚本、AGC 开发启动编排、本地开发运维文档;不改变 API、schema、生产部署和独立 `npm run agc` 行为。
|
||||
- 验证方式:参数/状态单测、开发栈健康端点 smoke、`.app/dev-stack.json` 身份复用检查、进程树收束检查。
|
||||
|
||||
## 2026-09-30 release 渠道移除产品名与包名后缀
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Genarrative / 陶泥儿当前主站聚焦图片画布创作、编辑器项目与
|
||||
|
||||
## 本地开发端口真相
|
||||
|
||||
`scripts/dev.mjs` 的默认值是 Web `3000`、API `8082`、BgFilter worker `8083`、SpacetimeDB `3101`、后台 Web `3102`。Linux 用户端口段会把这五个服务映射到 `start` 至 `start+4`,AGC Vite 使用 `start+5`;显式端口或端口段配置可覆盖默认值。
|
||||
`scripts/dev.mjs` 的默认值是 Web `3000`、API `8082`、BgFilter worker `8083`、SpacetimeDB `3101`、后台 Web `3102`。Linux 用户端口段会把这五个服务映射到 `start` 至 `start+4`,AGC Vite 使用 `start+5`;显式端口或端口段配置可覆盖默认值。`npm run dev:all` 复用 AGC 的数据库和 data dir 启动一份共享后端,并在根 dev 栈就绪后启动 AGC Vite 与 Tauri;管理后台只由根 dev 栈启动。
|
||||
|
||||
端口的运行时权威始终是当前工作区的 `.app/dev-stack.json` 与启动日志(该文件可能在未启动时不存在),不得从文档默认值推断当前监听端口。前端代理、API URL、SpacetimeDB 地址和 AGC Vite 地址都必须读取同一份运行时状态。
|
||||
|
||||
|
||||
@@ -70,6 +70,15 @@ npm run dev
|
||||
- 后台 Vite。
|
||||
|
||||
`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令;稳定版状态还记录顶层 `repoRoot + instanceId`,每个服务记录 `repoRoot + instanceId + dataDir`,与端口组成复用身份。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。缺少身份字段或身份不匹配的旧状态不得被 AGC 静默复用。
|
||||
一键启动 Web、后台与 AGC:
|
||||
|
||||
```bash
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
`npm run dev:all` 保持 `npm run dev` 的主站完整栈语义不变,使用 AGC 当前配套后端的数据库 `genarrative-game-creator-dev` 与 data dir `server-rs/.spacetimedb/ai-game-creator/data` 启动一份 SpacetimeDB、BgFilter worker、api-server、主站 Vite 和后台 Vite;待这五个服务就绪后,再启动 AGC Vite 与 Tauri。AGC 通过匹配的 `.app/dev-stack.json` 复用这份后端,且设置 `AGC_DEV_ADMIN_WEB=0`,因此一键入口只保留一份管理后台。该入口不接受覆盖 database 或 SpacetimeDB data dir 的参数;需要独立数据库时分别使用 `npm run dev` / `npm run agc`。
|
||||
|
||||
一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。任一子进程异常退出都应停止另一侧并返回非零退出码;端口漂移和运行态地址仍以启动日志及 `.app/dev-stack.json` 为准。
|
||||
|
||||
通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:all": "node scripts/dev-all.mjs",
|
||||
"dev:spacetime": "node scripts/dev.mjs spacetime",
|
||||
"dev:api-server": "node scripts/dev.mjs api-server",
|
||||
"dev:bgfilter-worker": "node scripts/dev.mjs bgfilter-worker",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
agcDatabase,
|
||||
agcDataDir,
|
||||
buildDevAllEnvironment,
|
||||
buildRootDevArgs,
|
||||
isSharedRootStackReady,
|
||||
runDevAll,
|
||||
validateDevAllArgs,
|
||||
} from './dev-all.mjs';
|
||||
type FakeChild = EventEmitter & {
|
||||
pid: number;
|
||||
exitCode: number | null;
|
||||
signalCode: string | null;
|
||||
};
|
||||
|
||||
function makeReadyState(root = resolve('fixture-root')) {
|
||||
const instanceId = 'dev-fixture';
|
||||
const service = (status = 'running', url = 'http://127.0.0.1:1') => ({
|
||||
repoRoot: root,
|
||||
instanceId,
|
||||
status,
|
||||
url,
|
||||
});
|
||||
|
||||
return {
|
||||
command: 'all',
|
||||
instanceId,
|
||||
repoRoot: root,
|
||||
database: agcDatabase,
|
||||
spacetimeDataDir: agcDataDir,
|
||||
services: {
|
||||
spacetime: service('reused', 'http://127.0.0.1:3101'),
|
||||
'api-server': service('running', 'http://127.0.0.1:8082'),
|
||||
'bgfilter-worker': service('running', 'http://127.0.0.1:8083'),
|
||||
web: service('running', 'http://127.0.0.1:3000'),
|
||||
'admin-web': service('running', 'http://127.0.0.1:3102/admin/'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('dev:all 参数和共享开发栈合同', () => {
|
||||
test('根 dev 固定使用 AGC 配套数据库,并保留调用方选项', () => {
|
||||
expect(buildRootDevArgs(['--watch', '--api-port', '9082'])).toEqual([
|
||||
'run',
|
||||
'dev',
|
||||
'--',
|
||||
'--database',
|
||||
agcDatabase,
|
||||
'--spacetime-data-dir',
|
||||
'server-rs/.spacetimedb/ai-game-creator/data',
|
||||
'--preserve-database',
|
||||
'--no-interactive',
|
||||
'--watch',
|
||||
'--api-port',
|
||||
'9082',
|
||||
]);
|
||||
});
|
||||
|
||||
test('拒绝会破坏单后端拓扑的数据库和 data dir 覆盖', () => {
|
||||
expect(() => validateDevAllArgs(['--database', 'other'])).toThrow(
|
||||
'不能覆盖 --database 或 --spacetime-data-dir',
|
||||
);
|
||||
expect(() => validateDevAllArgs(['--database=other'])).toThrow(
|
||||
'不能覆盖 --database 或 --spacetime-data-dir',
|
||||
);
|
||||
expect(() => validateDevAllArgs(['--spacetime-data-dir', 'other'])).toThrow(
|
||||
'不能覆盖 --database 或 --spacetime-data-dir',
|
||||
);
|
||||
expect(() => validateDevAllArgs(['backend'])).toThrow('只接受 dev 选项');
|
||||
});
|
||||
|
||||
test('预留 AGC Vite 端口并关闭 AGC 自带后台', () => {
|
||||
const baseEnv = { AGC_DEV_ADMIN_WEB: '1' };
|
||||
const env = buildDevAllEnvironment(baseEnv, 3080);
|
||||
|
||||
expect(env.GENARRATIVE_AGC_VITE_PORT).toBe('3080');
|
||||
expect(env.AGC_DEV_ADMIN_WEB).toBe('0');
|
||||
expect(baseEnv.AGC_DEV_ADMIN_WEB).toBe('1');
|
||||
});
|
||||
|
||||
test('仅接受同一工作树、实例、数据库和 data dir 的完整栈', () => {
|
||||
const root = resolve('fixture-root');
|
||||
const ready = makeReadyState(root);
|
||||
|
||||
expect(
|
||||
isSharedRootStackReady(ready, {
|
||||
expectedRepoRoot: root,
|
||||
expectedDatabase: agcDatabase,
|
||||
expectedDataDir: agcDataDir,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const wrongInstance = structuredClone(ready);
|
||||
wrongInstance.services.web.instanceId = 'other-instance';
|
||||
expect(
|
||||
isSharedRootStackReady(wrongInstance, {
|
||||
expectedRepoRoot: root,
|
||||
expectedDatabase: agcDatabase,
|
||||
expectedDataDir: agcDataDir,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
const incomplete = structuredClone(ready);
|
||||
incomplete.services['admin-web'].status = 'idle';
|
||||
expect(
|
||||
isSharedRootStackReady(incomplete, {
|
||||
expectedRepoRoot: root,
|
||||
expectedDatabase: agcDatabase,
|
||||
expectedDataDir: agcDataDir,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('任一子进程退出时收束另一侧进程树', async () => {
|
||||
const children: FakeChild[] = [];
|
||||
const stopCalls: FakeChild[] = [];
|
||||
let nextPid = 100;
|
||||
const run = runDevAll([], {
|
||||
resolveEndpoint: async () => ({
|
||||
port: 3080,
|
||||
url: 'http://127.0.0.1:3080/',
|
||||
}),
|
||||
spawnProcess: () => {
|
||||
const child = new EventEmitter() as FakeChild;
|
||||
child.pid = nextPid;
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
nextPid += 1;
|
||||
children.push(child);
|
||||
if (children.length === 2) {
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 1;
|
||||
child.emit('exit', 1, null);
|
||||
});
|
||||
}
|
||||
return child;
|
||||
},
|
||||
waitForRoot: async () => ({}),
|
||||
stopProcessTree: async (child) => {
|
||||
stopCalls.push(child);
|
||||
if (child?.exitCode == null) {
|
||||
child.exitCode = 0;
|
||||
child.emit('exit', 0, null);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await expect(run).resolves.toBe(1);
|
||||
expect(children).toHaveLength(2);
|
||||
expect(stopCalls).toEqual([children[1], children[0]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user