From c383355301ba1e4f3c98a05dd31daa4ab50ee1ff Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 16:47:16 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Web=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=B8=8E=20AGC=20=E4=B8=80=E9=94=AE=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 dev:all 跨平台编排共享后端、主站、后台与 AGC。 保持 npm run dev、npm run agc 及单模块入口行为不变。 补充参数、状态身份、进程退出收束测试。 同步本地开发运维文档、项目概览和决策记录。 --- .../shared-memory/decision-log.md | 6 + .../shared-memory/project-overview.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 9 + package.json | 1 + scripts/dev-all.mjs | 478 ++++++++++++++++++ scripts/dev-all.test.ts | 157 ++++++ 6 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 scripts/dev-all.mjs create mode 100644 scripts/dev-all.test.ts 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]]); + }); +}); From a8097a43642538c0b75ac56a1c228fa8237242e9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 17:34:29 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Windows=20dev:all=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=20npm=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用 Windows shell 启动 npm.cmd,避免 spawn EINVAL。 补充 Windows 启动参数回归测试。 记录端口漂移与 AGC 依赖缺失的实际 smoke 结果。 --- docs/project-memory/shared-memory/pitfalls.md | 7 +++++++ scripts/dev-all.mjs | 16 ++++++++++------ scripts/dev-all.test.ts | 9 +++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e47120989..35de8bcaa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,13 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-01 Windows `dev:all` 启动 npm 子进程报 `spawn EINVAL` + +- **现象**:`npm run dev:all` 能完成 AGC Vite 端口预留,但在启动根开发栈前输出 `spawn EINVAL`。 +- **原因**:Windows 的 `npm.cmd` 是批处理入口;Node `child_process.spawn('npm.cmd', args, { shell: false })` 会直接返回 `EINVAL`,还未执行根 `npm run dev`。 +- **处理**:`scripts/dev-all.mjs` 在 Windows 使用 `shell: true`、`windowsHide: true` 启动 npm 子进程;POSIX 仍使用独立进程组,退出时按进程组收束。 +- **验证**:Windows 实测根开发栈已启动并完成端口漂移(Web `3001`、API `8084`、worker `8085`、SpacetimeDB `3104`、后台 `3105`),之后 AGC 因当前工作区缺少 `@anthropic-ai/claude-agent-sdk` 退出;dev:all 已收束根栈进程。 + ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 - **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。 diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs index f69d88b53..368017bfa 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -231,15 +231,19 @@ function waitForChildExit(child) { }); } -function spawnManaged(command, args, env) { - return spawn(command, args, { +function buildManagedSpawnOptions(env) { + return { cwd: repoRoot, env, stdio: 'inherit', - shell: false, + shell: process.platform === 'win32', detached: process.platform !== 'win32', - windowsHide: false, - }); + windowsHide: process.platform === 'win32', + }; +} + +function spawnManaged(command, args, env) { + return spawn(command, args, buildManagedSpawnOptions(env)); } function wait(ms) { @@ -463,13 +467,13 @@ export { agcDatabase, agcDataDir, buildDevAllEnvironment, + buildManagedSpawnOptions, buildRootDevArgs, isSharedRootStackReady, readDevStackState, runDevAll, validateDevAllArgs, }; - if ( process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) diff --git a/scripts/dev-all.test.ts b/scripts/dev-all.test.ts index 7b5b6ddcb..75684a6ca 100644 --- a/scripts/dev-all.test.ts +++ b/scripts/dev-all.test.ts @@ -7,6 +7,7 @@ import { agcDatabase, agcDataDir, buildDevAllEnvironment, + buildManagedSpawnOptions, buildRootDevArgs, isSharedRootStackReady, runDevAll, @@ -83,6 +84,14 @@ describe('dev:all 参数和共享开发栈合同', () => { expect(baseEnv.AGC_DEV_ADMIN_WEB).toBe('1'); }); + test('Windows 下通过 shell 启动 npm.cmd', () => { + const options = buildManagedSpawnOptions(process.env); + + expect(options.shell).toBe(process.platform === 'win32'); + expect(options.windowsHide).toBe(process.platform === 'win32'); + expect(options.detached).toBe(process.platform !== 'win32'); + }); + test('仅接受同一工作树、实例、数据库和 data dir 的完整栈', () => { const root = resolve('fixture-root'); const ready = makeReadyState(root); From e9b3e2b5318cad555b4b6ffa13d1d284fc80af64 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 18:22:24 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E8=A1=A5=E5=85=85=20dev:all=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=91=98=E8=A6=81=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 AGC Vite marker 就绪后打印全部服务的实际地址表。 补充 AGC 就绪等待、摘要构造测试和开发运维文档。 --- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/dev-all.mjs | 96 ++++++++++++++++++- scripts/dev-all.test.ts | 30 +++++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 3489fc64b..bc30babb5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -78,7 +78,7 @@ 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` 为准。 +一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。AGC Vite marker 可访问后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.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/scripts/dev-all.mjs b/scripts/dev-all.mjs index 368017bfa..06d29a9df 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -19,6 +19,7 @@ const rootReadyTimeoutMs = 660_000; const rootReadyPollMs = 500; const childGracefulTimeoutMs = 2_500; const childForceTimeoutMs = 2_000; +const agcReadyPollMs = 500; const rootOnlyOptions = new Set([ '--database', @@ -139,6 +140,57 @@ async function areRootFrontendsReady(state) { return results.every(Boolean); } +async function readAgcFrontendMarker(endpoint, timeoutMs = 1_000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(endpoint.markerUrl, { + signal: controller.signal, + }); + if (!response.ok) { + return null; + } + const marker = await response.json(); + return marker?.schemaVersion === 1 && + marker?.app === 'ai-game-creator-shell' + ? marker + : null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +async function waitForAgcFrontend(child, endpoint, options = {}) { + const timeoutMs = options.timeoutMs ?? rootReadyTimeoutMs; + const pollMs = options.pollMs ?? agcReadyPollMs; + const readMarker = options.readMarker ?? readAgcFrontendMarker; + const childExit = waitForChildExit(child); + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (await readMarker(endpoint)) { + return true; + } + + 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(`AGC Vite 未就绪即退出: ${detail}`); + } + } + + throw new Error(`等待 AGC Vite 就绪超时:${endpoint.url}`); +} + function isActiveService(service, expectedRepoRoot, expectedInstanceId) { return Boolean( service && @@ -201,6 +253,36 @@ function isSharedRootStackReady( ); } +function formatServiceStatus(service) { + return service?.status === 'reused' ? '已复用' : '已启动'; +} + +function buildStartupSummary(state, agcEndpoint) { + const services = state?.services ?? {}; + const rows = [ + ['SpacetimeDB', services.spacetime], + ['BgFilter worker', services['bgfilter-worker']], + ['API server', services['api-server']], + ['Web 主站', services.web], + ['Admin 后台', services['admin-web']], + ].map(([name, service]) => ({ + 项目: name, + 状态: formatServiceStatus(service), + 地址: service?.url ?? '—', + })); + + return [ + ...rows, + { 项目: 'AGC Vite', 状态: '已启动', 地址: agcEndpoint.url }, + { 项目: 'Tauri AGC', 状态: '已启动', 地址: '本地窗口' }, + ]; +} + +function printStartupSummary(state, agcEndpoint, table = console.table) { + console.log('\n[dev:all] 全部开发服务已启动'); + table(buildStartupSummary(state, agcEndpoint)); +} + function waitForChildExit(child) { if (!child) { return Promise.resolve({ code: null, signal: null, error: null }); @@ -366,6 +448,7 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { const stopProcessTree = dependencies.stopProcessTree ?? terminateChildTree; const readState = dependencies.readState ?? (() => readDevStackState()); const waitForRoot = dependencies.waitForRoot ?? waitForRootStack; + const waitForAgc = dependencies.waitForAgc ?? waitForAgcFrontend; let rootChild = null; let agcChild = null; @@ -410,7 +493,7 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { ); rootChild = spawnProcess(npmCommand, rootArgs, env); const rootExit = waitForChildExit(rootChild); - await waitForRoot(rootChild, { + const rootState = await waitForRoot(rootChild, { readState, expected: { expectedRepoRoot: repoRoot, @@ -430,6 +513,16 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { console.log('[dev:all] 根开发栈已就绪,启动 AGC Vite 与 Tauri'); agcChild = spawnProcess(npmCommand, ['run', 'agc'], env); const agcExit = waitForChildExit(agcChild); + await waitForAgc(agcChild, endpoint); + if (rootChild.exitCode !== null || rootChild.signalCode !== null) { + throw new Error('根开发栈在 AGC 就绪前退出'); + } + if (shuttingDown) { + await stopAll(); + return signalExitCode || 1; + } + + printStartupSummary(readState() ?? rootState, endpoint); const result = await Promise.race([ rootExit.then((exit) => ({ owner: 'root', exit })), agcExit.then((exit) => ({ owner: 'agc', exit })), @@ -469,6 +562,7 @@ export { buildDevAllEnvironment, buildManagedSpawnOptions, buildRootDevArgs, + buildStartupSummary, isSharedRootStackReady, readDevStackState, runDevAll, diff --git a/scripts/dev-all.test.ts b/scripts/dev-all.test.ts index 75684a6ca..894dfbe7e 100644 --- a/scripts/dev-all.test.ts +++ b/scripts/dev-all.test.ts @@ -9,6 +9,7 @@ import { buildDevAllEnvironment, buildManagedSpawnOptions, buildRootDevArgs, + buildStartupSummary, isSharedRootStackReady, runDevAll, validateDevAllArgs, @@ -92,6 +93,31 @@ describe('dev:all 参数和共享开发栈合同', () => { expect(options.detached).toBe(process.platform !== 'win32'); }); + test('按最终端口构造完整启动摘要表', () => { + const rows = buildStartupSummary(makeReadyState(), { + url: 'http://127.0.0.1:3080/', + }); + + expect(rows).toHaveLength(7); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + 项目: 'SpacetimeDB', + 状态: '已复用', + 地址: 'http://127.0.0.1:3101', + }), + expect.objectContaining({ + 项目: 'Admin 后台', + 地址: 'http://127.0.0.1:3102/admin/', + }), + expect.objectContaining({ + 项目: 'AGC Vite', + 地址: 'http://127.0.0.1:3080/', + }), + ]), + ); + }); + test('仅接受同一工作树、实例、数据库和 data dir 的完整栈', () => { const root = resolve('fixture-root'); const ready = makeReadyState(root); @@ -149,7 +175,9 @@ describe('dev:all 参数和共享开发栈合同', () => { } return child; }, - waitForRoot: async () => ({}), + waitForRoot: async () => makeReadyState(), + readState: () => null, + waitForAgc: async () => true, stopProcessTree: async (child) => { stopCalls.push(child); if (child?.exitCode == null) { From c911a338f956933cb0301dc06ea70ccdafaf86b2 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 19:03:52 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20dev:all=20=E6=91=98?= =?UTF-8?q?=E8=A6=81=E8=A1=A8=E8=BE=93=E5=87=BA=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用无引号纯文本表格显示启动服务和实际地址。 等待 Tauri Running DevCommand 后再打印摘要。 补充启动器输出转发、回归测试和开发运维说明。 --- .../scripts/start-dev-stack.mjs | 2 +- .../scripts/start-tauri-dev.mjs | 19 +++- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/dev-all.mjs | 86 +++++++++++++++++-- scripts/dev-all.test.ts | 15 ++++ 5 files changed, 113 insertions(+), 11 deletions(-) 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 20cf4a1ea..d8cb7d8c5 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -568,7 +568,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) { // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 detached: isPosix, - stdio: 'inherit', + stdio: options.stdio ?? 'inherit', }); const lifecycle = { failure: null, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 7cc06d82a..048ac9ec6 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -33,6 +33,7 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG'; const designDebugEnabled = process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1'; +const tauriDevReadyMarker = '[ai-game-creator-shell] Tauri DevCommand 已启动'; function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { const args = [...argv]; @@ -103,11 +104,27 @@ function prepareBundledResourcesBeforeTauri( } function spawnTauriCli(argv, { env = process.env } = {}) { - return spawnChild(process.execPath, [tauriCliPath, ...argv], { + const child = spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, env, shell: false, + stdio: ['inherit', 'pipe', 'pipe'], }); + let output = ''; + let announced = false; + const forwardOutput = (stream, target) => { + stream?.on('data', (chunk) => { + target.write(chunk); + output = `${output}${chunk.toString()}`.slice(-4096); + if (!announced && output.includes('Running DevCommand')) { + announced = true; + console.log(tauriDevReadyMarker); + } + }); + }; + forwardOutput(child.stdout, process.stdout); + forwardOutput(child.stderr, process.stderr); + return child; } /// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index bc30babb5..21e23ba35 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -78,7 +78,7 @@ 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 客户端的进程树。AGC Vite marker 可访问后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.app/dev-stack.json` 为准。 +一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。AGC Vite marker 和 Tauri `Running DevCommand` 就绪标记都出现后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.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/scripts/dev-all.mjs b/scripts/dev-all.mjs index 06d29a9df..3dd75afe3 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -20,6 +20,7 @@ const rootReadyPollMs = 500; const childGracefulTimeoutMs = 2_500; const childForceTimeoutMs = 2_000; const agcReadyPollMs = 500; +const tauriDevReadyMarker = '[ai-game-creator-shell] Tauri DevCommand 已启动'; const rootOnlyOptions = new Set([ '--database', @@ -278,9 +279,39 @@ function buildStartupSummary(state, agcEndpoint) { ]; } -function printStartupSummary(state, agcEndpoint, table = console.table) { - console.log('\n[dev:all] 全部开发服务已启动'); - table(buildStartupSummary(state, agcEndpoint)); +function getDisplayWidth(value) { + return [...String(value)].reduce( + (width, character) => width + (character.charCodeAt(0) > 0xff ? 2 : 1), + 0, + ); +} + +function renderStartupTable(rows) { + const columns = ['项目', '状态', '地址']; + const values = rows.map((row) => columns.map((column) => row[column] ?? '—')); + const widths = columns.map((column, index) => + Math.max( + getDisplayWidth(column), + ...values.map((row) => getDisplayWidth(row[index])), + ), + ); + const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`; + const renderRow = (row) => + `| ${row.map((value, index) => `${value}${' '.repeat(widths[index] - getDisplayWidth(value))}`).join(' | ')} |`; + + return [ + border, + renderRow(columns), + border, + ...values.map(renderRow), + border, + ].join('\n'); +} + +function printStartupSummary(state, agcEndpoint) { + console.log( + `\n[dev:all] 全部开发服务已启动\n${renderStartupTable(buildStartupSummary(state, agcEndpoint))}`, + ); } function waitForChildExit(child) { @@ -313,19 +344,53 @@ function waitForChildExit(child) { }); } -function buildManagedSpawnOptions(env) { +function buildManagedSpawnOptions(env, { captureOutput = false } = {}) { return { cwd: repoRoot, env, - stdio: 'inherit', + stdio: captureOutput ? ['inherit', 'pipe', 'pipe'] : 'inherit', shell: process.platform === 'win32', detached: process.platform !== 'win32', windowsHide: process.platform === 'win32', }; } -function spawnManaged(command, args, env) { - return spawn(command, args, buildManagedSpawnOptions(env)); +function createTauriReadyPromise(child) { + return new Promise((resolveReady) => { + let ready = false; + let output = ''; + const resolveOnce = () => { + if (!ready) { + ready = true; + resolveReady(); + } + }; + const forward = (stream, target) => { + stream?.on('data', (chunk) => { + target.write(chunk); + output = `${output}${chunk.toString()}`.slice(-4096); + if (output.includes(tauriDevReadyMarker)) { + resolveOnce(); + } + }); + }; + forward(child.stdout, process.stdout); + forward(child.stderr, process.stderr); + child.once('error', resolveOnce); + child.once('exit', resolveOnce); + }); +} + +function spawnManaged(command, args, env, options = {}) { + const child = spawn(command, args, buildManagedSpawnOptions(env, options)); + child.tauriReadyPromise = options.captureOutput + ? createTauriReadyPromise(child) + : Promise.resolve(); + return child; +} + +function waitForTauriProcess(child) { + return child?.tauriReadyPromise ?? Promise.resolve(); } function wait(ms) { @@ -449,6 +514,7 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { const readState = dependencies.readState ?? (() => readDevStackState()); const waitForRoot = dependencies.waitForRoot ?? waitForRootStack; const waitForAgc = dependencies.waitForAgc ?? waitForAgcFrontend; + const waitForTauri = dependencies.waitForTauri ?? waitForTauriProcess; let rootChild = null; let agcChild = null; @@ -511,9 +577,12 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { } console.log('[dev:all] 根开发栈已就绪,启动 AGC Vite 与 Tauri'); - agcChild = spawnProcess(npmCommand, ['run', 'agc'], env); + agcChild = spawnProcess(npmCommand, ['run', 'agc'], env, { + captureOutput: true, + }); const agcExit = waitForChildExit(agcChild); await waitForAgc(agcChild, endpoint); + await waitForTauri(agcChild); if (rootChild.exitCode !== null || rootChild.signalCode !== null) { throw new Error('根开发栈在 AGC 就绪前退出'); } @@ -565,6 +634,7 @@ export { buildStartupSummary, isSharedRootStackReady, readDevStackState, + renderStartupTable, runDevAll, validateDevAllArgs, }; diff --git a/scripts/dev-all.test.ts b/scripts/dev-all.test.ts index 894dfbe7e..bf86ff53d 100644 --- a/scripts/dev-all.test.ts +++ b/scripts/dev-all.test.ts @@ -11,6 +11,7 @@ import { buildRootDevArgs, buildStartupSummary, isSharedRootStackReady, + renderStartupTable, runDevAll, validateDevAllArgs, } from './dev-all.mjs'; @@ -91,6 +92,9 @@ describe('dev:all 参数和共享开发栈合同', () => { expect(options.shell).toBe(process.platform === 'win32'); expect(options.windowsHide).toBe(process.platform === 'win32'); expect(options.detached).toBe(process.platform !== 'win32'); + expect( + buildManagedSpawnOptions(process.env, { captureOutput: true }).stdio, + ).toEqual(['inherit', 'pipe', 'pipe']); }); test('按最终端口构造完整启动摘要表', () => { @@ -118,6 +122,17 @@ describe('dev:all 参数和共享开发栈合同', () => { ); }); + test('渲染摘要表时不输出字符串引号', () => { + const rows = buildStartupSummary(makeReadyState(), { + url: 'http://127.0.0.1:3080/', + }); + const table = renderStartupTable(rows); + + expect(table).not.toContain("'"); + expect(table).toContain('| Web 主站'); + expect(table).toContain('http://127.0.0.1:3080/'); + }); + test('仅接受同一工作树、实例、数据库和 data dir 的完整栈', () => { const root = resolve('fixture-root'); const ready = makeReadyState(root); From 5dfd2314968c776a5d463ae2926204254cb6e0a7 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 19:28:43 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E5=9B=9E=E9=80=80=20dev:all=20Tauri=20?= =?UTF-8?q?=E6=97=B6=E5=BA=8F=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留无引号启动摘要表。 移除 Tauri 输出捕获、就绪标记和额外等待逻辑。 恢复启动器原有输出与启动时序说明。 --- .../scripts/start-dev-stack.mjs | 2 +- .../scripts/start-tauri-dev.mjs | 19 +------ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/dev-all.mjs | 49 ++----------------- scripts/dev-all.test.ts | 3 -- 5 files changed, 8 insertions(+), 67 deletions(-) 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 d8cb7d8c5..20cf4a1ea 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -568,7 +568,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) { // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 detached: isPosix, - stdio: options.stdio ?? 'inherit', + stdio: 'inherit', }); const lifecycle = { failure: null, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 048ac9ec6..7cc06d82a 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -33,7 +33,6 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG'; const designDebugEnabled = process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1'; -const tauriDevReadyMarker = '[ai-game-creator-shell] Tauri DevCommand 已启动'; function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { const args = [...argv]; @@ -104,27 +103,11 @@ function prepareBundledResourcesBeforeTauri( } function spawnTauriCli(argv, { env = process.env } = {}) { - const child = spawnChild(process.execPath, [tauriCliPath, ...argv], { + return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, env, shell: false, - stdio: ['inherit', 'pipe', 'pipe'], }); - let output = ''; - let announced = false; - const forwardOutput = (stream, target) => { - stream?.on('data', (chunk) => { - target.write(chunk); - output = `${output}${chunk.toString()}`.slice(-4096); - if (!announced && output.includes('Running DevCommand')) { - announced = true; - console.log(tauriDevReadyMarker); - } - }); - }; - forwardOutput(child.stdout, process.stdout); - forwardOutput(child.stderr, process.stderr); - return child; } /// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 21e23ba35..bc30babb5 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -78,7 +78,7 @@ 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 客户端的进程树。AGC Vite marker 和 Tauri `Running DevCommand` 就绪标记都出现后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.app/dev-stack.json` 为准。 +一键入口由自身负责收束根 dev 栈与 AGC 客户端的进程树。AGC Vite marker 可访问后,终端会打印包含 SpacetimeDB、BgFilter worker、api-server、主站、后台、AGC Vite 和 Tauri 窗口的实际地址表;任一子进程异常退出都应停止另一侧并返回非零退出码。端口漂移和运行态地址仍以启动日志及 `.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/scripts/dev-all.mjs b/scripts/dev-all.mjs index 3dd75afe3..34918d6d6 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -20,7 +20,6 @@ const rootReadyPollMs = 500; const childGracefulTimeoutMs = 2_500; const childForceTimeoutMs = 2_000; const agcReadyPollMs = 500; -const tauriDevReadyMarker = '[ai-game-creator-shell] Tauri DevCommand 已启动'; const rootOnlyOptions = new Set([ '--database', @@ -344,53 +343,19 @@ function waitForChildExit(child) { }); } -function buildManagedSpawnOptions(env, { captureOutput = false } = {}) { +function buildManagedSpawnOptions(env) { return { cwd: repoRoot, env, - stdio: captureOutput ? ['inherit', 'pipe', 'pipe'] : 'inherit', + stdio: 'inherit', shell: process.platform === 'win32', detached: process.platform !== 'win32', windowsHide: process.platform === 'win32', }; } -function createTauriReadyPromise(child) { - return new Promise((resolveReady) => { - let ready = false; - let output = ''; - const resolveOnce = () => { - if (!ready) { - ready = true; - resolveReady(); - } - }; - const forward = (stream, target) => { - stream?.on('data', (chunk) => { - target.write(chunk); - output = `${output}${chunk.toString()}`.slice(-4096); - if (output.includes(tauriDevReadyMarker)) { - resolveOnce(); - } - }); - }; - forward(child.stdout, process.stdout); - forward(child.stderr, process.stderr); - child.once('error', resolveOnce); - child.once('exit', resolveOnce); - }); -} - -function spawnManaged(command, args, env, options = {}) { - const child = spawn(command, args, buildManagedSpawnOptions(env, options)); - child.tauriReadyPromise = options.captureOutput - ? createTauriReadyPromise(child) - : Promise.resolve(); - return child; -} - -function waitForTauriProcess(child) { - return child?.tauriReadyPromise ?? Promise.resolve(); +function spawnManaged(command, args, env) { + return spawn(command, args, buildManagedSpawnOptions(env)); } function wait(ms) { @@ -514,7 +479,6 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { const readState = dependencies.readState ?? (() => readDevStackState()); const waitForRoot = dependencies.waitForRoot ?? waitForRootStack; const waitForAgc = dependencies.waitForAgc ?? waitForAgcFrontend; - const waitForTauri = dependencies.waitForTauri ?? waitForTauriProcess; let rootChild = null; let agcChild = null; @@ -577,12 +541,9 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { } console.log('[dev:all] 根开发栈已就绪,启动 AGC Vite 与 Tauri'); - agcChild = spawnProcess(npmCommand, ['run', 'agc'], env, { - captureOutput: true, - }); + agcChild = spawnProcess(npmCommand, ['run', 'agc'], env); const agcExit = waitForChildExit(agcChild); await waitForAgc(agcChild, endpoint); - await waitForTauri(agcChild); if (rootChild.exitCode !== null || rootChild.signalCode !== null) { throw new Error('根开发栈在 AGC 就绪前退出'); } diff --git a/scripts/dev-all.test.ts b/scripts/dev-all.test.ts index bf86ff53d..9a3352c03 100644 --- a/scripts/dev-all.test.ts +++ b/scripts/dev-all.test.ts @@ -92,9 +92,6 @@ describe('dev:all 参数和共享开发栈合同', () => { expect(options.shell).toBe(process.platform === 'win32'); expect(options.windowsHide).toBe(process.platform === 'win32'); expect(options.detached).toBe(process.platform !== 'win32'); - expect( - buildManagedSpawnOptions(process.env, { captureOutput: true }).stdio, - ).toEqual(['inherit', 'pipe', 'pipe']); }); test('按最终端口构造完整启动摘要表', () => { From aad8f15a3c1414f8a5258ef47322d127c68b98ae Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 2 Oct 2026 11:03:11 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20dev:all=20=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E6=AE=B5=E4=B8=8E=E5=89=8D=E7=AB=AF=E5=B0=B1=E7=BB=AA?= =?UTF-8?q?=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 CLI port-range 同步到 AGC 端口解析环境。 将主站和后台 HTTP readiness 限制为 2xx。 补充真实 HTTP 状态码与端口段传递测试。 --- scripts/dev-all.mjs | 23 ++++++++++++++++++--- scripts/dev-all.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs index 34918d6d6..448b33cc9 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -91,6 +91,20 @@ function buildRootDevArgs(argv = []) { ]; } +function buildDevAllBaseEnvironment(argv = [], baseEnv = process.env) { + let portRange; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--port-range') { + portRange = argv[index + 1]; + index += 1; + } + } + + return portRange + ? { ...baseEnv, GENARRATIVE_DEV_PORT_RANGE: portRange } + : { ...baseEnv }; +} + function buildDevAllEnvironment(baseEnv = process.env, agcVitePort) { if (!Number.isInteger(agcVitePort) || agcVitePort < 1024) { throw new Error(`AGC Vite 端口无效: ${agcVitePort}`); @@ -120,7 +134,7 @@ async function isHttpReady(url, timeoutMs = 1_000) { const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); - return response.status >= 200 && response.status < 500; + return response.status >= 200 && response.status < 300; } catch { return false; } finally { @@ -511,11 +525,12 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { } try { + const baseEnv = buildDevAllBaseEnvironment(argv); const endpoint = await resolveEndpoint({ - env: process.env, + env: baseEnv, strictConfigured: true, }); - const env = buildDevAllEnvironment(process.env, endpoint.port); + const env = buildDevAllEnvironment(baseEnv, endpoint.port); const rootArgs = buildRootDevArgs(argv); console.log( @@ -589,10 +604,12 @@ async function runDevAll(argv = process.argv.slice(2), dependencies = {}) { export { agcDatabase, agcDataDir, + buildDevAllBaseEnvironment, buildDevAllEnvironment, buildManagedSpawnOptions, buildRootDevArgs, buildStartupSummary, + isHttpReady, isSharedRootStackReady, readDevStackState, renderStartupTable, diff --git a/scripts/dev-all.test.ts b/scripts/dev-all.test.ts index 9a3352c03..be5f2c37e 100644 --- a/scripts/dev-all.test.ts +++ b/scripts/dev-all.test.ts @@ -1,4 +1,6 @@ import { EventEmitter } from 'node:events'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { resolve } from 'node:path'; import { describe, expect, test } from 'vitest'; @@ -6,10 +8,12 @@ import { describe, expect, test } from 'vitest'; import { agcDatabase, agcDataDir, + buildDevAllBaseEnvironment, buildDevAllEnvironment, buildManagedSpawnOptions, buildRootDevArgs, buildStartupSummary, + isHttpReady, isSharedRootStackReady, renderStartupTable, runDevAll, @@ -86,6 +90,48 @@ describe('dev:all 参数和共享开发栈合同', () => { expect(baseEnv.AGC_DEV_ADMIN_WEB).toBe('1'); }); + test('CLI port-range 覆盖旧环境并同步给 AGC', () => { + const env = buildDevAllBaseEnvironment(['--port-range', '10100-10199'], { + GENARRATIVE_DEV_PORT_RANGE: '10000-10099', + }); + + expect(env.GENARRATIVE_DEV_PORT_RANGE).toBe('10100-10199'); + }); + + test.each([ + [200, true], + [204, true], + [299, true], + [400, false], + [401, false], + [403, false], + [404, false], + [500, false], + [503, false], + ])('HTTP %i 的前端就绪结果为 %s', async (status, expected) => { + const server = createServer((_request, response) => { + response.writeHead(status).end(); + }); + await new Promise((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + + try { + const { port } = server.address() as AddressInfo; + await expect(isHttpReady(`http://127.0.0.1:${port}`)).resolves.toBe( + expected, + ); + } finally { + server.closeAllConnections(); + await new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) rejectClose(error); + else resolveClose(); + }); + }); + } + }); + test('Windows 下通过 shell 启动 npm.cmd', () => { const options = buildManagedSpawnOptions(process.env);