修复AI游戏创作启动评审问题
将本地发布身份改为按SpacetimeDB数据目录持久化并兼容迁移旧记录 为开发栈状态补充专用数据目录并拒绝复用旧共享后端 统一处理子进程启动错误、启动期信号和进程组清理 补充身份、后端复用与子进程生命周期回归测试及文档
This commit is contained in:
@@ -11,10 +11,15 @@ 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 defaultApiTarget =
|
||||
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
|
||||
const backendDatabase = 'genarrative-game-creator-dev';
|
||||
const backendSpacetimeDataDir = 'server-rs/.spacetimedb/ai-game-creator/data';
|
||||
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)) {
|
||||
@@ -54,44 +59,70 @@ function httpGetText(url, timeout = 1000) {
|
||||
|
||||
async function isHttpReady(url) {
|
||||
const response = await httpGetText(url);
|
||||
return Boolean(response && response.statusCode >= 200 && response.statusCode < 300);
|
||||
return Boolean(
|
||||
response && response.statusCode >= 200 && response.statusCode < 300,
|
||||
);
|
||||
}
|
||||
|
||||
function readBackendTargets({ requireAgcDatabase = false } = {}) {
|
||||
const state = readJson(devStackStatePath);
|
||||
function resolveBackendTargetsFromState(
|
||||
state,
|
||||
{
|
||||
requireAgcBackend = false,
|
||||
expectedDatabase = backendDatabase,
|
||||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||||
fallbackApiTarget = defaultApiTarget,
|
||||
} = {},
|
||||
) {
|
||||
const apiServer = state?.services?.['api-server'];
|
||||
const spacetime = state?.services?.spacetime;
|
||||
const isActive = (service) =>
|
||||
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
|
||||
const database = typeof state?.database === 'string' ? state.database : '';
|
||||
const hasMatchingDatabase = database === backendDatabase;
|
||||
const canReuseState = !requireAgcDatabase || hasMatchingDatabase;
|
||||
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
|
||||
: requireAgcDatabase
|
||||
: requireAgcBackend
|
||||
? ''
|
||||
: defaultApiTarget;
|
||||
: fallbackApiTarget;
|
||||
const spacetimeUrl =
|
||||
canReuseState && isActive(spacetime) && spacetime.url
|
||||
? spacetime.url
|
||||
: requireAgcDatabase
|
||||
: requireAgcBackend
|
||||
? ''
|
||||
: 'http://127.0.0.1:3101';
|
||||
return {
|
||||
apiUrl,
|
||||
spacetimeUrl,
|
||||
database,
|
||||
spacetimeDataDir,
|
||||
hasMatchingDatabase,
|
||||
hasMatchingDataDir,
|
||||
hasMatchingBackend,
|
||||
};
|
||||
}
|
||||
|
||||
function readBackendTargets({ requireAgcBackend = false } = {}) {
|
||||
return resolveBackendTargetsFromState(readJson(devStackStatePath), {
|
||||
requireAgcBackend,
|
||||
});
|
||||
}
|
||||
|
||||
async function isBackendReady() {
|
||||
const { apiUrl, spacetimeUrl, hasMatchingDatabase } = readBackendTargets({
|
||||
requireAgcDatabase: true,
|
||||
const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({
|
||||
requireAgcBackend: true,
|
||||
});
|
||||
return (
|
||||
hasMatchingDatabase &&
|
||||
hasMatchingBackend &&
|
||||
Boolean(apiUrl) &&
|
||||
Boolean(spacetimeUrl) &&
|
||||
(await isHttpReady(`${apiUrl}/healthz`)) &&
|
||||
@@ -146,9 +177,9 @@ async function isExistingVitePairedWithBackend(apiTarget) {
|
||||
);
|
||||
}
|
||||
|
||||
function spawnChild(command, args, options) {
|
||||
function spawnChild(command, args, options, spawnImpl = spawn) {
|
||||
const useShell = process.platform === 'win32';
|
||||
return spawn(command, args, {
|
||||
const child = spawnImpl(command, args, {
|
||||
...options,
|
||||
shell: useShell,
|
||||
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
|
||||
@@ -156,6 +187,47 @@ function spawnChild(command, args, options) {
|
||||
detached: !useShell,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
const lifecycle = { failure: null, promise: 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') {
|
||||
@@ -179,49 +251,62 @@ function stopChild(child, signal = 'SIGTERM') {
|
||||
|
||||
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
||||
const startedAt = Date.now();
|
||||
let backendExit = null;
|
||||
backendChild?.on('exit', (code, signal) => {
|
||||
backendExit = signal ? `signal=${signal}` : `code=${code ?? 0}`;
|
||||
});
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isBackendReady()) {
|
||||
return readBackendTargets();
|
||||
}
|
||||
if (backendExit) {
|
||||
throw new Error(`配套后端启动失败: ${backendExit}`);
|
||||
const failure = readChildFailure(backendChild);
|
||||
if (failure) {
|
||||
throw new Error(`配套后端启动失败: ${formatChildFailure(failure)}`);
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 1000));
|
||||
await Promise.race([
|
||||
new Promise((resolveWait) => setTimeout(resolveWait, 1000)),
|
||||
waitForChildTermination(backendChild),
|
||||
]);
|
||||
}
|
||||
throw new Error('等待配套后端和数据库启动超时');
|
||||
}
|
||||
|
||||
async function ensureBackend() {
|
||||
if (await isBackendReady()) {
|
||||
const targets = readBackendTargets();
|
||||
async function ensureBackend({
|
||||
onBackendChild = () => {},
|
||||
checkBackendReady = isBackendReady,
|
||||
resolveTargets = readBackendTargets,
|
||||
spawnBackend = () =>
|
||||
spawnChild(
|
||||
npm,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'run',
|
||||
'agc:backend',
|
||||
'--',
|
||||
'--database',
|
||||
backendDatabase,
|
||||
'--spacetime-data-dir',
|
||||
backendSpacetimeDataDir,
|
||||
'--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 = spawnChild(
|
||||
npm,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'run',
|
||||
'agc:backend',
|
||||
'--',
|
||||
'--database',
|
||||
backendDatabase,
|
||||
'--spacetime-data-dir',
|
||||
backendSpacetimeDataDir,
|
||||
'--no-interactive',
|
||||
],
|
||||
{ cwd: appRoot },
|
||||
);
|
||||
const targets = await waitForBackendReady(backendChild);
|
||||
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
|
||||
return { backendChild, targets };
|
||||
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) {
|
||||
@@ -239,7 +324,9 @@ async function startVite(apiTarget) {
|
||||
(await isExistingVitePairedWithBackend(apiTarget)) &&
|
||||
(await isExistingViteProxyReady())
|
||||
) {
|
||||
console.log(`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (isAiGameCreatorServer(existing)) {
|
||||
@@ -259,40 +346,86 @@ async function startVite(apiTarget) {
|
||||
);
|
||||
}
|
||||
|
||||
let backendChild = null;
|
||||
let viteChild = null;
|
||||
async function main() {
|
||||
let backendChild = null;
|
||||
let viteChild = null;
|
||||
let shutdownSignal = '';
|
||||
const signalHandlers = new Map();
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = await ensureBackend();
|
||||
backendChild = backend.backendChild;
|
||||
viteChild = await startVite(backend.targets.apiUrl);
|
||||
|
||||
const children = [backendChild, viteChild].filter(Boolean);
|
||||
if (children.length === 0) {
|
||||
process.exit(0);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
const handler = () => {
|
||||
shutdownSignal = signal;
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
};
|
||||
signalHandlers.set(signal, handler);
|
||||
process.on(signal, handler);
|
||||
}
|
||||
|
||||
await new Promise((resolveExit) => {
|
||||
for (const child of children) {
|
||||
child.on('exit', (code, signal) => {
|
||||
stopChild(viteChild);
|
||||
stopChild(backendChild);
|
||||
resolveExit(signal ? 1 : code ?? 0);
|
||||
});
|
||||
try {
|
||||
const backend = await ensureBackend({
|
||||
onBackendChild(child) {
|
||||
backendChild = child;
|
||||
if (shutdownSignal) {
|
||||
stopChild(child, shutdownSignal);
|
||||
}
|
||||
},
|
||||
});
|
||||
backendChild = backend.backendChild;
|
||||
if (shutdownSignal) {
|
||||
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
|
||||
}
|
||||
}).then((code) => process.exit(code));
|
||||
} catch (error) {
|
||||
stopChild(viteChild);
|
||||
stopChild(backendChild);
|
||||
console.error(
|
||||
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
viteChild = await startVite(backend.targets.apiUrl);
|
||||
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 {
|
||||
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,
|
||||
isDirectModuleExecution,
|
||||
readChildFailure,
|
||||
resolveBackendTargetsFromState,
|
||||
spawnChild,
|
||||
stopChild,
|
||||
waitForBackendReady,
|
||||
waitForChildTermination,
|
||||
};
|
||||
|
||||
if (isDirectModuleExecution()) {
|
||||
process.exitCode = await main();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureBackend,
|
||||
resolveBackendTargetsFromState,
|
||||
spawnChild,
|
||||
waitForChildTermination,
|
||||
} from '../scripts/start-dev-stack.mjs';
|
||||
|
||||
const expectedDatabase = 'genarrative-game-creator-dev';
|
||||
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
|
||||
|
||||
function backendState(spacetimeDataDir?: string) {
|
||||
return {
|
||||
schemaVersion: spacetimeDataDir ? 2 : 1,
|
||||
database: expectedDatabase,
|
||||
...(spacetimeDataDir ? { spacetimeDataDir } : {}),
|
||||
services: {
|
||||
'api-server': {
|
||||
status: 'running',
|
||||
url: 'http://127.0.0.1:8082',
|
||||
},
|
||||
spacetime: {
|
||||
status: 'running',
|
||||
url: 'http://127.0.0.1:3101',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AI 游戏创作配套后端复用门禁', () => {
|
||||
test('旧状态缺少专用 data dir 时拒绝复用同名健康后端', () => {
|
||||
const targets = resolveBackendTargetsFromState(backendState(), {
|
||||
requireAgcBackend: true,
|
||||
expectedDatabase,
|
||||
expectedSpacetimeDataDir: expectedDataDir,
|
||||
});
|
||||
|
||||
expect(targets.hasMatchingDatabase).toBe(true);
|
||||
expect(targets.hasMatchingDataDir).toBe(false);
|
||||
expect(targets.hasMatchingBackend).toBe(false);
|
||||
expect(targets.apiUrl).toBe('');
|
||||
expect(targets.spacetimeUrl).toBe('');
|
||||
});
|
||||
|
||||
test('只有数据库名和专用 data dir 都匹配时才允许复用', () => {
|
||||
const wrongDir = resolveBackendTargetsFromState(
|
||||
backendState(resolve('server-rs/.spacetimedb/local/data')),
|
||||
{
|
||||
requireAgcBackend: true,
|
||||
expectedDatabase,
|
||||
expectedSpacetimeDataDir: expectedDataDir,
|
||||
},
|
||||
);
|
||||
const matching = resolveBackendTargetsFromState(
|
||||
backendState(expectedDataDir),
|
||||
{
|
||||
requireAgcBackend: true,
|
||||
expectedDatabase,
|
||||
expectedSpacetimeDataDir: expectedDataDir,
|
||||
},
|
||||
);
|
||||
|
||||
expect(wrongDir.hasMatchingBackend).toBe(false);
|
||||
expect(matching.hasMatchingBackend).toBe(true);
|
||||
expect(matching.apiUrl).toBe('http://127.0.0.1:8082');
|
||||
expect(matching.spacetimeUrl).toBe('http://127.0.0.1:3101');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI 游戏创作启动子进程生命周期', () => {
|
||||
const posixTest = process.platform === 'win32' ? test.skip : test;
|
||||
|
||||
posixTest('npm 不可解析时进入受控 error 结果而不是未处理事件', async () => {
|
||||
const child = spawnChild('genarrative-command-that-does-not-exist', [], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const failure = await waitForChildTermination(child);
|
||||
|
||||
expect(failure.type).toBe('error');
|
||||
expect(failure.error).toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: vi.fn(),
|
||||
});
|
||||
const onBackendChild = vi.fn();
|
||||
const waitUntilReady = vi.fn(async (receivedChild) => {
|
||||
expect(receivedChild).toBe(child);
|
||||
expect(onBackendChild).toHaveBeenCalledWith(child);
|
||||
throw new Error('等待配套后端和数据库启动超时');
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureBackend({
|
||||
checkBackendReady: async () => false,
|
||||
spawnBackend: () => child,
|
||||
onBackendChild,
|
||||
waitUntilReady,
|
||||
}),
|
||||
).rejects.toThrow('等待配套后端和数据库启动超时');
|
||||
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
});
|
||||
});
|
||||
@@ -3514,7 +3514,7 @@
|
||||
|
||||
- 现象:`npm run agc` 在发布模块时先访问 `auth.spacetimedb.com` 并以 401 失败;改成 `--anonymous` 后首次可能成功,但再次启动会因匿名 identity 变化而 403。若把 403 当成可忽略警告继续启动,api-server 会连接旧 schema,随后持续输出 `external_generation_job`、`profile_recharge_order_expiration_timer` 等缺表订阅失败,Tauri 也可能在后端就绪前退出或迟迟不弹窗。
|
||||
- 原因:本地 publish 默认继承开发者全局 SpacetimeDB 云端登录,离线时 standalone 无法校验 issuer;`--anonymous` 不是可跨进程持久复用的 owner identity;AI 游戏创作壳若再复用主站历史数据目录,还会继承旧数据库归属和旧 schema。
|
||||
- 处理:AI 游戏创作壳固定使用 gitignored 的独立数据目录;standalone 就绪后先从 `/v1/identity` 获取并持久化同一 API identity,再用数据目录内权限为 `0600` 的独立 `cli.toml` 执行 `spacetime login --token` 和 publish。远程 server 继续使用正常登录配置;本地 publish 403 必须阻断 API/Vite,不得带旧 schema 降级启动。POSIX 启动器用独立进程组收束 npm、Node、Cargo 和子进程,退出后确认 3080、8082、3101 均释放。
|
||||
- 处理:AI 游戏创作壳固定使用 gitignored 的独立数据目录;standalone 就绪后先从 `/v1/identity` 获取并按 data dir 而非监听端口持久化同一 API identity,再用数据目录内权限为 `0600` 的独立 `cli.toml` 执行 `spacetime login --token` 和 publish。旧端口作用域记录在同一 data dir 下身份唯一时迁移,存在多个不同身份时失败关闭,不能猜 owner。远程 server 继续使用正常登录配置;本地 publish 403 必须阻断 API/Vite,不得带旧 schema 降级启动。`.app/dev-stack.json` 记录规范化 data dir,独立壳复用后端时必须同时匹配数据库名、专用目录和健康状态;缺少目录字段的旧状态不得复用。POSIX 启动器在 `spawn` 后立即监听 `error / exit`、向外层登记句柄并用独立进程组收束 npm、Node、Cargo 和子进程;ready 前中断、超时或 ENOENT 也走统一清理,退出后确认 3080、8082、3101 均释放。
|
||||
- macOS 日志:api-server 进程指标当前只实现 Windows API 和 Linux `/proc`,macOS 必须跳过 observable callback 注册;不能每轮采集为每个指标重复打印“不支持平台”。Rust/Tauri 既有 `dead_code` warning 与一次性配置缺失提示不属于长驻重试日志。
|
||||
- 验证:连续运行两次 `npm run agc`,两次都必须真实完成 module publish、`/v1/ping`、`/healthz`、Vite 3080 和 Tauri `Running`;稳定观察期间不得出现缺表订阅失败或进程指标平台告警,Ctrl-C 后三个端口和主 Tauri 进程均应释放。
|
||||
- 验证:定向测试覆盖同一 data dir 跨端口复用 identity、不同 data dir 隔离、旧 state/data dir 不匹配拒绝复用、spawn ENOENT 受控失败,以及后端 ready 前句柄已登记且超时清理。连续运行两次 `npm run agc`,两次都必须真实完成 module publish、`/v1/ping`、`/healthz`、Vite 3080 和 Tauri `Running`;稳定观察期间不得出现缺表订阅失败或进程指标平台告警,Ctrl-C 后三个端口和主 Tauri 进程均应释放。
|
||||
- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`server-rs/crates/api-server/src/process_metrics.rs`。
|
||||
|
||||
@@ -449,6 +449,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`。
|
||||
- `npm run agc` 的本地 SpacetimeDB owner identity 以独立 `spacetimeDataDir` 为作用域,不绑定可能漂移的监听端口;旧端口作用域记录仅在同一 data dir 下身份唯一时自动迁移,出现多个不同旧身份时失败关闭。`.app/dev-stack.json` 必须记录规范化 `spacetimeDataDir`,独立壳只复用数据库名和该目录同时匹配且健康的后端,旧 schema 状态或共享目录状态缺少此字段时不得复用。POSIX 子进程在 `spawn` 返回时立即登记 `error / exit` 生命周期并把句柄交给外层;后端 ready 前的 SIGINT、SIGTERM、超时或 ENOENT 都必须走同一进程组清理链路,不能遗留 npm、Cargo 或 SpacetimeDB。
|
||||
- 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`。
|
||||
- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。
|
||||
|
||||
+99
-48
@@ -383,10 +383,11 @@ function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
command: runner.command ?? 'all',
|
||||
repoRoot,
|
||||
database: runner.options.database,
|
||||
spacetimeDataDir: resolve(runner.options.spacetimeDataDir),
|
||||
watch: Boolean(runner.options.watch),
|
||||
updatedAt,
|
||||
services,
|
||||
@@ -924,7 +925,7 @@ function readLinuxApiServerProcessSnapshot(pid) {
|
||||
if (
|
||||
error?.code === 'ENOENT' ||
|
||||
error?.code === 'EACCES' ||
|
||||
error?.code === 'EPERM' ||
|
||||
error?.code === 'EPERM' ||
|
||||
error?.code === 'ESRCH'
|
||||
) {
|
||||
return null;
|
||||
@@ -1124,7 +1125,11 @@ class DevRunner {
|
||||
this.command = command;
|
||||
ensureRequiredFiles(command);
|
||||
requireCommand('node');
|
||||
if (command === 'api-server' || command === 'all' || command === 'backend') {
|
||||
if (
|
||||
command === 'api-server' ||
|
||||
command === 'all' ||
|
||||
command === 'backend'
|
||||
) {
|
||||
requireCommand('cargo');
|
||||
}
|
||||
if (
|
||||
@@ -1319,7 +1324,11 @@ class DevRunner {
|
||||
}
|
||||
}
|
||||
|
||||
if (command === 'all' || command === 'backend' || command === 'api-server') {
|
||||
if (
|
||||
command === 'all' ||
|
||||
command === 'backend' ||
|
||||
command === 'api-server'
|
||||
) {
|
||||
portConfig.api = {
|
||||
host: options.apiHost,
|
||||
preferredPort: options.apiPort,
|
||||
@@ -2490,10 +2499,85 @@ function normalizeSpacetimeServerForIdentity(serverUrl) {
|
||||
return url.href.replace(/\/$/u, '');
|
||||
}
|
||||
|
||||
function resolveLocalSpacetimeApiIdentityPath(dataDir, serverUrl) {
|
||||
const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl);
|
||||
const serverKey = createHash('sha256').update(normalizedServer).digest('hex');
|
||||
return resolve(dataDir, 'dev-api-identities', `${serverKey}.json`);
|
||||
function resolveLocalSpacetimeApiIdentityPath(dataDir) {
|
||||
return resolve(dataDir, 'dev-api-identities', 'local-node.json');
|
||||
}
|
||||
|
||||
function readLocalSpacetimeApiIdentityRecord(identityPath, expected = {}) {
|
||||
const stat = lstatSync(identityPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error('记录不是普通文件');
|
||||
}
|
||||
chmodSync(identityPath, 0o600);
|
||||
const payload = JSON.parse(readFileSync(identityPath, 'utf8'));
|
||||
const identity =
|
||||
typeof payload.identity === 'string' ? payload.identity.trim() : '';
|
||||
const token = typeof payload.token === 'string' ? payload.token.trim() : '';
|
||||
if (!identity || !token) {
|
||||
throw new Error('记录缺少 identity 或 token');
|
||||
}
|
||||
|
||||
if (payload.schemaVersion === 2 && payload.scope === 'local-data-dir') {
|
||||
return { identity, token };
|
||||
}
|
||||
if (
|
||||
expected.allowLegacy &&
|
||||
payload.schemaVersion === 1 &&
|
||||
typeof payload.server === 'string' &&
|
||||
isLoopbackSpacetimeServer(payload.server)
|
||||
) {
|
||||
return { identity, token };
|
||||
}
|
||||
throw new Error('记录格式或 data dir 作用域不匹配');
|
||||
}
|
||||
|
||||
function migrateLegacyLocalSpacetimeApiIdentity(dataDir) {
|
||||
const identityDir = resolve(dataDir, 'dev-api-identities');
|
||||
if (!existsSync(identityDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const entry of readdirSync(identityDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
||||
continue;
|
||||
}
|
||||
const candidatePath = resolve(identityDir, entry.name);
|
||||
if (candidatePath === resolveLocalSpacetimeApiIdentityPath(dataDir)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
candidates.push(
|
||||
readLocalSpacetimeApiIdentityRecord(candidatePath, {
|
||||
allowLegacy: true,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// 无效或非本地旧记录不参与迁移。
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueCandidates = new Map(
|
||||
candidates.map((candidate) => [
|
||||
`${candidate.identity}\n${candidate.token}`,
|
||||
candidate,
|
||||
]),
|
||||
);
|
||||
if (uniqueCandidates.size === 0) {
|
||||
return null;
|
||||
}
|
||||
if (uniqueCandidates.size > 1) {
|
||||
throw new Error(
|
||||
'同一 SpacetimeDB data dir 下发现多个旧 API identity,无法安全判断数据库 owner;请保留正确 owner 记录后重试',
|
||||
);
|
||||
}
|
||||
|
||||
const [identity] = uniqueCandidates.values();
|
||||
writeLocalSpacetimeApiIdentity({ dataDir, ...identity });
|
||||
console.log(
|
||||
'[dev:spacetime] 已将旧端口作用域 API identity 迁移到 data dir 作用域',
|
||||
);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath(
|
||||
@@ -2643,37 +2727,13 @@ function readLocalSpacetimeApiIdentity({ dataDir, serverUrl }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl);
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(
|
||||
dataDir,
|
||||
normalizedServer,
|
||||
);
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(dataDir);
|
||||
if (!existsSync(identityPath)) {
|
||||
return null;
|
||||
return migrateLegacyLocalSpacetimeApiIdentity(dataDir);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = lstatSync(identityPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error('记录不是普通文件');
|
||||
}
|
||||
chmodSync(identityPath, 0o600);
|
||||
const payload = JSON.parse(readFileSync(identityPath, 'utf8'));
|
||||
if (
|
||||
payload.schemaVersion !== 1 ||
|
||||
payload.server !== normalizedServer ||
|
||||
typeof payload.identity !== 'string' ||
|
||||
!payload.identity.trim() ||
|
||||
typeof payload.token !== 'string' ||
|
||||
!payload.token.trim()
|
||||
) {
|
||||
throw new Error('记录格式或 server 绑定不匹配');
|
||||
}
|
||||
|
||||
return {
|
||||
identity: payload.identity.trim(),
|
||||
token: payload.token.trim(),
|
||||
};
|
||||
return readLocalSpacetimeApiIdentityRecord(identityPath);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[dev:spacetime] 本地 API identity 记录不可用,将重新创建: ${error.message}`,
|
||||
@@ -2682,17 +2742,8 @@ function readLocalSpacetimeApiIdentity({ dataDir, serverUrl }) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalSpacetimeApiIdentity({
|
||||
dataDir,
|
||||
serverUrl,
|
||||
identity,
|
||||
token,
|
||||
}) {
|
||||
const normalizedServer = normalizeSpacetimeServerForIdentity(serverUrl);
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(
|
||||
dataDir,
|
||||
normalizedServer,
|
||||
);
|
||||
function writeLocalSpacetimeApiIdentity({ dataDir, identity, token }) {
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(dataDir);
|
||||
const tempPath = `${identityPath}.${process.pid}.${randomHex(8)}.tmp`;
|
||||
ensureParentDir(identityPath);
|
||||
|
||||
@@ -2700,8 +2751,8 @@ function writeLocalSpacetimeApiIdentity({
|
||||
writeFileSync(
|
||||
tempPath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
server: normalizedServer,
|
||||
schemaVersion: 2,
|
||||
scope: 'local-data-dir',
|
||||
identity,
|
||||
token,
|
||||
})}\n`,
|
||||
|
||||
+101
-24
@@ -11,7 +11,7 @@ import {
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -510,9 +510,12 @@ describe('dev scheduler stack state file', () => {
|
||||
|
||||
const snapshot = buildDevStackSnapshot(runner, updatedAt);
|
||||
|
||||
expect(snapshot.schemaVersion).toBe(1);
|
||||
expect(snapshot.schemaVersion).toBe(2);
|
||||
expect(snapshot.command).toBe('web');
|
||||
expect(snapshot.database).toBe('genarrative-test');
|
||||
expect(snapshot.spacetimeDataDir).toBe(
|
||||
resolve('server-rs/.spacetimedb/local/data'),
|
||||
);
|
||||
expect(snapshot.services.web).toMatchObject({
|
||||
status: 'running',
|
||||
pid: 4321,
|
||||
@@ -827,26 +830,18 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0;
|
||||
expect(runner.publishSpacetimeModule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('本地 API identity 路径同时绑定 data dir 和规范化 server', () => {
|
||||
test('本地 API identity 路径只绑定 data dir', () => {
|
||||
const first = resolveLocalSpacetimeApiIdentityPath(
|
||||
'/tmp/genarrative-data-a',
|
||||
'http://127.0.0.1:3101',
|
||||
);
|
||||
const normalizedEquivalent = resolveLocalSpacetimeApiIdentityPath(
|
||||
const sameDataDir = resolveLocalSpacetimeApiIdentityPath(
|
||||
'/tmp/genarrative-data-a',
|
||||
'http://127.0.0.1:3101/',
|
||||
);
|
||||
const otherServer = resolveLocalSpacetimeApiIdentityPath(
|
||||
'/tmp/genarrative-data-a',
|
||||
'http://127.0.0.1:3102',
|
||||
);
|
||||
const otherDataDir = resolveLocalSpacetimeApiIdentityPath(
|
||||
'/tmp/genarrative-data-b',
|
||||
'http://127.0.0.1:3101',
|
||||
);
|
||||
|
||||
expect(normalizedEquivalent).toBe(first);
|
||||
expect(otherServer).not.toBe(first);
|
||||
expect(sameDataDir).toBe(first);
|
||||
expect(otherDataDir).not.toBe(first);
|
||||
expect(first).toContain(join('genarrative-data-a', 'dev-api-identities'));
|
||||
});
|
||||
@@ -873,10 +868,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0;
|
||||
|
||||
await firstRunner.ensureApiServerSpacetimeToken();
|
||||
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(
|
||||
tempDir,
|
||||
firstRunner.state.spacetimeServer,
|
||||
);
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(tempDir);
|
||||
expect(firstRunner.spacetimeApiToken).toBe('local-api-token');
|
||||
expect(firstRunner.baseEnv.GENARRATIVE_SPACETIME_TOKEN).toBeUndefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
@@ -884,8 +876,8 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0;
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
expect(JSON.parse(readFileSync(identityPath, 'utf8'))).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
server: 'http://127.0.0.1:3101',
|
||||
schemaVersion: 2,
|
||||
scope: 'local-data-dir',
|
||||
identity: 'c200localidentity',
|
||||
token: 'local-api-token',
|
||||
});
|
||||
@@ -895,7 +887,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0;
|
||||
}
|
||||
|
||||
const secondRunner = new DevRunner(options, {}, explicitOptions);
|
||||
secondRunner.state.spacetimeServer = 'http://127.0.0.1:3101';
|
||||
secondRunner.state.spacetimeServer = 'http://127.0.0.1:3199';
|
||||
globalThis.fetch = vi.fn();
|
||||
|
||||
await secondRunner.ensureApiServerSpacetimeToken();
|
||||
@@ -914,6 +906,94 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0;
|
||||
}
|
||||
});
|
||||
|
||||
test('旧端口作用域 API identity 会迁移为 data dir 作用域', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||||
try {
|
||||
const legacyServer = 'http://127.0.0.1:3101';
|
||||
const legacyKey = createHash('sha256').update(legacyServer).digest('hex');
|
||||
const legacyPath = join(
|
||||
tempDir,
|
||||
'dev-api-identities',
|
||||
`${legacyKey}.json`,
|
||||
);
|
||||
mkdirSync(dirname(legacyPath), { recursive: true });
|
||||
writeFileSync(
|
||||
legacyPath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
server: legacyServer,
|
||||
identity: 'legacy-owner-identity',
|
||||
token: 'legacy-owner-token',
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const { explicitOptions, options } = parseArgs(
|
||||
['--spacetime-data-dir', tempDir],
|
||||
{},
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
runner.state.spacetimeServer = 'http://127.0.0.1:3199';
|
||||
globalThis.fetch = vi.fn();
|
||||
|
||||
await runner.ensureApiServerSpacetimeToken();
|
||||
|
||||
expect(runner.spacetimeApiToken).toBe('legacy-owner-token');
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(resolveLocalSpacetimeApiIdentityPath(tempDir), 'utf8'),
|
||||
),
|
||||
).toMatchObject({
|
||||
schemaVersion: 2,
|
||||
scope: 'local-data-dir',
|
||||
identity: 'legacy-owner-identity',
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('同一 data dir 存在多个旧 identity 时失败关闭', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||||
try {
|
||||
for (const [port, identity] of [
|
||||
[3101, 'legacy-owner-a'],
|
||||
[3199, 'legacy-owner-b'],
|
||||
] as const) {
|
||||
const server = `http://127.0.0.1:${port}`;
|
||||
const legacyPath = join(
|
||||
tempDir,
|
||||
'dev-api-identities',
|
||||
`${createHash('sha256').update(server).digest('hex')}.json`,
|
||||
);
|
||||
mkdirSync(dirname(legacyPath), { recursive: true });
|
||||
writeFileSync(
|
||||
legacyPath,
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
server,
|
||||
identity,
|
||||
token: `${identity}-token`,
|
||||
})}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
const { explicitOptions, options } = parseArgs(
|
||||
['--spacetime-data-dir', tempDir],
|
||||
{},
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
globalThis.fetch = vi.fn();
|
||||
|
||||
await expect(runner.ensureApiServerSpacetimeToken()).rejects.toThrow(
|
||||
'无法安全判断数据库 owner',
|
||||
);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('外部显式 token 优先于已持久化的本地 API identity', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-api-identity-'));
|
||||
const originalToken = process.env.GENARRATIVE_SPACETIME_TOKEN;
|
||||
@@ -969,10 +1049,7 @@ spacetimedb tool version 2.6.0; spacetimedb-lib version 2.6.0;
|
||||
);
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
runner.state.spacetimeServer = 'http://127.0.0.1:3101';
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(
|
||||
tempDir,
|
||||
runner.state.spacetimeServer,
|
||||
);
|
||||
const identityPath = resolveLocalSpacetimeApiIdentityPath(tempDir);
|
||||
mkdirSync(dirname(identityPath), { recursive: true });
|
||||
const linkedRecordPath = join(tempDir, 'linked-api-identity.json');
|
||||
writeFileSync(
|
||||
|
||||
Reference in New Issue
Block a user