修复AI游戏创作壳跨平台启动 (#107)
Project CI / Frontend tests (push) Failing after 20s
Project CI / Repository checks (push) Successful in 1m33s
Project CI / Backend tests (push) Successful in 4m7s
Project CI / Native shell tests (push) Successful in 11m20s

修复 macOS 下 Unix 文件身份比较和临时目录测试兼容
隔离 AI 游戏创作本地数据库与发布身份并阻止旧 schema 降级启动
完善 Tauri 开发栈错误传播和 POSIX 子进程树清理
跳过 macOS 不支持的进程指标回调以消除周期告警
补充开发调度测试、技术方案和团队排障记忆

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/107
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
This commit was merged in pull request #107.
This commit is contained in:
2026-07-23 10:50:02 +08:00
committed by 段舒康
parent 27f66bf0b1
commit ff84b5a308
15 changed files with 909 additions and 216 deletions
+176 -57
View File
@@ -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,
@@ -1513,12 +1522,11 @@ class DevRunner {
await this.publishSpacetimeModule();
} catch (error) {
if (isSpacetimePublishPermissionError(error)) {
console.warn(
`[dev:spacetime] 本地发布被当前 identity 拒绝,保留已启动的 standalone: ${error.message}`,
throw new Error(
`本地数据库不属于当前隔离 identity,已停止启动以避免 API 使用旧 schema 后持续重试订阅。请改用独立本地数据目录,或在确认无需保留旧开发数据后重建该目录。详情: ${error.message}`,
);
} else {
throw error;
}
throw error;
}
}
}
@@ -1645,8 +1653,10 @@ class DevRunner {
async publishSpacetimeModule() {
const env = buildLocalRustProcessEnv(this.baseEnv);
this.prepareMigrationBootstrapSecret(env);
const cliConfigPath = await this.prepareLocalSpacetimeCliIdentity(env);
const args = buildSpacetimePublishArgs({
cliConfigPath,
database: this.options.database,
preserveDatabase: this.options.preserveDatabase,
server: this.state.spacetimeServer,
@@ -1660,6 +1670,48 @@ class DevRunner {
});
}
async prepareLocalSpacetimeCliIdentity(env) {
if (!isLoopbackSpacetimeServer(this.state.spacetimeServer)) {
return '';
}
await this.ensureApiServerSpacetimeToken();
const cliConfigPath = resolve(
this.options.spacetimeDataDir,
'dev-cli',
'cli.toml',
);
ensureParentDir(cliConfigPath);
if (
existsSync(cliConfigPath) &&
resolveCurrentSpacetimeCliToken(cliConfigPath) === this.spacetimeApiToken
) {
chmodSync(cliConfigPath, 0o600);
console.log('[dev:spacetime] 已复用隔离的本地发布 identity');
return cliConfigPath;
}
await runForeground(
'spacetime',
[
'--config-path',
cliConfigPath,
'login',
'--token',
this.spacetimeApiToken,
],
{
cwd: serverRsDir,
env,
label: 'spacetime-login',
},
);
if (existsSync(cliConfigPath)) {
chmodSync(cliConfigPath, 0o600);
}
console.log('[dev:spacetime] 已配置隔离的本地发布 identity');
return cliConfigPath;
}
prepareMigrationBootstrapSecret(env) {
let runtimeServiceBootstrapSecret = '';
switch (this.options.migrationBootstrapSecretMode) {
@@ -2447,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(
@@ -2600,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}`,
@@ -2639,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);
@@ -2657,8 +2751,8 @@ function writeLocalSpacetimeApiIdentity({
writeFileSync(
tempPath,
`${JSON.stringify({
schemaVersion: 1,
server: normalizedServer,
schemaVersion: 2,
scope: 'local-data-dir',
identity,
token,
})}\n`,
@@ -2814,8 +2908,14 @@ function isLoopbackSpacetimeServer(serverUrl) {
}
}
function resolveCurrentSpacetimeCliToken() {
const result = spawnSync('spacetime', ['login', 'show', '--token'], {
function resolveCurrentSpacetimeCliToken(cliConfigPath = '') {
const args = [
...(cliConfigPath ? ['--config-path', cliConfigPath] : []),
'login',
'show',
'--token',
];
const result = spawnSync('spacetime', args, {
cwd: repoRoot,
encoding: 'utf8',
shell: process.platform === 'win32',
@@ -2839,13 +2939,21 @@ function trimPreview(text, maxLength = 300) {
function runForeground(command, args, { cwd, env, label }) {
return new Promise((resolveRun, rejectRun) => {
let capturedOutput = '';
const capture = (chunk, target) => {
target.write(chunk);
capturedOutput = `${capturedOutput}${String(chunk)}`.slice(-32_768);
};
const child = spawn(command, args, {
cwd,
env,
stdio: 'inherit',
stdio: ['inherit', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});
child.stdout?.on('data', (chunk) => capture(chunk, process.stdout));
child.stderr?.on('data', (chunk) => capture(chunk, process.stderr));
child.on('error', rejectRun);
child.on('exit', (code, signal) => {
if (signal) {
@@ -2854,7 +2962,12 @@ function runForeground(command, args, { cwd, env, label }) {
}
if (code !== 0) {
rejectRun(new Error(`[dev:${label}] 退出码: ${code}`));
const detail = trimPreview(capturedOutput, 2_000);
rejectRun(
new Error(
`[dev:${label}] 退出码: ${code}${detail ? `: ${detail}` : ''}`,
),
);
return;
}
@@ -2914,8 +3027,14 @@ function isDirectModuleExecution(argv1, moduleUrl, resolvePath = safeRealpath) {
}
}
function buildSpacetimePublishArgs({ database, server, preserveDatabase }) {
function buildSpacetimePublishArgs({
cliConfigPath = '',
database,
server,
preserveDatabase,
}) {
const args = [
...(cliConfigPath ? ['--config-path', cliConfigPath] : []),
'publish',
database,
'--server',