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) {