合并主分支并同步AGC开发端口修复

统一AGC开发端口分配与旧注册记录迁移逻辑
修复AGC实时清单测试竞态
补齐相关测试、开发文档与共享记忆
This commit is contained in:
2026-08-08 17:23:08 +08:00
20 changed files with 722 additions and 107 deletions
@@ -2,7 +2,7 @@
name: genarrative-dev-stack-port-routing
short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。
description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:bgfilter-worker / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、Rust 进程环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。
version: 1.1.0
version: 1.2.0
author: Hermes Agent
license: MIT
metadata:
@@ -17,7 +17,7 @@ metadata:
## 适用场景
- 修改 `scripts/dev.mjs``scripts/dev-utils.mjs``scripts/dev-stack-port-utils.mjs`
- 修改 `scripts/dev.mjs``scripts/dev-utils.mjs``scripts/dev-stack-port-utils.mjs` 或 AI 游戏创作客户端 dev 启动器
- 处理 `3000``3101``3102``8082` 等端口被占用导致本地开发栈启动失败。
- 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。
- 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。
@@ -32,10 +32,11 @@ metadata:
3. SpacetimeDB standalone`3101`,健康检查为 `http://127.0.0.1:<spacetime-port>/v1/ping`
4. 后台 Vite`3102`,后台地址为 `http://127.0.0.1:<admin-web-port>/admin/`
5. 独立 BgFilter worker`8083`,就绪检查为 `http://127.0.0.1:<bgfilter-worker-port>/readyz`
6. AI 游戏创作 Vite:非 Linux 兼容首选 `3080`Linux 使用当前用户端口段的 `start + 5`
端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。
Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE``--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start``api = start + 1``spacetime = start + 2``adminWeb = start + 3``bgfilterWorker = start + 4`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199``10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有统一端口探测和漂移逻辑,不读系统级注册表。
Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE``--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start``api = start + 1``spacetime = start + 2``adminWeb = start + 3``bgfilterWorker = start + 4``agcVite = start + 5`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199``10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有统一端口探测和漂移逻辑,不读系统级注册表。
## 实现入口
@@ -51,9 +52,12 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range`
- `scripts/dev.mjs`
- 解析 CLI 参数后统一计算 client host、端口、`SPACETIME_SERVER``RUST_SERVER_TARGET`
- 完整栈按 SpacetimeDB、publish、BgFilter worker readiness、api-server readiness、主站 Vite、后台 Vite 顺序启动。
- Linux 下会先申请系统级端口段并把它映射成五个 dev 端口;自动分配从 `10000-10099`Windows 则把第五个服务纳入原有统一参数解析与端口漂移逻辑。
- Linux 下会先申请系统级端口段并映射成六个预留槽位;主 dev 栈使用前五个,AGC Vite 使用 `start + 5`自动分配从 `10000-10099`Windows 的主 dev 栈和 AGC 则各自沿用统一端口探测与漂移逻辑。
- 完整栈和 `dev:api-server` 把两个 Rust 进程作为同一重启单元,先全部停止,再先启动 BgFilter worker、后启动 api-server;不要为同一份 Rust 源码创建两个并发 `cargo` watcher。
- 单模块命令复用同一套参数和 env 解析。
- `apps/ai-game-creator-shell/scripts/dev-port.mjs`
- 复用系统级用户端口段,解析 AGC Vite 的 `start + 5` 首选槽位。
- 把最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 同步给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI `--config` 同步 `build.devUrl`,并通过 Vite CLI `--port` 同步 `strictPort` 监听。
## 必须保持的传递链路
@@ -69,6 +73,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range`
8. 后台 Vite`ADMIN_API_TARGET``GENARRATIVE_API_TARGET``GENARRATIVE_API_PORT``--port=${ADMIN_WEB_PORT}`
9. 控制台日志:`[dev:ports]``[dev] web/admin web/api-server/bgfilter-worker/spacetime` 必须显示最终实际地址。
10. Linux 端口段注册:`[dev] port-range:``[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。
11. AI 游戏创作客户端:外层启动器解析最终 AGC Vite 端口后,通过 Tauri CLI `--config` 覆盖 `build.devUrl`,把同一 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 与配套后端端口解析器,并用 Vite CLI `--port` 启动严格监听;后端端口漂移必须跳过该预留端口。
如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。
@@ -128,6 +133,7 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap
- [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
- [ ] BgFilter worker 在 api-server 前 ready,父子共享实际 base URL / TokenRust watch 只触发一次组合重启。
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
- [ ] `npm run agc` / `npm run agc:game-chat` 在 Linux 使用用户段 `start + 5`Tauri、Vite、marker 和预检使用同一最终端口。
- [ ] 文档同步更新 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
- [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`
- [ ] 修改中文文件后运行 `npm run check:encoding`
@@ -62,6 +62,14 @@ const viteConfigSource = fs.readFileSync(
new URL('../vite.config.ts', import.meta.url),
'utf8',
);
const devPortSource = fs.readFileSync(
new URL('../scripts/dev-port.mjs', import.meta.url),
'utf8',
);
const startTauriDevSource = fs.readFileSync(
new URL('../scripts/start-tauri-dev.mjs', import.meta.url),
'utf8',
);
const appSource = [
readSourceTree(new URL('../src/', import.meta.url), '.ts'),
readSourceTree(new URL('../src/', import.meta.url), '.tsx'),
@@ -1217,7 +1225,7 @@ if (
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
throw new Error(
'AI game creator shell Tauri devUrl must stay on the fixed Vite dev port',
'AI game creator shell Tauri config must retain the non-launcher fallback devUrl',
);
}
@@ -1227,12 +1235,41 @@ if (!viteConfigSource.includes("host: '127.0.0.1'")) {
);
}
if (!viteConfigSource.includes('port: 3080')) {
if (
!viteConfigSource.includes('port: 3080') ||
!viteConfigSource.includes('port: server.config.server.port')
) {
throw new Error(
'AI game creator shell Vite dev port must match Tauri devUrl',
'AI game creator shell Vite config must retain its fallback and report the actual CLI-selected port',
);
}
for (const snippet of [
'mapDevPortsToPortRange',
'agcVitePort',
'resolveAgcDevEndpoint',
'GENARRATIVE_AGC_VITE_PORT',
]) {
if (!devPortSource.includes(snippet)) {
throw new Error(
`AI game creator shell dev port resolver drifted: ${snippet}`,
);
}
}
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"'--config'",
'configOverride',
]) {
if (!startTauriDevSource.includes(snippet)) {
throw new Error(
`AI game creator shell Tauri dev port injection drifted: ${snippet}`,
);
}
}
if (!viteConfigSource.includes('strictPort: true')) {
throw new Error(
'AI game creator shell Vite dev server must not drift away from Tauri devUrl',
@@ -1253,7 +1290,7 @@ if (
)
) {
throw new Error(
'AI game creator shell beforeDevCommand must reuse or start the fixed Vite dev server',
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
}
@@ -1463,12 +1500,15 @@ if (
}
for (const snippet of [
'const port = 3080',
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"response.body.includes('<title>AI 游戏创作</title>')",
'function isPortListening()',
'reuse existing Vite dev server',
'cannot be safely reused',
'non-HTTP or unrecognized server',
"'--config', 'vite.config.ts'",
"'--config'",
"'vite.config.ts'",
"'--port'",
]) {
if (!devServerSource.includes(snippet)) {
throw new Error(
@@ -0,0 +1,111 @@
import {
findAvailablePort,
formatPortDecision,
mapDevPortsToPortRange,
normalizePort,
reserveLinuxDevPortRange,
} from '../../../scripts/dev-stack-port-utils.mjs';
const agcDevHost = '127.0.0.1';
const legacyAgcDevPort = 3080;
const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT';
function readConfiguredAgcDevPort(env = process.env) {
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
if (!rawPort) {
return null;
}
const port = normalizePort(rawPort, -1);
if (port < 1024) {
throw new Error(`${agcVitePortEnvKey} 必须是 1024-65535 的有效端口`);
}
return port;
}
function createAgcDevEndpoint(port, portRange = null) {
const url = `http://${agcDevHost}:${port}/`;
return {
host: agcDevHost,
port,
url,
markerUrl: `${url}__agc_dev_server.json`,
portRange,
};
}
function readAgcDevEndpoint(env = process.env) {
return createAgcDevEndpoint(
readConfiguredAgcDevPort(env) ?? legacyAgcDevPort,
);
}
async function resolveAgcDevEndpoint({
env = process.env,
platform = process.platform,
strictConfigured = false,
reservePortRange = reserveLinuxDevPortRange,
findPort = findAvailablePort,
} = {}) {
const configuredPort = readConfiguredAgcDevPort(env);
let portRange = null;
let preferredPort = configuredPort ?? legacyAgcDevPort;
if (platform === 'linux') {
const allocation = await reservePortRange({ env });
if (!allocation?.range) {
throw new Error('无法取得当前 Linux 用户的 dev 端口段');
}
portRange = allocation.range;
const mappedAgcVitePort = mapDevPortsToPortRange(portRange)?.agcVitePort;
if (!Number.isInteger(mappedAgcVitePort)) {
throw new Error(
`当前 Linux dev 端口段 ${portRange.label} 缺少 AGC Vite 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`,
);
}
if (configuredPort != null && configuredPort < mappedAgcVitePort) {
throw new Error(
`${agcVitePortEnvKey} ${configuredPort} 占用了 Linux dev 端口段 ${portRange.label} 的前五个服务槽位`,
);
}
preferredPort = configuredPort ?? mappedAgcVitePort;
}
const port = await findPort({
host: agcDevHost,
preferredPort,
portRange,
strict: strictConfigured && configuredPort != null,
});
console.log(
formatPortDecision({
name: 'ai-game-creator-shell',
host: agcDevHost,
preferredPort,
resolvedPort: port,
}),
);
if (portRange) {
console.log(`[ai-game-creator-shell] dev port-range: ${portRange.label}`);
}
return createAgcDevEndpoint(port, portRange);
}
function withAgcDevEndpointEnv(endpoint, env = process.env) {
return {
...env,
[agcVitePortEnvKey]: String(endpoint.port),
};
}
export {
agcDevHost,
agcVitePortEnvKey,
createAgcDevEndpoint,
legacyAgcDevPort,
readAgcDevEndpoint,
readConfiguredAgcDevPort,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
};
@@ -3,10 +3,11 @@ import http from 'node:http';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { resolveAgcDevEndpoint, withAgcDevEndpointEnv } from './dev-port.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const host = '127.0.0.1';
const port = 3080;
const devUrl = `http://${host}:${port}/`;
const endpoint = await resolveAgcDevEndpoint();
const { host, port, url: devUrl } = endpoint;
function readExistingServer() {
return new Promise((resolve) => {
@@ -69,8 +70,10 @@ function isPortListening() {
const existing = await readExistingServer();
if (existing) {
if (isAiGameCreatorServer(existing)) {
console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${devUrl}`);
process.exit(0);
console.error(
`[ai-game-creator-shell] ${devUrl} is already running but cannot be safely reused. Stop that process before starting the dev server.`,
);
process.exit(1);
}
console.error(
`[ai-game-creator-shell] ${devUrl} is already in use by another server. Stop that process before starting Tauri dev.`,
@@ -88,9 +91,20 @@ if (await isPortListening()) {
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const child = spawn(
npm,
['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'],
[
'--prefix',
'../..',
'exec',
'vite',
'--',
'--config',
'vite.config.ts',
'--port',
String(endpoint.port),
],
{
cwd: appRoot,
env: withAgcDevEndpointEnv(endpoint),
stdio: 'inherit',
// Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL).
shell: true,
@@ -5,13 +5,16 @@ 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 viteHost = '127.0.0.1';
const vitePort = 3080;
const viteUrl = `http://${viteHost}:${vitePort}/`;
const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
const defaultApiTarget =
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev';
@@ -131,13 +134,13 @@ async function isBackendReady() {
);
}
async function readExistingViteServer() {
return httpGetText(viteUrl);
async function readExistingViteServer(endpoint = readAgcDevEndpoint()) {
return httpGetText(endpoint.url);
}
function isVitePortListening() {
function isVitePortListening(endpoint = readAgcDevEndpoint()) {
return new Promise((resolveRequest) => {
const socket = net.connect({ host: viteHost, port: vitePort });
const socket = net.connect({ host: endpoint.host, port: endpoint.port });
socket.once('connect', () => {
socket.destroy();
resolveRequest(true);
@@ -160,8 +163,8 @@ function isAiGameCreatorServer(response) {
);
}
async function readExistingViteMarker() {
const response = await httpGetText(viteMarkerUrl, 2000);
async function readExistingViteMarker(endpoint = readAgcDevEndpoint()) {
const response = await httpGetText(endpoint.markerUrl, 2000);
if (!response || response.statusCode !== 200) {
return null;
}
@@ -173,15 +176,16 @@ async function readExistingViteMarker() {
}
async function preflightExistingVite({
endpoint = readAgcDevEndpoint(),
readServer = readExistingViteServer,
portListening = isVitePortListening,
readMarker = readExistingViteMarker,
} = {}) {
const existing = await readServer();
const existing = await readServer(endpoint);
if (!existing) {
if (await portListening()) {
if (await portListening(endpoint)) {
throw new Error(
`${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
`${endpoint.url} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
);
}
return { status: 'available', apiTarget: '' };
@@ -189,11 +193,11 @@ async function preflightExistingVite({
if (!isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
`${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
const marker = await readMarker();
const marker = await readMarker(endpoint);
const markerApiTarget =
marker?.schemaVersion === 1 &&
marker?.app === 'ai-game-creator-shell' &&
@@ -202,7 +206,7 @@ async function preflightExistingVite({
: '';
const actualTarget = markerApiTarget || 'unknown';
throw new Error(
`${viteUrl} 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.`,
`${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.`,
);
}
@@ -549,7 +553,7 @@ async function ensureBackend({
}
}
async function startVite(apiTarget) {
async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
const { apiUrl } = readBackendTargets();
if (apiUrl !== apiTarget) {
throw new Error(
@@ -557,22 +561,32 @@ async function startVite(apiTarget) {
);
}
const existing = await readExistingViteServer();
const existing = await readExistingViteServer(endpoint);
if (existing) {
if (isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already running and cannot be safely reused. Stop it before starting Tauri dev.`,
`${endpoint.url} is already running and cannot be safely reused. Stop it before starting Tauri dev.`,
);
}
throw new Error(
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
`${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'],
{ cwd: appRoot },
[
'--prefix',
'../..',
'exec',
'vite',
'--',
'--config',
'vite.config.ts',
'--port',
String(endpoint.port),
],
{ cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
);
}
@@ -593,7 +607,9 @@ async function main() {
}
try {
await preflightExistingVite();
const endpoint = await resolveAgcDevEndpoint({ strictConfigured: true });
process.env[agcVitePortEnvKey] = String(endpoint.port);
await preflightExistingVite({ endpoint });
const backend = await ensureBackend({
onBackendChild(child) {
backendChild = child;
@@ -607,7 +623,7 @@ async function main() {
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
}
viteChild = await startVite(backend.targets.apiUrl);
viteChild = await startVite(backend.targets.apiUrl, endpoint);
if (shutdownSignal) {
stopChild(viteChild, shutdownSignal);
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
@@ -8,6 +8,11 @@ import {
terminateChildTree,
waitForChildTermination,
} from './start-dev-stack.mjs';
import {
readAgcDevEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
@@ -22,17 +27,37 @@ function parseLauncherArguments(argv) {
return { gameChat, args };
}
function buildTauriArguments(argv) {
function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
const { gameChat, args } = parseLauncherArguments(argv);
const configOverride = JSON.stringify({ build: { devUrl } });
if (gameChat) {
return ['dev', '--', '--', '--game-chat', ...args];
return [
'dev',
'--config',
configOverride,
'--',
'--',
'--game-chat',
...args,
];
}
return ['dev', ...args];
const separatorIndex = args.indexOf('--');
if (separatorIndex < 0) {
return ['dev', ...args, '--config', configOverride];
}
return [
'dev',
...args.slice(0, separatorIndex),
'--config',
configOverride,
...args.slice(separatorIndex),
];
}
function spawnTauriCli(argv) {
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
env,
shell: false,
});
}
@@ -40,16 +65,20 @@ function spawnTauriCli(argv) {
async function runTauriDev(
argv = process.argv.slice(2),
{
resolveDevEndpoint = resolveAgcDevEndpoint,
preflight = preflightExistingVite,
spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination,
terminateTree = terminateChildTree,
} = {},
) {
await preflight();
const endpoint = await resolveDevEndpoint();
await preflight({ endpoint });
const tauriArguments = buildTauriArguments(argv);
const child = spawnCli(tauriArguments);
const tauriArguments = buildTauriArguments(argv, endpoint.url);
const child = spawnCli(tauriArguments, {
env: withAgcDevEndpointEnv(endpoint),
});
let resolveShutdown;
let shutdownSignal = '';
let repeatedSignal = false;
@@ -296,19 +296,20 @@ export function registerClientHomeTests() {
expect(
screen.queryByRole('button', { name: /runtime-live-hero\.png/ }),
).toBeNull();
const manifestReadsBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
const inspectionsBeforeEvent = invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length;
await waitFor(() => {
expect(runtimeHarness.listen).toHaveBeenCalledWith(
'game-creator-manifest-invalidated',
expect.any(Function),
);
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
),
).toHaveLength(2);
});
invoke.mockClear();
manifestChanged = true;
act(() => {
runtimeHarness.emitManifestInvalidated('art-asset-plan');
@@ -328,13 +329,13 @@ export function registerClientHomeTests() {
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length,
).toBeGreaterThan(manifestReadsBeforeEvent);
).toBeGreaterThan(0);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'inspect_local_project_directory',
).length,
).toBe(inspectionsBeforeEvent);
).toBe(0);
});
it('does not let a late manifest refresh from the previous project replace the active project', async () => {
@@ -0,0 +1,130 @@
import { describe, expect, test, vi } from 'vitest';
import {
createAgcDevEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from '../scripts/dev-port.mjs';
describe('AI 游戏创作 dev 端口', () => {
test('Linux 使用用户端口段的 start + 5 槽位', async () => {
const findPort = vi.fn(async ({ preferredPort }) => preferredPort);
const endpoint = await resolveAgcDevEndpoint({
env: { USER: 'alice', LOGNAME: 'alice' },
platform: 'linux',
reservePortRange: async () => ({
username: 'alice',
range: { start: 10000, end: 10099, label: '10000-10099' },
}),
findPort,
});
expect(endpoint).toMatchObject({
port: 10005,
url: 'http://127.0.0.1:10005/',
markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json',
});
expect(findPort).toHaveBeenCalledWith(
expect.objectContaining({
preferredPort: 10005,
portRange: { start: 10000, end: 10099, label: '10000-10099' },
strict: false,
}),
);
});
test('Linux 槽位被占时接受本用户端口段内的漂移结果', async () => {
const endpoint = await resolveAgcDevEndpoint({
env: { USER: 'alice', LOGNAME: 'alice' },
platform: 'linux',
reservePortRange: async () => ({
username: 'alice',
range: { start: 10000, end: 10099, label: '10000-10099' },
}),
findPort: async ({ preferredPort, portRange }) => {
expect(preferredPort).toBe(10005);
expect(portRange?.label).toBe('10000-10099');
return 10006;
},
});
expect(endpoint.port).toBe(10006);
});
test('父启动器选定端口后子启动器严格使用同一端口', async () => {
const findPort = vi.fn(async ({ preferredPort }) => preferredPort);
await resolveAgcDevEndpoint({
env: {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_AGC_VITE_PORT: '10007',
},
platform: 'linux',
strictConfigured: true,
reservePortRange: async () => ({
username: 'alice',
range: { start: 10000, end: 10099, label: '10000-10099' },
}),
findPort,
});
expect(findPort).toHaveBeenCalledWith(
expect.objectContaining({ preferredPort: 10007, strict: true }),
);
});
test('旧五端口段无法安全扩容时 AGC 明确失败而不越界', async () => {
await expect(
resolveAgcDevEndpoint({
env: { USER: 'alice', LOGNAME: 'alice' },
platform: 'linux',
reservePortRange: async () => ({
username: 'alice',
range: { start: 10000, end: 10004, label: '10000-10004' },
}),
findPort: async () => 10005,
}),
).rejects.toThrow('缺少 AGC Vite 槽位');
});
test('Linux 显式 AGC 端口不能占用前五个服务槽位', async () => {
await expect(
resolveAgcDevEndpoint({
env: {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_AGC_VITE_PORT: '10004',
},
platform: 'linux',
reservePortRange: async () => ({
username: 'alice',
range: { start: 10000, end: 10099, label: '10000-10099' },
}),
findPort: async () => 10004,
}),
).rejects.toThrow('前五个服务槽位');
});
test('非 Linux 保留 3080 兼容优先端口并允许统一漂移', async () => {
const findPort = vi.fn(async () => 3081);
const endpoint = await resolveAgcDevEndpoint({
env: {},
platform: 'win32',
findPort,
});
expect(endpoint.port).toBe(3081);
expect(findPort).toHaveBeenCalledWith(
expect.objectContaining({ preferredPort: 3080, portRange: null }),
);
});
test('最终端口通过单一环境变量传给 Tauri 和 Vite 子进程', () => {
expect(
withAgcDevEndpointEnv(createAgcDevEndpoint(10008), { KEEP_ME: 'yes' }),
).toMatchObject({
KEEP_ME: 'yes',
GENARRATIVE_AGC_VITE_PORT: '10008',
});
});
});
@@ -253,7 +253,14 @@ describe('AI 游戏创作启动子进程生命周期', () => {
});
});
describe('AI 游戏创作 3080 启动前预检', () => {
describe('AI 游戏创作动态端口启动前预检', () => {
const endpoint = {
host: '127.0.0.1',
port: 10005,
url: 'http://127.0.0.1:10005/',
markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json',
portRange: { start: 10000, end: 10099, label: '10000-10099' },
};
const agcHtml = {
statusCode: 200,
body: '<html><title>AI 游戏创作</title><script src="/src/main.tsx"></script></html>',
@@ -262,6 +269,7 @@ describe('AI 游戏创作 3080 启动前预检', () => {
test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => agcHtml,
portListening: async () => true,
readMarker: async () => ({
@@ -278,6 +286,7 @@ describe('AI 游戏创作 3080 启动前预检', () => {
test('marker target 看似匹配时仍拒绝复用无法证明归属的 Vite', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => agcHtml,
portListening: async () => true,
readMarker: async () => ({
@@ -292,15 +301,17 @@ describe('AI 游戏创作 3080 启动前预检', () => {
test('HTTP 探测无响应但端口已监听时失败关闭', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => null,
portListening: async () => true,
}),
).rejects.toThrow('non-HTTP or unrecognized server');
});
test('3080 未监听时允许继续启动', async () => {
test('当前动态端口未监听时允许继续启动', async () => {
await expect(
preflightExistingVite({
endpoint,
readServer: async () => null,
portListening: async () => false,
}),
@@ -15,6 +15,15 @@ import {
runTauriDev,
} from '../scripts/start-tauri-dev.mjs';
const testEndpoint = {
host: '127.0.0.1',
port: 10005,
url: 'http://127.0.0.1:10005/',
markerUrl: 'http://127.0.0.1:10005/__agc_dev_server.json',
portRange: { start: 10000, end: 10099, label: '10000-10099' },
};
const resolveTestEndpoint = async () => testEndpoint;
async function waitForFile(path: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@@ -28,18 +37,42 @@ async function waitForFile(path: string, timeoutMs = 5000) {
describe('AI 游戏创作 Tauri dev 启动参数', () => {
test('普通 dev 参数原样交给 Tauri CLI', () => {
expect(buildTauriArguments(['--no-watch'])).toEqual(['dev', '--no-watch']);
expect(buildTauriArguments(['--no-watch'], testEndpoint.url)).toEqual([
'dev',
'--no-watch',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/"}}',
]);
});
test('动态 devUrl 配置位于用户 Tauri 配置之后且不越过参数分隔符', () => {
expect(
buildTauriArguments(
['--config', 'custom.json', '--', '--', '--example-app-arg'],
testEndpoint.url,
),
).toEqual([
'dev',
'--config',
'custom.json',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/"}}',
'--',
'--',
'--example-app-arg',
]);
});
test('game-chat 参数进入应用参数区且保留项目参数', () => {
expect(
buildTauriArguments([
'--game-chat',
'--project-path',
'/tmp/example-game',
]),
buildTauriArguments(
['--game-chat', '--project-path', '/tmp/example-game'],
testEndpoint.url,
),
).toEqual([
'dev',
'--config',
'{"build":{"devUrl":"http://127.0.0.1:10005/"}}',
'--',
'--',
'--game-chat',
@@ -50,17 +83,18 @@ describe('AI 游戏创作 Tauri dev 启动参数', () => {
});
describe('AI 游戏创作 Tauri dev 生命周期', () => {
test('3080 预检失败时不启动 Tauri CLI', async () => {
test('动态端口预检失败时不启动 Tauri CLI', async () => {
const spawnCli = vi.fn();
await expect(
runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {
throw new Error('stale 3080');
throw new Error('stale AGC Vite');
},
spawnCli,
}),
).rejects.toThrow('stale 3080');
).rejects.toThrow('stale AGC Vite');
expect(spawnCli).not.toHaveBeenCalled();
});
@@ -74,6 +108,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
kill: vi.fn(),
});
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {
order.push('preflight');
},
@@ -132,6 +167,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
let cliChild;
try {
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
@@ -187,6 +223,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
let cliChild;
try {
const result = await runTauriDev([], {
resolveDevEndpoint: resolveTestEndpoint,
preflight: async () => {},
spawnCli: () => {
cliChild = spawnChild(
+17 -10
View File
@@ -47,16 +47,23 @@ export default defineConfig({
{
name: 'genarrative-ai-game-creator-dev-marker',
configureServer(server) {
server.middlewares.use('/__agc_dev_server.json', (_request, response) => {
response.setHeader('Content-Type', 'application/json; charset=utf-8');
response.end(
JSON.stringify({
schemaVersion: 1,
app: 'ai-game-creator-shell',
apiTarget,
}),
);
});
server.middlewares.use(
'/__agc_dev_server.json',
(_request, response) => {
response.setHeader(
'Content-Type',
'application/json; charset=utf-8',
);
response.end(
JSON.stringify({
schemaVersion: 1,
app: 'ai-game-creator-shell',
port: server.config.server.port,
apiTarget,
}),
);
},
);
},
},
],
@@ -6799,3 +6799,11 @@
- External v1 的二次 allowlist 裁剪同样保留 `prompt / actualPrompt`,契约验收以 `serialize_atomic_editor_generation_job_result` 最终 JSON 为准,不只测上游 builder。图标/UI 正常与 source-only fallback 同时保留 `ok / prompt / actualPrompt`fallback 的尺寸/model/价格也从本次生成上下文显式携带,不依赖可选 project resource。Editor Agent 图片生成/修改 DTO 允许 compact payload 不携带 `provider`。inline 八类 provider 生成的已成功 billing guard 延迟到 owner handler 完成 durable receipt 提交才 disarmprocedure 发出前的明确失败/取消退款,发出后回包前的传输不确定或取消保留扣款。
- 影响范围:所有现役编辑器生成类型、`spacetime-module` / `spacetime-client` 结果提交契约、queue worker 终态写回、schema / migration / generated bindings 与对应故障注入测试。完美像素保留现有专用原子 procedure;手动图集拆分保留现有批量事务,其 canvas completion 并入批量事务另行收口。
- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134
## 2026-08-08 AGC Vite 纳入统一用户端口段
- 背景:Linux 主开发栈已按用户分配 `100` 端口段,但后加入的 AGC Tauri 壳仍固定监听全机共享的 `3080`。同机任一用户的旧客户端都会阻塞其它用户,且 marker 中出现的动态 API 端口无法解决 Vite 本身的跨用户冲突。
- 决策:端口段正式增加第六个槽位 `agc-vite = start + 5`,端口段最小长度同步改为 `6`。Linux AGC 首选该槽位并只在当前用户段内漂移;Windows / macOS 保留 `3080` 兼容首选并统一探测漂移,不读取 Linux 系统注册表。
- 一致性:`start-tauri-dev.mjs` 是端口选择权威,最终端口通过 Tauri CLI `--config` 覆盖 `build.devUrl`,通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,并通过 Vite CLI `--port` 启动严格监听。父启动器选定端口后,子启动器只允许严格使用同一端口,配套后端漂移必须跳过该预留端口,竞态占用必须失败关闭。
- 安全边界:动态端口不恢复旧 Vite 复用。无法证明 worktree 归属的监听器仍不复用、不主动终止;同用户多 worktree 通过段内漂移并行,不通过共享未知服务并行。
- 验证:公共端口映射、Linux 默认槽位与段内漂移、非 Linux 兼容漂移、Tauri 动态配置、启动前预检、进程树收束、AGC typecheck / 配置门禁、编码检查和差异检查必须通过。
@@ -334,9 +334,9 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
定向命令必须实际匹配到 V1.10 用例,`0 tests` 不算通过。真实 Provider fixture 不得把工具顺序、processId、readiness 文本所在 chunk 或 OS PID 写进任务提示;验收器只按持久 action identity、fixture 计数、私有输出和公共泄漏扫描判定。三项门禁实际通过后才能把日期、Provider、数量和 PASS 结果写入技术方案或 decision log;未运行或被外部配置阻断时只记录 `BLOCKED` / 未验收事实。
`npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。开发态只打开游戏创作聊天入口使用 `npm run agc:game-chat -- [--project-path <absolute-path>]`。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database <name>`
`npm run agc` 会启动 Tauri 开发客户端;其启动器先解析 AGC Vite 实际端口,再把同一地址动态注入 Tauri `devUrl``beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 并严格启动该端口上的 Vite。开发态只打开游戏创作聊天入口使用 `npm run agc:game-chat -- [--project-path <absolute-path>]`。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database <name>`
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口五个 dev 服务依次使用 `start``start + 4`其中 BgFilter worker 固定为 `start + 4`。可用 `GENARRATIVE_DEV_PORT_RANGE``npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 把第五个服务纳入原有统一端口探测与漂移逻辑。
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口五个 dev 服务依次使用 `start``start + 4`AGC Vite 使用 `start + 5`;同用户后续启动复用自己的固定段,AGC 首选槽位被占时只在该段内继续漂移。可用 `GENARRATIVE_DEV_PORT_RANGE``npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段。该机制只在 Linux 生效,Windows 继续使用统一端口探测与漂移逻辑。
本地 `npm run dev``npm run dev:spacetime``npm run dev:api-server``npm run dev:bgfilter-worker` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 Rust 服务启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。
@@ -4241,8 +4241,9 @@
- 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。
- 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。
- 处理:`dev``game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILLWindows 固定调用 `taskkill /PID <pid> /T /F``start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。
- 2026-08-08 后续统一:上述 `3080` 是事故发生时的历史实现,不再是当前 Linux 启动口径。AGC Vite 已纳入系统级用户端口段,首选 `start + 5`,占用时只在本用户段内漂移;外层启动器把最终端口写入 Tauri CLI 动态 `build.devUrl` 和子进程 `GENARRATIVE_AGC_VITE_PORT`,并用 Vite CLI `--port` 启动严格监听。`beforeDevCommand`、配套后端预留、WebView 与 Vite `strictPort` 必须使用同一值。Windows / macOS 仅把 `3080` 保留为兼容首选并允许统一漂移。未知归属监听器仍不得复用或主动终止,但其它用户固定 `3080` 不再阻塞 Linux 当前用户启动。
- Linux 容器边界:最小化 CI 容器的 PID 1 可能不回收孤儿后代,进程组在所有可执行成员退出后仍只剩 `Z` 僵尸;此时 `kill(-pgid, 0)` 仍成功,不能据此把已经完成的收束误报为失败。Linux 等待逻辑在 signal 探活后必须核对 `/proc/<pid>/stat`,只把同 PGID 的非 `Z / X` 成员视为存活;`/proc` 不可读时继续使用原保守判断,macOS 等其它 POSIX 平台仍只走 signal 探活。
- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
- 验证:定向测试必须覆盖用户段 `start + 5` 映射、同段占用漂移、父子启动器严格复用最终端口、动态 Tauri `--config`、marker 与预检地址一致、未知归属监听器拒绝复用、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts``apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`
## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03
@@ -4323,7 +4324,7 @@
- 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。
- 原因:测试 Supervisor 直接调用 `onManifestChange`,只证明 `App manifest -> WorkspaceLauncher -> ProjectDevelopmentView` 的下游桥接;真实 Runtime event 没有失效字段,监听器也没有重读 manifest。External Runner 又与 GUI 分属不同进程,Runner 内无法使用 GUI `AppHandle`,只补普通 Tauri event 仍不能形成生产链路。
- 处理:后台 manifest mutation 收敛到共用 Runtime emitterGUI 内进程用带 `manifestInvalidated` 的 Runtime updateExternal Runner 通过 GUI owner attach 登记的受令牌保护 loopback sink 转发专用失效事件。App 对当前项目做 single-flight manifest 重读,并以 mounted、项目路径和 scope version 丢弃迟到结果;WorkspaceLauncher 继续只消费完整 manifest 快照,不新增平行状态或轮询。
- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。测试夹具必须先等待目标 Tauri listener 注册完成再发失效事件,并对“事件 -> manifest 重读 -> 工作台重投影”使用局部、有界的 `5_000ms` 等待,避免并行全量回归把监听注册或异步投影调度误判为功能失败。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。
- 验证:集成测试必须渲染真实 `App + WorkspaceLauncher`、捕获真实 Tauri listener,让 `get_local_game_manifest` 从旧快照切换到新快照,并由非 Supervisor Agent 事件驱动资产、completed 任务、运行入口和版本卡出现;另测项目切换时旧请求迟到。测试夹具必须先等待目标 Tauri listener 注册完成,并等待项目写入最近列表后触发的只读目录状态刷新完成,再清空调用记录和发送失效事件对“事件 -> manifest 重读 -> 工作台重投影”使用局部、有界的 `5_000ms` 等待,避免并行全量回归把合法后台检查、监听注册或异步投影调度误判为功能失败。旧的直接 `onManifestChange` 测试只能标记为下游桥接证据。
## React 资源详情焦点不能依赖重建对象身份(2026-08-05)
@@ -77,7 +77,7 @@
## 2026-08-03 game-chat 开发态同源与持久输出修复
- 开发态启动必须在 Tauri CLI 之前预检固定 `3080`。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;因此只有端口空闲时才允许继续,任何已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。
- 开发态启动必须在 Tauri CLI 之前解析并预检 AGC Vite 最终地址。Linux 使用系统级用户端口段的 `start + 5` 槽位并只在本段内漂移,Windows / macOS 以 `3080` 为兼容首选;启动器通过 `GENARRATIVE_AGC_VITE_PORT` 绑定 `beforeDevCommand` 和配套后端预留,通过 Tauri CLI `--config` 绑定 `build.devUrl`,并通过 Vite CLI `--port` 绑定 `strictPort` 监听。任何竞态中已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。
- game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能进入 Supervisor 决策前零 child 的单主 laneSupervisor 持久 intent 后先运行 `code-prototype`,由它按权威审计结果选择复用或暂时委派受限美术,再在同一主 Run 完成接入、静态 smoke、desktop/mobile 试玩、单轮确定性收束和自动预览。若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。
- source-aware lane 的主 Run 或其经授权美术 child 可能在 UI hydration 写回时短暂恢复为 `Pending`。该例外必须从当前 root source、持久工作流决策、单主 route 与 child delegation 解析本轮已开放工作,不得从旧七节点图硬编码重启 Director、验证或试玩节点;未授权 child、第二个活跃美术 child,或缺少成功 `asset.list` 审计的美术委派仍严格失败关闭。
- 专业 Agent 的非流式 final reply 继续由既有 finalization journal 重建并提交 `responseStream``streaming / ready` 投影仍必须匹配当前项目 revision;已经 finalization 提交的 `committed` 回复以 Agent / Session / run / request slot / response revision 稳定身份为准,不得因后续阶段推进项目 revision 而从 game-chat 查询中消失。
@@ -591,7 +591,7 @@ game-project/
## 当前最小落地
- `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`
- 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code``/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`Tauri dev 下 `/api`固定 3080 Vite 代理,发布版静态窗口下登录请求默认直连本机配套 `http://127.0.0.1:8082` API,网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`
- 独立客户端启动时先进入平台登录检查;未登录页默认展示手机号验证码登录,并保留密码登录切换。验证码登录调用平台后端 `/api/auth/phone/send-code``/api/auth/phone/login`,密码登录继续调用 `/api/auth/entry`Tauri dev 下 `/api`本轮动态 AGC Vite 代理,发布版静态窗口下登录请求默认直连本机配套 `http://127.0.0.1:8082` API,网络层失败时展示登录服务不可达提示,不裸露 WebView 的 `Load failed`
- `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期、保存 detached leader 的 PGID 并把句柄交给外层;即使 direct leader 已先退出,也必须继续向负 PGID 发信号清理同组后代。后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。非 Linux Runtime 执行 `project.verify` 时,`npm run` 参数校验必须允许受控的 `--silent``--ignore-scripts` 位于脚本名前,并继续拒绝缺少真实脚本名的调用。
- Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。
- 本地项目初始化会创建 `game/``assets/``memory/``memory/agents/``exports/``.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`
@@ -617,7 +617,7 @@ game-project/
- 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。
- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `llm.apiKind``openai_chat`Anthropic Messages 网关设置 `llm.apiKind``anthropic`。真实 OpenAI-compatible 网关建议设置 `llm.stream``true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。
- 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、Planner 与 Generator 分别命中自己的 `agentLlm` provider 配置、provider prompt 收到图片与音频资产上下文以及最近对话上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。
- `npm run ai-game-creator-shell:dev` Tauri `devUrl` 固定为 `http://127.0.0.1:3080/`Vite 必须 `strictPort` 对齐;`beforeDevCommand` 先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,否则才启动新的 Vite端口被其它服务占用直接失败并提示释放端口
- `npm run ai-game-creator-shell:dev` 必须经受控启动器解析 AGC Vite 端口;Linux 默认使用用户端口段的 `start + 5`,非 Linux 保留 `3080` 兼容首选。启动器用动态 Tauri `build.devUrl``GENARRATIVE_AGC_VITE_PORT` 和 Vite CLI `--port` 保证 WebView、`beforeDevCommand`、配套后端预留和 Vite `strictPort` 对齐;不得复用无法证明 worktree 归属的现有 Vite最终端口被竞态占用直接失败。
- AI 游戏创作 App 的本地后端使用 gitignored 的 `server-rs/.spacetimedb/ai-game-creator/data`,不复用主站旧 standalone 数据目录。启动器从本地 `/v1/identity` 获取并持久化 API identity,再通过数据目录内 `0600` 的独立 `dev-cli/cli.toml` 发布模块;不得读取或覆盖开发者全局 SpacetimeDB 登录,也不得回退到每次变化的 `--anonymous` 身份。发布失败时 API 和 Vite 不得继续启动旧 schema,避免 `external_generation_job` 等缺表订阅进入持续重试。
- `start-dev-stack.mjs` 在 POSIX 下以独立进程组托管后端和 Vite,关闭 Tauri 或任一子进程失败时必须收束整组;macOS 不注册仅支持 Windows/Linux 的 api-server 进程指标 observable callback,避免每轮指标采集重复输出平台不支持告警。
- Unix 下 Agent DB、External Runner owner 和 tool-plan handoff 的相对句柄复核必须同时比较设备号、inode 和文件类型;`libc::stat``st_dev / st_ino` 先按 Rust `MetadataExt` 的 Unix 口径规范为 `u64` 再比较,保持 Linux 和 macOS 的同一安全语义,不得为了通过 macOS 编译而删除路径替换检测。
@@ -914,6 +914,6 @@ game-project/
- `.agent/manifest.json` 的存储写边界使用同目录持久文件锁跨线程、跨进程串行化;锁必须覆盖旧 manifest 读取、不可变版本前缀校验、临时文件安装和安装后回读一致性校验。锁文件拒绝符号链接、非普通文件和异常所有权 / 硬链接;Windows 使用不共享写句柄,Unix 使用 `O_NOFOLLOW + flock`。旧快照在新版本安装后只能被拒绝,不能覆盖已追加版本。
- 后台 Agent 的 manifest 变化以共用 Runtime 状态投影 / 终态 emitter 作为失效因果点:`game-creator-agent-runtime-update` 的 Rust / TypeScript DTO 固定携带 `manifestInvalidated`,且 App 必须在 Supervisor、selected agent、session 和 run 身份的任何 early return 之前处理失效。GUI 进程内 Runtime 直接发该事件;External Runner 是独立进程、没有 GUI `AppHandle`,因此 Runner 协议 v5 的 `runner.attach_gui_owner` 必须登记 GUI 创建的随机 loopback 端口和 64 位随机令牌,Runner 的同一 emitter 通过受令牌保护的短连接转发 `game-creator-manifest-invalidated`。两条路径都只传项目路径与 Agent 身份,不复制 manifest,也不靠轮询补偿。
- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 再通过既有 `onManifestChange(projectPath, manifest)` 向启动器外传完整快照,启动器只更新仍为同一路径的活动项目上下文;资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。
- App 收到当前项目的 Runtime / relay 失效后重新调用 `get_local_game_manifest`。重读按项目 single-flight 合并事件风暴;读取中再到达失效只追加一轮串行重读,不并发提交同项目响应。应用结果同时校验组件仍挂载、当前项目路径和项目 scope version;项目切换、组件卸载或旧 scope 的迟到响应不得覆盖新项目。Project Supervisor 再通过既有 `onManifestChange(projectPath, manifest)` 向启动器外传完整快照,启动器只更新仍为同一路径的活动项目上下文;资源列表、依赖图输入、任务状态、运行入口和正式版本卡必须在当前页面实时重投影,不要求关闭或重开项目。集成测试记录“事件未重新打开项目”的调用基线前,必须先等待项目写入最近列表后触发的只读目录状态刷新完成,不能把这项合法后台检查误算成失效事件副作用。
- `.agent/agent.db` 有界尾部读取报告截断时,审计 producer 映射失败关闭,不生成基于不完整审计的 producer、task flow 或对应任务环。前端收到截断 DTO 时只剔除 `producerAssignments``taskFlows` 与对应 `cyclicTaskIds`Rust 根据当前 manifest、精确资源引用和仍可信任务深度下限返回的 `dependencyDepths` 继续保留,前端只校验资源仍存在且深度为非负安全整数,不得自行重算或压平权威深度。精确引用边、reference connection index、`cyclicResourceIds` 与 unresolved references 同样继续保留。
- 资源依赖 SVG 继续作为不可交互装饰层隐藏,但 dependency 画布通过 `aria-describedby` 提供当前可见精确引用和任务流的文本等价列表。中央资源聚焦按稳定 `resourceId` 驱动焦点状态:仅 `null -> id``idA -> idB` 聚焦详情 region,同一 ID 的 manifest 重投影不得抢走音频、视频、链接或关闭按钮焦点;显式收起和 Escape 恢复画布滚动并优先聚焦原触发卡片。聚焦资源被删除时清理 stale focused / selected ID,关闭详情并把焦点落到资源搜索框;项目切换或运行视图切换清除旧恢复意图,不得恢复旧项目卡片。橙色引用线及箭头使用对 `#fffdfa` 画布达到至少 `3:1` 的颜色。
@@ -54,13 +54,13 @@ npm run dev:api-server
npm run dev:bgfilter-worker
```
Linux 本机多用户并发开发时,`npm run dev``npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start``api = start + 1``spacetime = start + 2``admin-web = start + 3``bgfilter-worker = start + 4`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199``10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE``--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 / 8083 优先端口统一探测并漂移,不读这个系统级注册表。父 API 与 worker 始终使用解析后的实际 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`,不能写死 `8083`
Linux 本机多用户并发开发时,`npm run dev``npm run dev:*` 单模块命令`npm run agc` / `npm run agc:game-chat` 会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start``api = start + 1``spacetime = start + 2``admin-web = start + 3``bgfilter-worker = start + 4``agc-vite = start + 5`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199``10200-10299` 递增;同用户已有 worktree 占用首选 AGC 槽位时,AGC 只在本用户段内继续漂移。`GENARRATIVE_DEV_PORT_RANGE``--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 / 8083 与 AGC 兼容优先端口 3080 统一探测并漂移,不读这个系统级注册表。父 API 与 worker 始终使用解析后的实际 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`,不能写死 `8083`
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。
AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
+70 -13
View File
@@ -16,6 +16,8 @@ const LINUX_DEV_PORT_RANGE_REGISTRY_ROOT = '/var/tmp/genarrative-dev-port-ranges
const LINUX_DEV_PORT_RANGE_POOL_START = 10000;
const LINUX_DEV_PORT_RANGE_POOL_END = 39999;
const LINUX_DEV_PORT_RANGE_BLOCK_SIZE = 100;
const LEGACY_DEV_PORT_RANGE_MIN_SIZE = 5;
const DEV_PORT_RANGE_MIN_SIZE = 6;
function toListenHosts(host) {
if (host === '0.0.0.0') {
@@ -38,7 +40,7 @@ export function normalizePort(value, fallback) {
return port;
}
export function parsePortRangeSpec(value) {
function parsePortRangeSpecWithMinimum(value, minimumSize) {
const spec = String(value ?? '').trim();
if (!spec) {
return null;
@@ -55,23 +57,33 @@ export function parsePortRangeSpec(value) {
throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`);
}
if (end - start + 1 < 5) {
throw new Error(`端口段至少需要 5 个端口: ${spec}`);
if (end - start + 1 < minimumSize) {
throw new Error(`端口段至少需要 ${minimumSize} 个端口: ${spec}`);
}
return {start, end, label: `${start}-${end}`};
}
function normalizePortRange(portRange) {
export function parsePortRangeSpec(value) {
return parsePortRangeSpecWithMinimum(value, DEV_PORT_RANGE_MIN_SIZE);
}
function normalizePortRange(
portRange,
{minimumSize = DEV_PORT_RANGE_MIN_SIZE} = {},
) {
if (!portRange) {
return null;
}
if (typeof portRange === 'string') {
return parsePortRangeSpec(portRange);
return parsePortRangeSpecWithMinimum(portRange, minimumSize);
}
return parsePortRangeSpec(`${portRange.start}-${portRange.end}`);
return parsePortRangeSpecWithMinimum(
`${portRange.start}-${portRange.end}`,
minimumSize,
);
}
export function getLinuxDevPortRangeRegistryPaths(env = process.env) {
@@ -108,7 +120,9 @@ export function getLinuxDevPortRangeSpec(env = process.env) {
}
export function mapDevPortsToPortRange(portRange) {
const normalizedRange = normalizePortRange(portRange);
const normalizedRange = normalizePortRange(portRange, {
minimumSize: LEGACY_DEV_PORT_RANGE_MIN_SIZE,
});
if (!normalizedRange) {
return null;
}
@@ -119,6 +133,10 @@ export function mapDevPortsToPortRange(portRange) {
spacetimePort: normalizedRange.start + 2,
adminWebPort: normalizedRange.start + 3,
bgfilterWorkerPort: normalizedRange.start + 4,
agcVitePort:
normalizedRange.start + 5 <= normalizedRange.end
? normalizedRange.start + 5
: null,
range: normalizedRange,
};
}
@@ -252,9 +270,12 @@ function readLinuxPortRangeRegistry(registryPath) {
return registry;
}
function safeNormalizePortRange(portRange) {
function safeNormalizePortRange(
portRange,
{minimumSize = LEGACY_DEV_PORT_RANGE_MIN_SIZE} = {},
) {
try {
return normalizePortRange(portRange);
return normalizePortRange(portRange, {minimumSize});
} catch {
return null;
}
@@ -264,6 +285,32 @@ function rangesOverlap(left, right) {
return left.start <= right.end && right.start <= left.end;
}
function tryExpandLegacyPortRange(registry, username, portRange) {
const normalizedRange = safeNormalizePortRange(portRange);
if (!normalizedRange) {
return null;
}
if (
normalizedRange.end - normalizedRange.start + 1 >=
DEV_PORT_RANGE_MIN_SIZE
) {
return normalizedRange;
}
const expandedEnd = normalizedRange.start + DEV_PORT_RANGE_MIN_SIZE - 1;
if (expandedEnd > 65535) {
return null;
}
const expandedRange = {
start: normalizedRange.start,
end: expandedEnd,
label: `${normalizedRange.start}-${expandedEnd}`,
};
return findRangeConflict(registry, expandedRange, username)
? null
: expandedRange;
}
function findRangeConflict(registry, portRange, excludingUsername = '') {
const rangeToCheck = safeNormalizePortRange(portRange);
if (!rangeToCheck) {
@@ -380,11 +427,16 @@ export async function reserveLinuxDevPortRange({
const current = registry.allocations[username];
if (current) {
const expandedRange = tryExpandLegacyPortRange(
registry,
username,
current.range,
);
registry.updatedAt = now;
registry.allocations[username] = {
...current,
username,
range: current.range,
range: expandedRange ?? current.range,
updatedAt: now,
};
atomicWriteJsonFile(registryPath, registry);
@@ -482,7 +534,9 @@ export async function findAvailablePort({
portRange = null,
strict = false,
}) {
const range = normalizePortRange(portRange);
const range = normalizePortRange(portRange, {
minimumSize: LEGACY_DEV_PORT_RANGE_MIN_SIZE,
});
const startPort = normalizePort(preferredPort, 0);
if (startPort === 0 && range) {
@@ -563,8 +617,11 @@ async function reserveEphemeralPort(host, reservedPorts) {
throw new Error(`无法为 ${host} 分配临时可用端口`);
}
export async function resolveDevStackPorts(config) {
const reservedPorts = new Set();
export async function resolveDevStackPorts(
config,
{reservedPorts: initialReservedPorts = new Set()} = {},
) {
const reservedPorts = new Set(initialReservedPorts);
const entries = [
['spacetime', config.spacetime],
['api', config.api],
+109 -4
View File
@@ -1,4 +1,4 @@
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import {createServer} from 'node:net';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
@@ -47,7 +47,7 @@ async function reserveConsecutivePorts() {
}
describe('dev stack port utils', () => {
it('解析端口段并映射到五个 dev 端口', () => {
it('解析端口段并映射到五个 dev 服务和 AGC 端口', () => {
expect(parsePortRangeSpec('10000-10099')).toEqual({
start: 10000,
end: 10099,
@@ -59,9 +59,10 @@ describe('dev stack port utils', () => {
spacetimePort: 10002,
adminWebPort: 10003,
bgfilterWorkerPort: 10004,
agcVitePort: 10005,
});
expect(() => parsePortRangeSpec('10000-10003')).toThrow(
'端口段至少需要 5 个端口',
expect(() => parsePortRangeSpec('10000-10004')).toThrow(
'端口段至少需要 6 个端口',
);
});
@@ -122,6 +123,21 @@ describe('dev stack port utils', () => {
expect(new Set(Object.values(resolvedPorts)).size).toBe(5);
});
it('解析 dev 服务时跳过父启动器预留的 AGC Vite 端口', async () => {
const server = await reservePort(0);
const preferredPort = server.address().port;
await new Promise((resolve) => server.close(resolve));
const resolvedPorts = await resolveDevStackPorts(
{
api: {host: '127.0.0.1', preferredPort},
},
{reservedPorts: new Set([preferredPort])},
);
expect(resolvedPorts.api).toBeGreaterThan(preferredPort);
});
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {
const rangeStart = 10000;
const rangeEnd = 10300;
@@ -142,6 +158,95 @@ describe('dev stack port utils', () => {
const linuxIt = process.platform === 'linux' ? it : it.skip;
linuxIt('Linux 升级时保留旧 v4 五端口记录并阻止重复分配', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
writeFileSync(
registryPath,
JSON.stringify({
version: 4,
updatedAt: '2026-08-01T00:00:00.000Z',
allocations: {
alice: {
username: 'alice',
range: {start: 10000, end: 10004, label: '10000-10004'},
claimedAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
source: 'manual',
},
},
}),
);
const bobAllocation = await reserveLinuxDevPortRange({
env: {
USER: 'bob',
LOGNAME: 'bob',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'bob',
requestedRange: null,
registryPath,
lockPath,
});
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
expect(bobAllocation.range.label).toBe('10100-10199');
expect(registry.allocations.alice.range.label).toBe('10000-10004');
expect(registry.allocations.bob.range.label).toBe('10100-10199');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
linuxIt('Linux 当前用户的旧五端口记录会在无冲突时扩出 AGC 槽位', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
const lockPath = join(tempRoot, 'registry.lock');
try {
writeFileSync(
registryPath,
JSON.stringify({
version: 4,
updatedAt: '2026-08-01T00:00:00.000Z',
allocations: {
alice: {
username: 'alice',
range: {start: 10000, end: 10004, label: '10000-10004'},
claimedAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
source: 'manual',
},
},
}),
);
const allocation = await reserveLinuxDevPortRange({
env: {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
},
username: 'alice',
requestedRange: null,
registryPath,
lockPath,
});
expect(allocation.range.label).toBe('10000-10005');
expect(
JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range
.label,
).toBe('10000-10005');
} finally {
rmSync(tempRoot, {recursive: true, force: true});
}
});
linuxIt('Linux 未手动指定端口段时从 10000 开始按 100 端口块自动分配', async () => {
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
const registryPath = join(tempRoot, 'registry.json');
+11 -1
View File
@@ -1447,7 +1447,17 @@ class DevRunner {
return;
}
const resolvedPorts = await resolveDevStackPorts(portConfig);
const reservedPorts = new Set();
const agcVitePort = normalizePort(
this.baseEnv.GENARRATIVE_AGC_VITE_PORT,
0,
);
if (agcVitePort > 0) {
reservedPorts.add(agcVitePort);
}
const resolvedPorts = await resolveDevStackPorts(portConfig, {
reservedPorts,
});
for (const [name, resolvedPort] of Object.entries(resolvedPorts)) {
const config = portConfig[name];
+32
View File
@@ -187,6 +187,38 @@ describe('dev scheduler argument routing', () => {
}
});
linuxTest(
'Linux 配套后端不会漂移到父启动器预留的 AGC Vite 端口',
async () => {
const tempDir = mkdtempSync(
join(tmpdir(), 'genarrative-dev-port-range-'),
);
const baseEnv = {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
GENARRATIVE_AGC_VITE_PORT: '22002',
};
try {
const { command, explicitOptions, options } = parseArgs(
['backend'],
baseEnv,
);
const runner = new DevRunner(options, baseEnv, explicitOptions);
await runner.prepareLinuxPortRange(command);
await runner.resolvePorts(command);
expect(runner.options.spacetimePort).not.toBe(22002);
expect(runner.options.apiPort).not.toBe(22002);
expect(runner.options.bgfilterWorkerPort).not.toBe(22002);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
linuxTest(
'Linux 桌面壳显式指定 web-port 时不被系统级端口段改写',
async () => {