From 5a9bc10bb6e3f113719dc929ab92c4403fb2c2bf Mon Sep 17 00:00:00 2001 From: menghao Date: Wed, 22 Jul 2026 16:54:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAI=E6=B8=B8=E6=88=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E5=90=AF=E5=8A=A8=E8=AF=84=E5=AE=A1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将本地发布身份改为按SpacetimeDB数据目录持久化并兼容迁移旧记录 为开发栈状态补充专用数据目录并拒绝复用旧共享后端 统一处理子进程启动错误、启动期信号和进程组清理 补充身份、后端复用与子进程生命周期回归测试及文档 --- .../scripts/start-dev-stack.mjs | 287 +++++++++++++----- .../tests/start-dev-stack.test.ts | 112 +++++++ docs/project-memory/shared-memory/pitfalls.md | 4 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + scripts/dev.mjs | 147 ++++++--- scripts/dev.test.ts | 125 ++++++-- 6 files changed, 525 insertions(+), 151 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/start-dev-stack.test.ts diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index a04f5d85b..9c121d191 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -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(); } diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts new file mode 100644 index 000000000..877e0da1a --- /dev/null +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -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'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 00ddc076f..284c55478 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index af9a832f9..61527e2a7 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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 对话历史的事实源;目录在首次写入时创建。 diff --git a/scripts/dev.mjs b/scripts/dev.mjs index e45ab806b..49ba11217 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -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`, diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index b7257987b..f6e09fd93 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -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(