diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 95feedd00..99f4c3506 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -2,6 +2,7 @@ use super::*; use axum::extract::DefaultBodyLimit; use axum::extract::State as AxumState; use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; use axum::routing::post; use axum::Json; use axum::Router; @@ -2090,7 +2091,7 @@ async fn handle_external_mcp_http_request( AxumState(state): AxumState, headers: HeaderMap, Json(request): Json, -) -> Result, StatusCode> { +) -> Result { if !external_mcp_authorized(&headers, &state.token) { return Err(StatusCode::UNAUTHORIZED); } @@ -2107,6 +2108,13 @@ async fn handle_external_mcp_http_request( .in_flight .try_acquire() .map_err(|_| StatusCode::TOO_MANY_REQUESTS)?; + // JSON-RPC 通知(没有 `id`)没有响应体:MCP 客户端在 `initialize` 之后一定会发 + // `notifications/initialized`,按错误回 400 会被判成握手失败,整个服务器就被标记 + // 成 `failed`、一个工具都不广告(真实案例:2026-10-02 的 cc DirectProject 因此 + // 拿到 0 个工具)。规范要求的应答是 202 Accepted(空体)。 + if request.get("id").is_none() { + return Ok(StatusCode::ACCEPTED.into_response()); + } let response = EXTERNAL_MCP_BRIDGE_URL .scope( state.bridge_url.clone(), @@ -2114,7 +2122,7 @@ async fn handle_external_mcp_http_request( ) .await .ok_or(StatusCode::BAD_REQUEST)?; - Ok(Json(response)) + Ok(Json(response).into_response()) } pub(crate) async fn start_external_mcp_loopback( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4a7223ed8..1b0a1a4b6 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-10-01 AGC 命令错误结构化与错误报告口径 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d72150812..6bd76c225 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,18 +12,22 @@ - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 +## 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-10-02 AGC 页面在自绘标题栏外壳里自己算 `100vh`:底部被裁而且没得滚 - **现象**:帮助页(使用指南 / 联系客服 / 更新日志)在矮窗口里底部卡片看不到,把窗口拉高才出现;外壳 `.launcher-main { overflow: hidden }` 之下没有任何可滚动祖先,页面既滚不动也裁得干净。首页在通知横幅出现时用 `h-[calc(100vh-32px)]`,同样把窗口高度当成了舞台高度。 - **原因**:AGC 桌面外壳是自绘标题栏(`--window-chrome-height`;窗口 100vh=800 时舞台只有 750),页面根节点写 `100vh` / `100dvh` / `calc(100vh - Npx)` 就比真实舞台高一整个标题栏,差额被外壳裁掉;横幅是 `.launcher-main` 里的真实行,再写 `-32px` 等于重复扣一次。帮助页还没有内层滚动容器,连「内容超高就在内部滚动」这条兜底也不存在。 - **处理(现行口径)**:页面高度只由外壳分配——`apps/ai-game-creator-shell/src/styles.css` 里 `.launcher-main:has(<页面钩子>)` 是纵向 flex 列(`height: 100dvh`,窗口外壳命中 `height: 100%` 时贴合真实舞台),`.launcher-main > <页面根节点>` 统一 `flex: 1 1 auto; height: auto; min-height: 0`,帮助页这类没有内层滚动容器的再加 `overflow-y: auto`。页面根节点一律不再写 `100vh` / `100dvh` / `calc(100vh - Npx)`;有横幅就靠 flex 自动少一份,不要手算偏移。 - **验证**:真机判据是 Vite + Chromium 量页面根节点是否正好等于 `.window-chrome__content` 的高度(1440x800 / 1440x560 / 390x844 / 390x560,带与不带横幅),帮助页应可滚动到底。 - ## 2026-10-02 固定试玩误判祖先的指针穿透样式 - `pointer-events:none` 不会强制禁用整棵子树;后代显式 `auto` 可以恢复命中。控件探针只检查目标的计算样式,继承未覆盖的 `none` 仍拒绝;可见性、遮挡、disabled 与 inert 保留各自检查。 - 修复和回归必须经过生产输入入口及可信事件驱动的状态变化,不能用程序化点击证明真实可玩。双视口 generic 回归与真实触摸验收需要区分,详见 AGC 实施计划“固定试玩控件的指针命中边界”。 - ## 2026-10-01 Rust 分片编译失败只剩汇总错误 - **原因**:`--message-format=json` 把编译诊断写到 stdout;只读取 `compiler-artifact` 的运行器会丢弃 `compiler-message`,CI 只能看到「due to 1 previous error」。 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 d3272305e..07e2a2654 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -94,6 +94,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 客户端的进程树。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/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..448b33cc9 --- /dev/null +++ b/scripts/dev-all.mjs @@ -0,0 +1,624 @@ +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 agcReadyPollMs = 500; + +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 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}`); + } + + 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 < 300; + } 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); +} + +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 && + 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 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 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) { + 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 buildManagedSpawnOptions(env) { + return { + cwd: repoRoot, + env, + stdio: '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 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; + const waitForAgc = dependencies.waitForAgc ?? waitForAgcFrontend; + + 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 baseEnv = buildDevAllBaseEnvironment(argv); + const endpoint = await resolveEndpoint({ + env: baseEnv, + strictConfigured: true, + }); + const env = buildDevAllEnvironment(baseEnv, endpoint.port); + const rootArgs = buildRootDevArgs(argv); + + console.log( + `[dev:all] 保留 AGC Vite 端口 ${endpoint.url},启动共享根开发栈`, + ); + rootChild = spawnProcess(npmCommand, rootArgs, env); + const rootExit = waitForChildExit(rootChild); + const rootState = 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); + 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 })), + ]); + + 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, + buildDevAllBaseEnvironment, + buildDevAllEnvironment, + buildManagedSpawnOptions, + buildRootDevArgs, + buildStartupSummary, + isHttpReady, + isSharedRootStackReady, + readDevStackState, + renderStartupTable, + 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..be5f2c37e --- /dev/null +++ b/scripts/dev-all.test.ts @@ -0,0 +1,252 @@ +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'; + +import { + agcDatabase, + agcDataDir, + buildDevAllBaseEnvironment, + buildDevAllEnvironment, + buildManagedSpawnOptions, + buildRootDevArgs, + buildStartupSummary, + isHttpReady, + isSharedRootStackReady, + renderStartupTable, + 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('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); + + expect(options.shell).toBe(process.platform === 'win32'); + expect(options.windowsHide).toBe(process.platform === 'win32'); + 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('渲染摘要表时不输出字符串引号', () => { + 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); + + 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 () => makeReadyState(), + readState: () => null, + waitForAgc: async () => true, + 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]]); + }); +});