diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0bf78d647..ad4791429 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,4 +1,10 @@ # 决策记录 +## 2026-10-01 Web、后台与 AGC 一键联调 + +- 背景:Web、管理后台和 AGC 同时开发时,分别启动入口容易产生两套 API/worker/SpacetimeDB,以及重复后台 Vite。 +- 决策:新增 `npm run dev:all`,保持 `npm run dev` 现有主站完整栈语义不变;一键入口固定使用 AGC 的 database/data dir,先启动根完整栈,待五个服务就绪后由 AGC 复用该后端,再启动 AGC Vite 与 Tauri,并关闭 AGC 自带后台。 +- 影响范围:根开发脚本、AGC 开发启动编排、本地开发运维文档;不改变 API、schema、生产部署和独立 `npm run agc` 行为。 +- 验证方式:参数/状态单测、开发栈健康端点 smoke、`.app/dev-stack.json` 身份复用检查、进程树收束检查。 ## 2026-09-30 release 渠道移除产品名与包名后缀 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 93e3e407c..4634ed447 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -23,7 +23,7 @@ Genarrative / 陶泥儿当前主站聚焦图片画布创作、编辑器项目与 ## 本地开发端口真相 -`scripts/dev.mjs` 的默认值是 Web `3000`、API `8082`、BgFilter worker `8083`、SpacetimeDB `3101`、后台 Web `3102`。Linux 用户端口段会把这五个服务映射到 `start` 至 `start+4`,AGC Vite 使用 `start+5`;显式端口或端口段配置可覆盖默认值。 +`scripts/dev.mjs` 的默认值是 Web `3000`、API `8082`、BgFilter worker `8083`、SpacetimeDB `3101`、后台 Web `3102`。Linux 用户端口段会把这五个服务映射到 `start` 至 `start+4`,AGC Vite 使用 `start+5`;显式端口或端口段配置可覆盖默认值。`npm run dev:all` 复用 AGC 的数据库和 data dir 启动一份共享后端,并在根 dev 栈就绪后启动 AGC Vite 与 Tauri;管理后台只由根 dev 栈启动。 端口的运行时权威始终是当前工作区的 `.app/dev-stack.json` 与启动日志(该文件可能在未启动时不存在),不得从文档默认值推断当前监听端口。前端代理、API URL、SpacetimeDB 地址和 AGC Vite 地址都必须读取同一份运行时状态。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ec8a2ef39..3489fc64b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -70,6 +70,15 @@ npm run dev - 后台 Vite。 `npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令;稳定版状态还记录顶层 `repoRoot + instanceId`,每个服务记录 `repoRoot + instanceId + dataDir`,与端口组成复用身份。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。缺少身份字段或身份不匹配的旧状态不得被 AGC 静默复用。 +一键启动 Web、后台与 AGC: + +```bash +npm run dev:all +``` + +`npm run dev:all` 保持 `npm run dev` 的主站完整栈语义不变,使用 AGC 当前配套后端的数据库 `genarrative-game-creator-dev` 与 data dir `server-rs/.spacetimedb/ai-game-creator/data` 启动一份 SpacetimeDB、BgFilter worker、api-server、主站 Vite 和后台 Vite;待这五个服务就绪后,再启动 AGC Vite 与 Tauri。AGC 通过匹配的 `.app/dev-stack.json` 复用这份后端,且设置 `AGC_DEV_ADMIN_WEB=0`,因此一键入口只保留一份管理后台。该入口不接受覆盖 database 或 SpacetimeDB data dir 的参数;需要独立数据库时分别使用 `npm run dev` / `npm run agc`。 + +一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。任一子进程异常退出都应停止另一侧并返回非零退出码;端口漂移和运行态地址仍以启动日志及 `.app/dev-stack.json` 为准。 通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。 diff --git a/package.json b/package.json index c26f72011..a4d1641c8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "scripts": { "prepare": "husky", "dev": "node scripts/dev.mjs", + "dev:all": "node scripts/dev-all.mjs", "dev:spacetime": "node scripts/dev.mjs spacetime", "dev:api-server": "node scripts/dev.mjs api-server", "dev:bgfilter-worker": "node scripts/dev.mjs bgfilter-worker", diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs new file mode 100644 index 000000000..f69d88b53 --- /dev/null +++ b/scripts/dev-all.mjs @@ -0,0 +1,478 @@ +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { resolveAgcDevEndpoint } from '../apps/ai-game-creator-shell/scripts/dev-port.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..'); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const agcDatabase = 'genarrative-game-creator-dev'; +const agcDataDir = resolve( + repoRoot, + 'server-rs/.spacetimedb/ai-game-creator/data', +); +const agcDataDirArgument = 'server-rs/.spacetimedb/ai-game-creator/data'; +const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); +const rootReadyTimeoutMs = 660_000; +const rootReadyPollMs = 500; +const childGracefulTimeoutMs = 2_500; +const childForceTimeoutMs = 2_000; + +const rootOnlyOptions = new Set([ + '--database', + '--spacetime-data-dir', + '--database=', + '--spacetime-data-dir=', +]); + +const optionsWithValues = new Set([ + '--api-host', + '--api-port', + '--bgfilter-worker-host', + '--bgfilter-worker-port', + '--web-host', + '--web-port', + '--admin-web-host', + '--admin-web-port', + '--spacetime-host', + '--spacetime-port', + '--port-range', + '--log', + '--spacetime-timeout-seconds', + '--api-timeout-seconds', + '--migration-bootstrap-secret', +]); + +function isRootOnlyOption(argument) { + return [...rootOnlyOptions].some( + (option) => argument === option || argument.startsWith(option), + ); +} + +function validateDevAllArgs(argv = []) { + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument.startsWith('-')) { + throw new Error( + `npm run dev:all 只接受 dev 选项,不接受子命令或位置参数: ${argument}`, + ); + } + if (isRootOnlyOption(argument)) { + throw new Error( + 'npm run dev:all 固定使用 AGC 配套 database 与 SpacetimeDB data dir,不能覆盖 --database 或 --spacetime-data-dir', + ); + } + if (optionsWithValues.has(argument)) { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`dev:all 选项 ${argument} 缺少值`); + } + index += 1; + } + } +} + +function buildRootDevArgs(argv = []) { + validateDevAllArgs(argv); + return [ + 'run', + 'dev', + '--', + '--database', + agcDatabase, + '--spacetime-data-dir', + agcDataDirArgument, + '--preserve-database', + '--no-interactive', + ...argv, + ]; +} + +function buildDevAllEnvironment(baseEnv = process.env, agcVitePort) { + if (!Number.isInteger(agcVitePort) || agcVitePort < 1024) { + throw new Error(`AGC Vite 端口无效: ${agcVitePort}`); + } + + return { + ...baseEnv, + GENARRATIVE_AGC_VITE_PORT: String(agcVitePort), + AGC_DEV_ADMIN_WEB: '0', + }; +} + +function readDevStackState(path = devStackStatePath) { + if (!existsSync(path)) { + return null; + } + + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +async function isHttpReady(url, timeoutMs = 1_000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + return response.status >= 200 && response.status < 500; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +async function areRootFrontendsReady(state) { + const urls = [ + state?.services?.web?.url, + state?.services?.['admin-web']?.url, + ].filter(Boolean); + if (urls.length !== 2) { + return false; + } + const results = await Promise.all(urls.map((url) => isHttpReady(url))); + return results.every(Boolean); +} + +function isActiveService(service, expectedRepoRoot, expectedInstanceId) { + return Boolean( + service && + service.status === 'running' && + service.url && + service.repoRoot && + resolve(service.repoRoot) === resolve(expectedRepoRoot) && + service.instanceId === expectedInstanceId, + ); +} + +function isSharedRootStackReady( + state, + { + expectedRepoRoot = repoRoot, + expectedDatabase = agcDatabase, + expectedDataDir = agcDataDir, + } = {}, +) { + if (!state || state.command !== 'all') { + return false; + } + + const instanceId = String(state.instanceId ?? '').trim(); + if (!instanceId || state.database !== expectedDatabase) { + return false; + } + + if ( + !state.repoRoot || + resolve(state.repoRoot) !== resolve(expectedRepoRoot) || + !state.spacetimeDataDir || + resolve(state.spacetimeDataDir) !== resolve(expectedDataDir) + ) { + return false; + } + + const services = state.services ?? {}; + const spacetime = services.spacetime; + const requiredServices = [ + services['api-server'], + services['bgfilter-worker'], + services.web, + services['admin-web'], + ]; + + if ( + !spacetime || + !['running', 'reused'].includes(spacetime.status) || + !spacetime.url || + !spacetime.repoRoot || + resolve(spacetime.repoRoot) !== resolve(expectedRepoRoot) || + spacetime.instanceId !== instanceId + ) { + return false; + } + + return requiredServices.every((service) => + isActiveService(service, expectedRepoRoot, instanceId), + ); +} + +function waitForChildExit(child) { + if (!child) { + return Promise.resolve({ code: null, signal: null, error: null }); + } + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve({ + code: child.exitCode, + signal: child.signalCode, + error: null, + }); + } + + return new Promise((resolveExit) => { + const onError = (error) => { + cleanup(); + resolveExit({ code: null, signal: null, error }); + }; + const onExit = (code, signal) => { + cleanup(); + resolveExit({ code, signal, error: null }); + }; + const cleanup = () => { + child.off('error', onError); + child.off('exit', onExit); + }; + child.once('error', onError); + child.once('exit', onExit); + }); +} + +function spawnManaged(command, args, env) { + return spawn(command, args, { + cwd: repoRoot, + env, + stdio: 'inherit', + shell: false, + detached: process.platform !== 'win32', + windowsHide: false, + }); +} + +function wait(ms) { + return new Promise((resolveWait) => setTimeout(resolveWait, ms)); +} + +async function waitForRootStack(rootChild, options = {}) { + const timeoutMs = options.timeoutMs ?? rootReadyTimeoutMs; + const pollMs = options.pollMs ?? rootReadyPollMs; + const readState = options.readState ?? (() => readDevStackState()); + const expected = options.expected ?? {}; + const childExit = waitForChildExit(rootChild); + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const state = readState(); + if ( + isSharedRootStackReady(state, expected) && + (options.checkFrontends === false || (await areRootFrontendsReady(state))) + ) { + return state; + } + + const result = await Promise.race([ + childExit, + wait(pollMs).then(() => null), + ]); + if (result) { + const detail = result.error + ? result.error.message + : result.signal + ? `signal=${result.signal}` + : `code=${result.code ?? 1}`; + throw new Error(`根开发栈未就绪即退出: ${detail}`); + } + } + + throw new Error('等待根开发栈五个服务就绪超时'); +} + +async function runTaskkill(pid) { + return new Promise((resolveKill) => { + const killer = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + shell: false, + }); + const timer = setTimeout(() => { + killer.kill('SIGKILL'); + resolveKill(); + }, childGracefulTimeoutMs + childForceTimeoutMs); + killer.once('error', () => { + clearTimeout(timer); + resolveKill(); + }); + killer.once('exit', () => { + clearTimeout(timer); + resolveKill(); + }); + }); +} + +async function terminateChildTree(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + + if (process.platform === 'win32') { + if (Number.isInteger(child.pid)) { + await runTaskkill(child.pid); + } else { + child.kill('SIGTERM'); + } + return; + } + + const processGroupId = Number.isInteger(child.pid) ? -child.pid : child.pid; + if (!Number.isInteger(processGroupId)) { + return; + } + + try { + process.kill(processGroupId, 'SIGTERM'); + } catch { + return; + } + + const stoppedGracefully = await waitUntilChildExit( + child, + childGracefulTimeoutMs, + ); + if (stoppedGracefully) { + return; + } + + try { + process.kill(processGroupId, 'SIGKILL'); + } catch { + return; + } + await waitUntilChildExit(child, childForceTimeoutMs); +} + +async function waitUntilChildExit(child, timeoutMs) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return true; + } + + const result = await Promise.race([ + waitForChildExit(child), + wait(timeoutMs).then(() => null), + ]); + return result !== null; +} + +async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { + validateDevAllArgs(argv); + const resolveEndpoint = dependencies.resolveEndpoint ?? resolveAgcDevEndpoint; + const spawnProcess = dependencies.spawnProcess ?? spawnManaged; + const stopProcessTree = dependencies.stopProcessTree ?? terminateChildTree; + const readState = dependencies.readState ?? (() => readDevStackState()); + const waitForRoot = dependencies.waitForRoot ?? waitForRootStack; + + let rootChild = null; + let agcChild = null; + let shuttingDown = false; + let signalExitCode = 0; + let stopPromise = null; + const signalHandlers = new Map(); + + const stopAll = () => { + if (stopPromise) { + return stopPromise; + } + shuttingDown = true; + stopPromise = (async () => { + await stopProcessTree(agcChild); + await stopProcessTree(rootChild); + })(); + return stopPromise; + }; + + const handleSignal = (signal) => { + signalExitCode = signal === 'SIGTERM' ? 143 : 130; + void stopAll(); + }; + + for (const signal of ['SIGINT', 'SIGTERM']) { + const handler = () => handleSignal(signal); + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + + try { + const endpoint = await resolveEndpoint({ + env: process.env, + strictConfigured: true, + }); + const env = buildDevAllEnvironment(process.env, endpoint.port); + const rootArgs = buildRootDevArgs(argv); + + console.log( + `[dev:all] 保留 AGC Vite 端口 ${endpoint.url},启动共享根开发栈`, + ); + rootChild = spawnProcess(npmCommand, rootArgs, env); + const rootExit = waitForChildExit(rootChild); + await waitForRoot(rootChild, { + readState, + expected: { + expectedRepoRoot: repoRoot, + expectedDatabase: agcDatabase, + expectedDataDir: agcDataDir, + }, + }); + if (rootChild.exitCode !== null || rootChild.signalCode !== null) { + throw new Error('根开发栈已退出,不能启动 AGC'); + } + + if (shuttingDown) { + await stopAll(); + return signalExitCode || 1; + } + + console.log('[dev:all] 根开发栈已就绪,启动 AGC Vite 与 Tauri'); + agcChild = spawnProcess(npmCommand, ['run', 'agc'], env); + const agcExit = waitForChildExit(agcChild); + const result = await Promise.race([ + rootExit.then((exit) => ({ owner: 'root', exit })), + agcExit.then((exit) => ({ owner: 'agc', exit })), + ]); + + if (shuttingDown) { + await stopAll(); + return signalExitCode || 1; + } + + const detail = result.exit.error + ? result.exit.error.message + : result.exit.signal + ? `signal=${result.exit.signal}` + : `code=${result.exit.code ?? 0}`; + const exitCode = + result.exit.error || result.exit.signal ? 1 : (result.exit.code ?? 0); + console.error(`[dev:all] ${result.owner} 进程退出: ${detail}`); + await stopAll(); + return exitCode; + } catch (error) { + console.error( + `[dev:all] ${error instanceof Error ? error.message : error}`, + ); + await stopAll(); + return signalExitCode || 1; + } finally { + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + } +} + +export { + agcDatabase, + agcDataDir, + buildDevAllEnvironment, + buildRootDevArgs, + isSharedRootStackReady, + readDevStackState, + runDevAll, + validateDevAllArgs, +}; + +if ( + process.argv[1] && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) +) { + process.exitCode = await runDevAll(); +} diff --git a/scripts/dev-all.test.ts b/scripts/dev-all.test.ts new file mode 100644 index 000000000..7b5b6ddcb --- /dev/null +++ b/scripts/dev-all.test.ts @@ -0,0 +1,157 @@ +import { EventEmitter } from 'node:events'; +import { resolve } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { + agcDatabase, + agcDataDir, + buildDevAllEnvironment, + buildRootDevArgs, + isSharedRootStackReady, + runDevAll, + validateDevAllArgs, +} from './dev-all.mjs'; +type FakeChild = EventEmitter & { + pid: number; + exitCode: number | null; + signalCode: string | null; +}; + +function makeReadyState(root = resolve('fixture-root')) { + const instanceId = 'dev-fixture'; + const service = (status = 'running', url = 'http://127.0.0.1:1') => ({ + repoRoot: root, + instanceId, + status, + url, + }); + + return { + command: 'all', + instanceId, + repoRoot: root, + database: agcDatabase, + spacetimeDataDir: agcDataDir, + services: { + spacetime: service('reused', 'http://127.0.0.1:3101'), + 'api-server': service('running', 'http://127.0.0.1:8082'), + 'bgfilter-worker': service('running', 'http://127.0.0.1:8083'), + web: service('running', 'http://127.0.0.1:3000'), + 'admin-web': service('running', 'http://127.0.0.1:3102/admin/'), + }, + }; +} + +describe('dev:all 参数和共享开发栈合同', () => { + test('根 dev 固定使用 AGC 配套数据库,并保留调用方选项', () => { + expect(buildRootDevArgs(['--watch', '--api-port', '9082'])).toEqual([ + 'run', + 'dev', + '--', + '--database', + agcDatabase, + '--spacetime-data-dir', + 'server-rs/.spacetimedb/ai-game-creator/data', + '--preserve-database', + '--no-interactive', + '--watch', + '--api-port', + '9082', + ]); + }); + + test('拒绝会破坏单后端拓扑的数据库和 data dir 覆盖', () => { + expect(() => validateDevAllArgs(['--database', 'other'])).toThrow( + '不能覆盖 --database 或 --spacetime-data-dir', + ); + expect(() => validateDevAllArgs(['--database=other'])).toThrow( + '不能覆盖 --database 或 --spacetime-data-dir', + ); + expect(() => validateDevAllArgs(['--spacetime-data-dir', 'other'])).toThrow( + '不能覆盖 --database 或 --spacetime-data-dir', + ); + expect(() => validateDevAllArgs(['backend'])).toThrow('只接受 dev 选项'); + }); + + test('预留 AGC Vite 端口并关闭 AGC 自带后台', () => { + const baseEnv = { AGC_DEV_ADMIN_WEB: '1' }; + const env = buildDevAllEnvironment(baseEnv, 3080); + + expect(env.GENARRATIVE_AGC_VITE_PORT).toBe('3080'); + expect(env.AGC_DEV_ADMIN_WEB).toBe('0'); + expect(baseEnv.AGC_DEV_ADMIN_WEB).toBe('1'); + }); + + test('仅接受同一工作树、实例、数据库和 data dir 的完整栈', () => { + const root = resolve('fixture-root'); + const ready = makeReadyState(root); + + expect( + isSharedRootStackReady(ready, { + expectedRepoRoot: root, + expectedDatabase: agcDatabase, + expectedDataDir: agcDataDir, + }), + ).toBe(true); + + const wrongInstance = structuredClone(ready); + wrongInstance.services.web.instanceId = 'other-instance'; + expect( + isSharedRootStackReady(wrongInstance, { + expectedRepoRoot: root, + expectedDatabase: agcDatabase, + expectedDataDir: agcDataDir, + }), + ).toBe(false); + + const incomplete = structuredClone(ready); + incomplete.services['admin-web'].status = 'idle'; + expect( + isSharedRootStackReady(incomplete, { + expectedRepoRoot: root, + expectedDatabase: agcDatabase, + expectedDataDir: agcDataDir, + }), + ).toBe(false); + }); + + test('任一子进程退出时收束另一侧进程树', async () => { + const children: FakeChild[] = []; + const stopCalls: FakeChild[] = []; + let nextPid = 100; + const run = runDevAll([], { + resolveEndpoint: async () => ({ + port: 3080, + url: 'http://127.0.0.1:3080/', + }), + spawnProcess: () => { + const child = new EventEmitter() as FakeChild; + child.pid = nextPid; + child.exitCode = null; + child.signalCode = null; + nextPid += 1; + children.push(child); + if (children.length === 2) { + queueMicrotask(() => { + child.exitCode = 1; + child.emit('exit', 1, null); + }); + } + return child; + }, + waitForRoot: async () => ({}), + stopProcessTree: async (child) => { + stopCalls.push(child); + if (child?.exitCode == null) { + child.exitCode = 0; + child.emit('exit', 0, null); + } + }, + }); + + await expect(run).resolves.toBe(1); + expect(children).toHaveLength(2); + expect(stopCalls).toEqual([children[1], children[0]]); + }); +});