diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c4712a723..15f154c0d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -167,8 +167,12 @@ module.exports = { // ts-rs 生成绑定:不接受 eslint --fix 二次改写,必须与原始输出逐字节一致 'packages/shared/src/contracts/generated/**', 'apps/ai-game-creator-shell/src/view/project-development/chat/generated/**', + 'apps/ai-game-creator-shell/src/view/project-development/export/generated/**', 'apps/ai-game-creator-shell/src/services/generated/**', 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', + // 审核 Skill Pack:内容按 SHA-256 定址并经 include_bytes! 编译进客户端, + // 不接受 eslint --fix 改写(.prettierignore 同样忽略该目录,避免指纹漂移) + 'apps/ai-game-creator-shell/src-tauri/resources/agc-skills/**', 'target', 'src/main.tsx', 'src/App.tsx', diff --git a/.gitattributes b/.gitattributes index a63eef88a..16eded8f0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -30,4 +30,5 @@ packages/shared/src/contracts/generated/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/features/ui-editor/types/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/view/project-development/chat/generated/** linguist-generated=true whitespace=-trailing-space +apps/ai-game-creator-shell/src/view/project-development/export/generated/** linguist-generated=true whitespace=-trailing-space apps/ai-game-creator-shell/src/services/generated/** linguist-generated=true whitespace=-trailing-space diff --git a/.prettierignore b/.prettierignore index 446fa63d9..0561423f2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ packages/shared/src/contracts/generated/ public/Icons apps/ai-game-creator-shell/src/features/ui-editor/types/ apps/ai-game-creator-shell/src/view/project-development/chat/generated/ +apps/ai-game-creator-shell/src/view/project-development/export/generated/ apps/ai-game-creator-shell/src/services/generated/ apps/ai-game-creator-shell/src-tauri/resources/agc-skills/ # 预览桥脚本:随包注入浏览器的资源,按字节搬运自 Rust 内联字符串,禁止 prettier 二次改写 diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 40ac2f214..b06df864b 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -130,14 +130,10 @@ const allowedUncalledTauriCommands = [ // React 不再把页面消息当作持久化事实;策划/Runtime 由 Rust coordinator 写入, // 该通用命令仅保留给 Rust 内部链路与测试。 'append_local_conversation_message', - // 发行 facade 由 typed native service 通过注入的 invoke 调用;整包准备、旧分片上传 - // 与资料命令仍保留给 Rust/旧 native 测试,不允许重新接回 React 直连 HTTP。 - 'generate_game_distribution_cover', - 'prepare_local_project_game_package', - 'read_game_cover_generation_price', - 'suggest_game_distribution_publish_metadata', + // 发行 facade 由 typed native service 通过注入的 invoke 调用;发布状态回读也走同一条 + // native service,不允许重新接回 React 直连 HTTP。封面生成 / 封面报价 / 资料建议三条命令 + // 已随旧的发布表单一起退役(Rust 侧实现与注册都已删除),不再登记。 'read_game_distribution_publication', - 'upload_local_project_game_package', // 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts` // 里以字面量出现,静态扫描仍按共享注册表核验。 'read_profile_recharge_center', @@ -151,7 +147,6 @@ const allowedUncalledTauriCommands = [ // 仍注册在 Rust 侧供 native 流程与 Rust 测试使用,仅不出现在 App 前端源码里。 'check_game_creator_llm_config', 'diff_local_project_checkpoint', - 'list_local_project_export_packages', 'read_local_agent_memory', 'read_local_game_memory', 'read_local_project_file', diff --git a/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs b/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs index b92b38e7c..c04f75f56 100644 --- a/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs +++ b/apps/ai-game-creator-shell/scripts/check-skill-pack.test.mjs @@ -7,8 +7,10 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { + collectBundledFiles, computeSkillContentFingerprint, inspectSkillPack, + isSafeSkillRelativePath, } from './skill-pack-manifest.mjs'; test('bundled skill pack manifest is synchronized', () => { @@ -16,6 +18,37 @@ test('bundled skill pack manifest is synchronized', () => { assert.deepEqual(result.mismatches, []); }); +test('hidden skill paths are rejected and never declared', () => { + assert.equal(isSafeSkillRelativePath('SKILL.md'), true); + assert.equal(isSafeSkillRelativePath('references/contract.md'), true); + assert.equal(isSafeSkillRelativePath('.selective_rule.txt'), false); + assert.equal(isSafeSkillRelativePath('scripts/.pack.test.mjs'), false); + assert.equal(isSafeSkillRelativePath('.hidden/SKILL.md'), false); +}); + +test('skill pack collector ignores hidden files and directories', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-skill-hidden-')); + try { + fs.mkdirSync(path.join(root, 'demo', 'scripts'), { recursive: true }); + fs.writeFileSync(path.join(root, 'demo', 'SKILL.md'), '# demo\n'); + fs.writeFileSync(path.join(root, 'demo', '.selective_rule.txt'), 'local\n'); + fs.writeFileSync( + path.join(root, 'demo', 'scripts', 'pack.mjs'), + 'export {};\n', + ); + fs.writeFileSync( + path.join(root, 'demo', 'scripts', '.pack.test.mjs'), + 'test\n', + ); + assert.deepEqual(collectBundledFiles(root), [ + 'demo/SKILL.md', + 'demo/scripts/pack.mjs', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test('skill content fingerprint canonicalizes CRLF', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-skill-pack-')); try { diff --git a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs index 88c717208..a560f09e9 100644 --- a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs +++ b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs @@ -13,7 +13,10 @@ export const EXPECTED_SKILL_NAMES = Object.freeze([ 'agc-project-structure', 'agc-unity-editor', 'agc-web-game-development', + 'platform-abstract', 'taonier-art-assets', + 'vite-export-taonier', + 'vite-export-xhs-minitool', ]); const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); @@ -27,6 +30,17 @@ function canonicalTextBytes(filePath) { return Buffer.from(decoded.replaceAll('\r\n', '\n'), 'utf8'); } +/** + * 隐藏文件/目录名(以 `.` 开头)永远不进入审核 Skill Pack。 + * + * 约定:Skill 目录里以 `.` 开头的文件和目录是本地开发辅助(例如 + * `.selective_rule.txt`、`scripts/.pack.test.mjs`),既不写进 manifest, + * 也不做指纹、不安装、不可通过 `agc_read_skill_resource` 读取。 + */ +export function isHiddenSkillEntryName(name) { + return typeof name === 'string' && name.startsWith('.'); +} + export function isSafeSkillRelativePath(value) { if ( typeof value !== 'string' || @@ -40,7 +54,11 @@ export function isSafeSkillRelativePath(value) { return value .split('/') .every( - (segment) => segment.length > 0 && segment !== '.' && segment !== '..', + (segment) => + segment.length > 0 && + segment !== '.' && + segment !== '..' && + !isHiddenSkillEntryName(segment), ); } @@ -70,10 +88,14 @@ export function computeSkillContentFingerprint(rootDir, entry) { return digest.digest('hex'); } -function collectBundledFiles(rootDir) { +export function collectBundledFiles(rootDir) { const files = []; const walk = (directory, prefix) => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + // 隐藏文件/目录不参与审核:它们是本地开发辅助,不声明、不指纹、不安装。 + if (isHiddenSkillEntryName(entry.name)) { + continue; + } const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; const absolutePath = path.join(directory, entry.name); if (entry.isSymbolicLink()) { diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index 8d2e28cd1..17a012862 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -4,6 +4,8 @@ "conversation.list.description": "按序读取当前项目已记录的 Codex 返回摘要。", "conversation.read.description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。", "agc_read_skill_resource.description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。", + "agc_install_skill_resource.description": "把审核通过的 AGC Skill 自带文件原样复制到当前项目相对路径;宿主直接读内置字节,不会像读取那样被大文件截断,也不需要原生 cp 或审批,一次一个文件。仍走 agc_write_file 的合同与租约门。", + "agc_install_skill_resource.parameters.destinationPath": "当前项目根下的目标相对路径,例如 scripts/validate.mjs", "agc_write_file.description": "把文本写入当前 AGC 项目的相对路径,用于代码、配置、资源依赖或说明文件。", "agc_apply_patch.description": "使用官方 apply_patch 语法修改当前项目,支持 Add/Delete/Update/Move。固定当前项目为工作目录;一次最多64KiB UTF-8、256个操作,并受实际平台参数上限约束。完整检查全部源与目标后执行;失败可能已部分修改,先读取当前文件再提出新补丁。该工具可与独立的读取、生成和计划调用并行;同文件修改与依赖其结果的构建、检查须等待补丁回执。超时、取消或 needsReconciliation=true 时停止,不自动重放。", "agc_update_plan.description": "更新当前回合的进度计划,字段与 update_plan 相同:可选 explanation,以及 plan 中的 step/status(pending、in_progress、completed)。它可与其它独立工具并行;同一计划的连续更新按依赖顺序提交。计划完成只表示进度,不代替宿主交付验收。", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json index 5116ddcbe..d0fc2ea62 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json @@ -2,7 +2,7 @@ "identity": "对外身份:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问名称或能力时,以陶泥儿的身份回答。用户明确询问底层实现时可如实说明 Codex app-server 的作用。", "hostDelivery": "交付要求:当用户要求你制作、完成或交付游戏(例如“做一个游戏”“做一个可运行的游戏”“交付游戏”)时,调用 agc_register_delivery_contract 登记本轮 scope、changeKind 和 requirements,由你结合用户输入理解意图。只生图、普通修改、读取、咨询或不操作无需合同。文件写入、命令、生成和验证不以合同为前提。每项有唯一ID,仅支持visual或gameplay(scenario);登记后不能扩项。新Web合同由宿主补充现有双端视觉和固定玩法要求,以登记回包为准;跑酷选择runner-v1,俄罗斯方块选择tetris-v1,其余按真实能力选择固定场景。按实际需要构建和测试,构建命令返回值不是合同验收项。不能提交passed或改写证据。agc_delivery_status只返回当前评估,不会结束执行;完成本轮用户要求并正常回复后,宿主才复核已登记合同。", "deliveryFeedback": "本轮已登记合同的验收尚未通过。读取agc_delivery_status,针对未满足的视觉/玩法要求修复并提供真实证据;不得扩项、提交passed或改写证据。按实际需要构建和测试,再执行必要的固定双端场景。原用户目标与本轮登记要求保持不变。\n\n未满足项:\n{detail}", - "engineering": "AGC 工程要求:当前 cwd 是用户选择的项目目录。先读取适用的 AGENTS.md、README 或项目说明,识别实际引擎与工程结构。用户明确指定编辑器或引擎,而当前目录缺少对应工程结构时,先说明不匹配并澄清;用户确认继续当前工程或提供匹配目录后再执行。Cocos Creator 项目优先通过 `agc_cocos_execute` 或 `cocos.editor.execute` 操作已打开的编辑器。新 Web 游戏使用 npm + Vite;二维游戏使用 Phaser 4.2.1,以 `import Phaser from 'phaser'` 导入;三维游戏自行选择合适的三维技术栈。依赖统一使用 npm 包。Phaser 迁移:读取已有 game/index.html,将状态、输入、敌人/守卫、波次、胜负、重开和画布绘制迁移到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后启动 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布由单一机制居中:使用 Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 直接父容器使用尺寸明确的普通 block;使用 CSS 居中时,Phaser autoCenter 设为 NO_CENTER。外围布局可使用 flex/grid。预览偏移先检查并修正项目自身的 CSS 与 Phaser 配置。布局修改后按项目 scripts 构建 dist,在桌面、移动视口和 resize 后确认 canvas 相对父容器的中心误差不超过 1 CSS px、无溢出。简单修改聚焦用户要求及不可替代的最小验证;安装依赖、构建和试玩按此范围执行。源码和命令优先使用 cwd 相对路径,依赖安装与构建使用项目 npm scripts;原生文件读取、搜索、命令和图片查看按当前工具目录使用。源码局部补丁调用 `agc_apply_patch`,支持官方 Add/Delete/Update/Move 语法并固定当前项目目录;多步骤进度调用 `agc_update_plan`,计划状态不代替验收证据。完整文本写入可使用 `agc_write_file`,content 仅填写目标文件的完整原始 UTF-8 正文。可用能力包括原生文件、搜索、命令、图片查看、Skill、`agc_tools` 和用户已启用的第三方 MCP;用户指定工具时先查当前可用工具并调用,缺失时如实说明。资源工具按当前 schema 使用;Skill references 按需读取。完整新游戏或按策划案实现时执行 agc-game-production-workflow,依次完成“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”。需要视觉素材时执行 taonier-art-assets:检查已登记资源,缺少或不适用时调用生图/编辑工具,读取结果的相对路径和登记身份,将真实素材接入源码并验证显示后再交付。你负责推进任务和按范围试玩。", + "engineering": "AGC 工程要求:当前 cwd 是用户选择的项目目录。先读取适用的 AGENTS.md、README 或项目说明,识别实际引擎与工程结构。用户明确指定编辑器或引擎,而当前目录缺少对应工程结构时,先说明不匹配并澄清;用户确认继续当前工程或提供匹配目录后再执行。Cocos Creator 项目优先通过 `agc_cocos_execute` 或 `cocos.editor.execute` 操作已打开的编辑器。新 Web 游戏使用 npm + Vite;二维游戏使用 Phaser 4.2.1,以 `import Phaser from 'phaser'` 导入;三维游戏自行选择合适的三维技术栈。依赖统一使用 npm 包。Phaser 迁移:读取已有 game/index.html,将状态、输入、敌人/守卫、波次、胜负、重开和画布绘制迁移到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后启动 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布由单一机制居中:使用 Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 直接父容器使用尺寸明确的普通 block;使用 CSS 居中时,Phaser autoCenter 设为 NO_CENTER。外围布局可使用 flex/grid。预览偏移先检查并修正项目自身的 CSS 与 Phaser 配置。布局修改后按项目 scripts 构建 dist,在桌面、移动视口和 resize 后确认 canvas 相对父容器的中心误差不超过 1 CSS px、无溢出。简单修改聚焦用户要求及不可替代的最小验证;安装依赖、构建和试玩按此范围执行。源码和命令优先使用 cwd 相对路径,依赖安装与构建使用项目 npm scripts;原生文件读取、搜索、命令和图片查看按当前工具目录使用。源码局部补丁调用 `agc_apply_patch`,支持官方 Add/Delete/Update/Move 语法并固定当前项目目录;多步骤进度调用 `agc_update_plan`,计划状态不代替验收证据。完整文本写入可使用 `agc_write_file`,content 仅填写目标文件的完整原始 UTF-8 正文。复制 Skill 自带文件(脚本、配置、参考文档)进项目时改用 `agc_install_skill_resource`,一次一个文件、`destinationPath` 为目标相对路径;不要用 `agc_write_file` 重抄大文件正文,也不要依赖原生 `cp`。可用能力包括原生文件、搜索、命令、图片查看、Skill、`agc_tools` 和用户已启用的第三方 MCP;用户指定工具时先查当前可用工具并调用,缺失时如实说明。资源工具按当前 schema 使用;Skill references 按需读取。完整新游戏或按策划案实现时执行 agc-game-production-workflow,依次完成“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”。需要视觉素材时执行 taonier-art-assets:检查已登记资源,缺少或不适用时调用生图/编辑工具,读取结果的相对路径和登记身份,将真实素材接入源码并验证显示后再交付。你负责推进任务和按范围试玩。", "unityPlugin": "Unity 编辑器能力由 agc_unity_execute(Runtime 工具名 unity.editor.execute)提供。当前工程是 Unity 时使用该工具执行 C#,先读取实际场景与对象再修改。仅提交 code;缺少工具时报告该能力不可用,不要自行安装或改写插件。结果待人工核对、超时或断线时,禁止自动重发、重启插件或切换项目以绕过阻断。只有真实 completed 回执才可报告成功。", "godotPlugin": "Godot 编辑器能力由 agc_godot_execute(Runtime 工具名 godot.editor.execute)提供。当前工程是 Godot 时使用该工具执行支持 return/await 的 GDScript 函数体,先读取真实场景再修改;不改写为 Phaser。不要自行安装插件、写入库文件或描述文件。仅提交 code,不提供项目、进程、端口、令牌或库路径;缺少工具时报告该能力不可用。编译或确定运行失败可修正代码;结果待人工核对、超时或断线时禁止自动重发、重启插件或切换项目绕过阻断。只有真实 completed 回执才可报告成功。", "editorGuide": "常用编辑器操作:Unity 先读 agc-unity-editor,Godot 先读 agc-godot-editor。可用原生 Skill 读取,或调用 agc_read_skill_resource,skillName 为对应名称、relativePath 为 SKILL.md,再按入口读取操作参考。指南提供场景、对象/节点、资源、UI、保存和撤销示例;只读说明不代表编辑器工具已可用,实际执行仍检查当前工具。", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json index 5e0892573..44238a227 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json @@ -6,7 +6,6 @@ "playtest.tetris": "完成合同要求 tetris-v1 交互试玩。game/index.html 必须持续更新 '; + +/** 造一个最小的 H5 构建产物目录;extra 用来放额外文件。 */ +function writeBuiltDir(name, extra = {}) { + const root = join(BASE, name); + mkdirSync(join(root, 'assets'), { recursive: true }); + writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8'); + writeFileSync(join(root, 'assets', 'app.js'), "console.log('ok');\n", 'utf8'); + for (const [relativePath, content] of Object.entries(extra)) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } + return root; +} + +/** 直接解析中央目录读取 zip 条目名:验证真实写出的字节,而不是只看返回值。 */ +function zipEntryNames(zipPath) { + const buf = readFileSync(zipPath); + const eocd = buf.length - 22; + assert.equal(buf.readUInt32LE(eocd), 0x06054b50, '未找到 EOCD'); + const count = buf.readUInt16LE(eocd + 10); + let offset = buf.readUInt32LE(eocd + 16); + const names = []; + for (let i = 0; i < count; i += 1) { + assert.equal(buf.readUInt32LE(offset), 0x02014b50, '中央目录签名错误'); + const nameLength = buf.readUInt16LE(offset + 28); + const extraLength = buf.readUInt16LE(offset + 30); + const commentLength = buf.readUInt16LE(offset + 32); + names.push(buf.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')); + offset += 46 + nameLength + extraLength + commentLength; + } + return names; +} + +test('干净产物默认不套外层:index.html 直接位于 zip 存档根', () => { + writeBuiltDir('clean'); + const zipOut = join(BASE, 'clean-out', 'taonier.zip'); + assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'clean', zipOut }), 0); + + const names = zipEntryNames(zipOut); + assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`); + assert.ok(names.includes('assets/app.js'), `缺少 assets/app.js: ${names}`); + // 不得有外层文件夹:条目不许多出 game/ 之类的前缀。 + assert.deepEqual( + names.filter((name) => !name.startsWith('assets/') && name !== 'index.html'), + [], + `存档根出现了计划外条目: ${names}`, + ); + assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`); +}); + +test('显式传 rootDir: null 也不套外层文件夹', () => { + writeBuiltDir('explicit-null-root'); + const zipOut = join(BASE, 'explicit-null-root-out', 'taonier.zip'); + assert.equal( + packTaonier({ cwd: BASE, viteBuiltDir: 'explicit-null-root', zipOut, rootDir: null }), + 0, + ); + const names = zipEntryNames(zipOut); + assert.ok(names.includes('index.html'), `缺少存档根 index.html: ${names}`); + assert.ok(!names.some((name) => name.startsWith('game/')), `不应套外层: ${names}`); +}); + +test('--root-dir 可选:给了才套指定顶层文件夹', () => { + writeBuiltDir('custom-root'); + const zipOut = join(BASE, 'custom-root-out', 'taonier.zip'); + assert.equal( + packTaonier({ cwd: BASE, viteBuiltDir: 'custom-root', zipOut, rootDir: 'my-h5' }), + 0, + ); + const names = zipEntryNames(zipOut); + assert.ok(names.includes('my-h5/index.html'), `缺少 my-h5/index.html: ${names}`); + assert.deepEqual(names.filter((name) => !name.startsWith('my-h5/')), []); +}); + +test('产物根缺少 index.html 时整体失败,且不写出 zip', () => { + const root = join(BASE, 'no-index'); + mkdirSync(join(root, 'assets'), { recursive: true }); + writeFileSync(join(root, 'assets', 'app.js'), 'console.log(1);\n', 'utf8'); + const zipOut = join(BASE, 'no-index-out', 'taonier.zip'); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'no-index', zipOut }), + /缺少 index\.html/, + ); + assert.equal(existsSync(zipOut), false, '失败时不应写出 zip'); +}); + +test('只接受 .zip 落点', () => { + writeBuiltDir('only-zip'); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'only-zip', zipOut: 'only-zip-out/game.tar' }), + /只支持 \.zip/, + ); +}); + +test('非法 --root-dir 被拒绝', () => { + writeBuiltDir('bad-root'); + for (const rootDir of ['', '..', 'a/b', 'a\\b']) { + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'bad-root', zipOut: join(BASE, 'x.zip'), rootDir }), + /--root-dir/, + `rootDir=${JSON.stringify(rootDir)} 未被拒绝`, + ); + } +}); + +test('zip 内含 sourcemap 时失败并删除已写出的文件', () => { + writeBuiltDir('with-map', { 'assets/app.js.map': '{}\n' }); + const zipOut = join(BASE, 'with-map-out', 'taonier.zip'); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'with-map', zipOut }), + /禁止文件/, + ); + assert.equal(existsSync(zipOut), false, '不合规的 zip 不应留在落点'); +}); + +test('普通 .map 游戏资源不算 sourcemap', () => { + writeBuiltDir('with-level-map', { 'assets/level.map': '{"layers":[]}\n' }); + const zipOut = join(BASE, 'with-level-map-out', 'taonier.zip'); + assert.equal(packTaonier({ cwd: BASE, viteBuiltDir: 'with-level-map', zipOut }), 0); + assert.ok(zipEntryNames(zipOut).includes('assets/level.map')); +}); + +test('--zip-out 落在产物目录内时被拒绝', () => { + const root = writeBuiltDir('zip-inside'); + assert.throws( + () => packTaonier({ + cwd: BASE, + viteBuiltDir: 'zip-inside', + zipOut: join(root, 'taonier.zip'), + }), + /不能落在 --vite-built-dir 内/, + ); +}); + +test('产物含符号链接时失败', () => { + const root = writeBuiltDir('with-symlink'); + symlinkSync(join(root, 'assets', 'app.js'), join(root, 'assets', 'link.js')); + assert.throws( + () => packTaonier({ cwd: BASE, viteBuiltDir: 'with-symlink', zipOut: join(BASE, 'symlink.zip') }), + /符号链接/, + ); +}); + +test('默认参数:dist-taonier + 存档根 index.html + ../.export/taonier.zip(假设 cwd 是 game/)', () => { + const game = join(BASE, 'default-layout', 'game'); + writeBuiltDir(join('default-layout', 'game', 'dist-taonier')); + assert.equal(packTaonier({ cwd: game }), 0); + const zipOut = join(BASE, 'default-layout', '.export', 'taonier.zip'); + assert.ok(existsSync(zipOut), '默认落点不在项目根的 .export/taonier.zip'); + const names = zipEntryNames(zipOut); + assert.ok(names.includes('index.html'), `默认应当在存档根放 index.html: ${names}`); + assert.ok(!names.includes('game/index.html'), `默认不应套外层文件夹: ${names}`); +}); + +test('CLI 拒绝不认识的参数,不写出 zip', () => { + const dir = writeBuiltDir('cli'); + const zipOut = join(BASE, 'cli-out.zip'); + const result = spawnSync( + process.execPath, + [PACK, '--vite-built-dir', dir, '--out-dir', dir, '--zip-out', zipOut], + { cwd: BASE, encoding: 'utf8' }, + ); + assert.equal(result.status, 2); + assert.match(result.stderr, /不认识的参数/); + assert.equal(existsSync(zipOut), false, '参数被拒时不应写出 zip'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/.vite.config.taonier.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/.vite.config.taonier.test.mjs new file mode 100644 index 000000000..89ed5f100 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/.vite.config.taonier.test.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * vite.config.taonier.mjs 的收尾插件端到端测试:真跑一遍 `vite build`,验证 dist 在 build + * 结束时已经是可托管的 H5 根目录(根 index.html、资源相对路径、空目录被清掉),而 pack 只 + * 负责套单层文件夹并打 zip。 + * + * 这是本地开发辅助(`.` 前缀),不随 Skill Pack 发布。 + */ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { after } from 'node:test'; + +import { packTaonier } from './pack.mjs'; +import { + TAONIER_OUT_DIR, + assertH5Root, + defineTaonierConfig, + pruneEmptyDirs, + taonierArtifactPlugin, +} from './vite.config.taonier.mjs'; + +const BASE = mkdtempSync(join(tmpdir(), 'taonier-vite-config-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const SOURCE_HTML = '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' fixture\n' + + ' \n' + + ' \n' + + '
\n' + + ' \n' + + ' \n' + + '\n'; + +/** 造一个最小 vite 项目;返回项目根。 */ +function writeProject(name) { + const root = join(BASE, name); + mkdirSync(join(root, 'src'), { recursive: true }); + mkdirSync(join(root, 'public', 'empty-dir'), { recursive: true }); + writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8'); + writeFileSync( + join(root, 'src', 'main.js'), + "document.getElementById('app').textContent = 'ok';\n", + 'utf8', + ); + return root; +} + +test('config 的 outDir 固定为 dist-taonier', () => { + assert.equal(TAONIER_OUT_DIR, 'dist-taonier'); + assert.equal(defineTaonierConfig({}).build.outDir, 'dist-taonier'); +}); + +test('vite 构建收尾:dist 可直接被打成存档根带 index.html 的 zip', async () => { + const root = writeProject('finalized'); + const { build } = await import('vite'); + const config = defineTaonierConfig({ root, logLevel: 'silent' }); + + await build({ ...config, configFile: false }); + const outDir = join(root, 'dist-taonier'); + + assert.ok(existsSync(join(outDir, 'index.html')), 'index.html 未生成'); + const html = readFileSync(join(outDir, 'index.html'), 'utf8'); + assert.doesNotMatch(html, /\b(?:src|href)="\//, '仍有根路径资源引用'); + assert.match(html, /src="\.\/assets\/[^"]+\.js"/, '入口脚本不是相对路径'); + // public/empty-dir 是空目录,收尾后不应留在 dist。 + assert.equal(existsSync(join(outDir, 'empty-dir')), false, '空目录没有被清理'); + + const zipOut = join(BASE, 'out', 'taonier.zip'); + assert.equal(packTaonier({ cwd: root, zipOut }), 0); + assert.ok(existsSync(zipOut), 'zip 未生成'); +}); + +test('base 被改成根路径时构建失败', async () => { + const root = writeProject('absolute-base'); + const { build } = await import('vite'); + const config = defineTaonierConfig({ root, logLevel: 'silent', base: '/' }); + + await assert.rejects( + build({ ...config, configFile: false }), + /根路径资源/, + '根路径引用没有被收尾插件拦下', + ); +}); + +test('构建失败时不收尾:不覆盖真实报错', async () => { + const root = join(BASE, 'failed-build'); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8'); + writeFileSync(join(root, 'src', 'main.js'), 'this is not valid js {{\n', 'utf8'); + + const { build } = await import('vite'); + const config = defineTaonierConfig({ root, logLevel: 'silent' }); + await assert.rejects( + build({ ...config, configFile: false }), + (error) => { + assert.doesNotMatch(error.message, /taonier-artifact/, + '插件把自己的报错盖在了真实构建错误上'); + return true; + }, + ); + + assert.equal(existsSync(join(root, 'dist-taonier', 'index.html')), false, + '失败的构建不该有 index.html'); +}); + +test('defineTaonierConfig 始终追加收尾插件,且只跑一次', () => { + const withExtra = defineTaonierConfig({ plugins: [{ name: 'extra' }] }); + const names = withExtra.plugins.map((plugin) => plugin.name); + assert.ok(names.includes('taonier-artifact'), `缺少收尾插件: ${names}`); + assert.equal( + names.filter((name) => name === 'taonier-artifact').length, + 1, + `收尾插件重复: ${names}`, + ); + assert.ok(names.includes('extra'), 'overrides 的插件丢了'); + assert.equal(names.at(-1), 'taonier-artifact', `收尾插件不在最后: ${names}`); +}); + +test('assertH5Root 拦截根路径引用与缺失的 index.html', () => { + const root = join(BASE, 'unit-assert'); + mkdirSync(root, { recursive: true }); + assert.throws(() => assertH5Root(root), /index\.html 不存在/); + + writeFileSync(join(root, 'index.html'), '\n', 'utf8'); + assert.throws(() => assertH5Root(root), /根路径资源/); + + writeFileSync(join(root, 'index.html'), '\n', 'utf8'); + assert.doesNotThrow(() => assertH5Root(root)); +}); + +test('pruneEmptyDirs 只删空目录,保留有内容的目录', () => { + const root = join(BASE, 'prune'); + mkdirSync(join(root, 'empty', 'nested'), { recursive: true }); + mkdirSync(join(root, 'kept'), { recursive: true }); + writeFileSync(join(root, 'kept', 'file.js'), 'console.log(1);\n', 'utf8'); + + pruneEmptyDirs(root); + + assert.equal(existsSync(join(root, 'empty')), false, '空目录未删除'); + assert.ok(existsSync(join(root, 'kept', 'file.js')), '有内容的目录被误删'); +}); + +test('taonierArtifactPlugin 声明为 build-only 的后置插件', () => { + const plugin = taonierArtifactPlugin(); + assert.equal(plugin.apply, 'build'); + assert.equal(plugin.enforce, 'post'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/pack.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/pack.mjs new file mode 100644 index 000000000..077904563 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/pack.mjs @@ -0,0 +1,470 @@ +#!/usr/bin/env node +/** + * 把 vite 构建产物打成陶泥儿 H5 托管 zip(纯 Node,不依赖 zip / zipinfo 系统命令)。 + * + * 陶泥儿的上传要求很短: + * - 仅支持单个 .zip 压缩包; + * - zip 存档根目录必须直接包含 index.html(**不得有外层文件夹**)。 + * + * 所以本脚本只做三件确定性的事: + * 1. 把 `--vite-built-dir`(默认 dist-taonier)的内容原样放到 zip 存档根; + * 2. 用 node:zlib 写出 zip,index.html 位于存档根 `index.html`; + * 3. 校验结构与构建目录根:不合格时整体失败,不留下可误传的 zip。 + * + * 陶泥儿服务端只认存档根 `index.html`(`game/index.html` 会被判 MissingEntry),所以默认**不套** + * 外层文件夹;`--root-dir` 只在调用方确有需要时显式给出,才会额外套一层顶层文件夹。 + * + * dist 的收尾(相对路径、根 index.html、清空目录)已由 `vite.config.taonier.mjs` 的 + * `taonier-artifact` 插件在构建时完成;本脚本只读 dist,不改写它。 + */ +import { + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeSync, +} from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { deflateRawSync } from 'node:zlib'; + +const MIB = 1024 * 1024; + +/** + * 默认落点:假设 npm 脚本挂在 `game/` 子工程(cwd = `game/`),宿主在**项目根** + * `.export/taonier.zip` 找产物,所以默认写 `../.export/taonier.zip`。 + * 脚本挂在项目根 `package.json`(cwd = 项目根)时用 `--zip-out .export/taonier.zip` 覆盖。 + */ +const DEFAULT_ZIP_OUT = join('..', '.export', 'taonier.zip'); +const DEFAULT_VITE_BUILT_DIR = 'dist-taonier'; + +/** + * 可选外层文件夹名。陶泥儿要求存档根直接有 index.html,所以默认 `null`=不套外层; + * 调用方显式给 `--root-dir` 时才套一层(名字不能含路径分隔符)。 + */ +const DEFAULT_ROOT_DIR = null; + +/** + * 最终包不应携带的开发/构建残留;命中即失败。 + * + * 只拦 `*.js.map` / `*.css.map`(构建生成的 sourcemap),不拦所有 `.map`——有些引擎把关卡 + * 数据存成 `level.map`,那是正常游戏资源。 + */ +const FORBIDDEN_ENTRY = + /(^|\/)(?:node_modules|\.git)\/|(^|\/)\.DS_Store$|\.(?:js|css)\.map$|(^|\/)(?:vite|webpack|rollup)\.config\.[^/]+$/i; + +const USAGE = `用法:node pack.mjs [--vite-built-dir ] [--zip-out ] [--root-dir ] + +把 vite 构建产物打成陶泥儿 H5 zip(仅 .zip,存档根直接带 index.html)。 + +--vite-built-dir vite 的构建输出目录,必须与 vite config 的 build.outDir 一致。默认 ${DEFAULT_VITE_BUILT_DIR}。 + 该目录根下必须有 index.html;本脚本只读它,不改写。 +--zip-out 落点,必须以 .zip 结尾。默认 ${DEFAULT_ZIP_OUT}(假设 cwd 是 game/ 子工程, + 产物落在项目根 .export/);相对路径按当前工作目录解析,父目录不存在会自动建。 +--root-dir 可选。默认不套外层文件夹(构建产物内容直接位于存档根);给了才把内容套进 + 单层文件夹 /。不能包含路径分隔符。`; + +/** 顶层文件夹名必须是单个安全路径段:非空、不含分隔符、不是 . / ..;`null` 表示不套外层。 */ +export function normalizeRootDir(value) { + if (value === null || value === undefined) return null; + const name = String(value).trim(); + if (!name || name === '.' || name === '..') { + throw new Error('--root-dir 必须是一个非空的文件夹名'); + } + if (name.includes('/') || name.includes('\\')) { + throw new Error(`--root-dir 不能包含路径分隔符:${name}`); + } + return name; +} + +/* ------------------------------------------------------------------ */ +/* 纯 Node zip 写入(store / deflate,UTF-8 文件名,ZIP64 直接报错) */ +/* ------------------------------------------------------------------ */ + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let c = 0xffffffff; + for (let i = 0; i < buffer.length; i += 1) { + c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +/** + * 收集 dist 下所有条目。`rootName` 为 `null` 时不加前缀:构建目录内容直接落在存档根 + * (`index.html`、`assets/...`);给了 `rootName` 才整体挂到 `/` 前缀下。 + * + * 目录条目在前、文件名用 `/` 分隔、保持排序,产物稳定。套外层时顶层文件夹自身也写一个目录 + * 条目,这样即使 dist 为空(实际不会),解压仍能得到唯一文件夹。 + */ +function collectZipEntries(rootDir, rootName) { + const prefix = rootName ? `${rootName}/` : ''; + const entries = rootName ? [{ name: prefix, dir: true, full: rootDir }] : []; + const visit = (dir) => { + for (const name of readdirSync(dir).sort()) { + const full = join(dir, name); + const st = lstatSync(full); + const nameInZip = `${prefix}${relative(rootDir, full).split(sep).join('/')}`; + if (st.isDirectory()) { + entries.push({ name: `${nameInZip}/`, dir: true, full }); + visit(full); + } else if (st.isFile()) { + entries.push({ name: nameInZip, dir: false, full }); + } else { + // FIFO / socket / 设备文件既不是目录也不是普通文件;静默跳过会打出一个缺文件的包。 + throw new Error(`产物目录含非普通文件条目:${full};请清理后再打包`); + } + } + }; + visit(rootDir); + return entries; +} + +/** + * 写出 zip。数据按条目顺序**流式**写盘,只在内存里保留中央目录,避免大包在内存里再拼一份完整副本。 + * + * @returns {string[]} zip 内条目名(含目录条目),用于结构复核 + */ +export function writeZip(rootDir, zipPath, rootName) { + return writeZipEntries(collectZipEntries(rootDir, rootName), zipPath); +} + +/** + * 把已收集的条目流式写入 zip。 + * + * 单独抽一层是为了让调用方能先把条目列表拿去做结构校验、再落盘:结构不合规时落点上不能出现 + * 半成品,也不能把落点原有的文件覆盖掉。数据本身仍然边读边写,不在内存里拼完整副本。 + */ +function writeZipEntries(entries, zipPath) { + if (entries.length > 0xffff) { + throw new Error(`zip 条目数 ${entries.length} 超过 65535,当前 Node zip 实现不支持 ZIP64`); + } + + const now = new Date(); + const dosTime = + ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff; + const dosDate = + (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff; + + // 先写同目录临时文件、全部成功后再原子改名覆盖落点:任何写盘失败(磁盘满、4 GiB 上限、 + // 读源文件时被删)都不会截断或删掉落点上原有的 zip。 + const tempPath = `${zipPath}.tmp-${process.pid}`; + const fd = openSync(tempPath, 'w'); + const centralParts = []; + let offset = 0; + + try { + for (const entry of entries) { + const nameBuf = Buffer.from(entry.name, 'utf8'); + let data = Buffer.alloc(0); + let method = 0; + let crc = 0; + let uncompressed = 0; + + if (!entry.dir) { + // TODO(perf): 这里每个文件都整读一遍、再压出一份副本,单文件峰值内存约 2×;H5 里超大 + // 音频/贴图/WASM 可能顶爆内存或导出失败。彻底做法是改用 ZIP data descriptor(通用标志位 + // bit 3):先写数据、后回填压缩大小与 CRC,实现按文件流式写盘;代价是本地 zip 头与中央 + // 目录记账要重写。当前保留现状,先在此标注。 + const raw = readFileSync(entry.full); + uncompressed = raw.length; + const deflated = deflateRawSync(raw, { level: 9 }); + if (deflated.length < raw.length) { + data = deflated; + method = 8; + } else { + data = raw; + } + crc = crc32(raw); + } + + if (data.length > 0xffffffff || uncompressed > 0xffffffff || offset > 0xffffffff) { + throw new Error(`条目 ${entry.name} 超过 4 GiB,当前 Node zip 实现不支持 ZIP64`); + } + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(uncompressed, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + + writeSync(fd, local); + writeSync(fd, nameBuf); + if (data.length > 0) writeSync(fd, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(uncompressed, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(entry.dir ? 0x10 : 0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBuf); + + offset += local.length + nameBuf.length + data.length; + } + + const centralBuf = Buffer.concat(centralParts); + if (centralBuf.length > 0xffffffff) { + throw new Error('中央目录超过 4 GiB,当前 Node zip 实现不支持 ZIP64'); + } + const centralOffset = offset; + writeSync(fd, centralBuf); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(centralOffset, 16); + eocd.writeUInt16LE(0, 20); + writeSync(fd, eocd); + } catch (error) { + closeSync(fd); + rmSync(tempPath, { force: true }); + throw error; + } + closeSync(fd); + renameSync(tempPath, zipPath); + + return entries.map((entry) => entry.name); +} + +/** + * 复核 zip 结构:存档根直接包含 `index.html`。 + * + * `rootName` 为 `null`(默认)时要求条目名恰好是 `index.html`;给了 `rootName` 时要求 + * `/index.html`,且所有条目都在该文件夹内。 + * + * @param {string[]} entries writeZip 返回的条目名 + * @param {string | null} rootName + */ +function assertArchiveStructure(entries, rootName) { + const prefix = rootName ? `${rootName}/` : ''; + const indexName = `${prefix}index.html`; + if (!entries.includes(indexName)) { + throw new Error( + `zip 存档根缺少 ${indexName};陶泥儿要求存档根目录直接包含 index.html(不得有外层文件夹)`, + ); + } + if (rootName) { + const outside = entries.find((name) => !name.startsWith(prefix)); + if (outside) { + throw new Error(`zip 里出现 ${prefix} 之外的条目:${outside};套了 --root-dir 时所有条目都必须在其中`); + } + } + const bad = entries + .map((name) => name.slice(prefix.length)) + .find((name) => FORBIDDEN_ENTRY.test(name)); + if (bad) { + throw new Error(`zip 内含禁止文件:${prefix}${bad}`); + } +} + +/** 产物目录必须存在、根下有 index.html,且不含符号链接(链接不会被打进包)。 */ +function assertPackedSource(viteBuiltDir) { + if (!existsSync(viteBuiltDir)) { + throw new Error(`缺少 vite 构建产物目录 ${viteBuiltDir}`); + } + if (!statSync(viteBuiltDir).isDirectory()) { + throw new Error(`vite 构建产物路径不是目录 ${viteBuiltDir}`); + } + const indexPath = join(viteBuiltDir, 'index.html'); + if (!existsSync(indexPath)) { + throw new Error( + `产物根目录缺少 index.html:${indexPath};` + + '陶泥儿要求 zip 存档根目录直接包含 index.html', + ); + } + assertNoSymlinks(viteBuiltDir); +} + +function assertNoSymlinks(dir) { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + const st = lstatSync(full); + if (st.isSymbolicLink()) { + throw new Error(`产物目录含符号链接:${full};请复制真实文件后再打包`); + } + if (st.isDirectory()) assertNoSymlinks(full); + } +} + +/** + * 打包并复核。结构(存档根 index.html、可选外层文件夹、禁止条目)在落盘前就校验;写盘走 + * 临时文件 + 原子改名,任何一步失败都不会截断或删掉落点上原有的 zip。 + */ +function makeZip(viteBuiltDir, zipPath, rootName) { + const entries = collectZipEntries(viteBuiltDir, rootName); + // 先校验再落盘:落点原有文件不会被这次失败覆盖,也不存在「不合规 zip 短暂存在」的窗口。 + assertArchiveStructure( + entries.map((entry) => entry.name), + rootName, + ); + + try { + writeZipEntries(entries, zipPath); + + const size = statSync(zipPath).size; + const location = rootName ? `顶层文件夹 ${rootName}/` : '存档根 index.html'; + console.log( + `zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB,${location})`, + ); + } catch (error) { + // 不再删除 zipPath:写盘只落在临时文件,失败时原有产物必须保留。 + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message};未覆盖原有 zip`); + } +} + +/** + * cwd 歧义预警:宿主在**项目根** `.export/taonier.zip` 找产物,而 `--zip-out` 相对 cwd 解析。 + * npm 脚本挂在 `game/` 子工程时 cwd 就是 `game/`,写 `.export/...` 会落到 `game/.export/...`, + * 导出判失败却没有任何提示。这里只在落点位于「当前 cwd 的 `.export/`」时提醒(`../.export/...` + * 或绝对路径不触发),把 cwd 与绝对落点打出来,让 workdir 问题不再静默。 + */ +function warnIfWorkdirRelative(cwd, zipPath, zipOut) { + if (typeof zipOut !== 'string' || isAbsolute(zipOut)) return; + const relativeOut = relative(cwd, zipPath); + if (relativeOut.startsWith('..') || !relativeOut.startsWith(`.export${sep}`)) return; + console.warn( + `[warn] --zip-out 相对 cwd 解析:cwd=${cwd},落点=${zipPath}。` + + '宿主在项目根 `.export/taonier.zip` 找产物;若 cwd 是 game/ 子工程,' + + '请写 `../.export/taonier.zip`,否则会落在 game/.export/ 导致导出判失败。', + ); +} + +/** + * @param {{ + * cwd?: string, + * viteBuiltDir?: string, + * zipOut?: string, + * rootDir?: string | null, + * }} options + */ +export function packTaonier(options = {}) { + const cwd = resolve(options.cwd || process.cwd()); + const viteBuiltDir = resolve(cwd, options.viteBuiltDir || DEFAULT_VITE_BUILT_DIR); + const zipPath = resolve(cwd, options.zipOut || DEFAULT_ZIP_OUT); + const rootName = normalizeRootDir(options.rootDir ?? DEFAULT_ROOT_DIR); + + if (!zipPath.toLowerCase().endsWith('.zip')) { + throw new Error(`陶泥儿只支持 .zip 压缩包,--zip-out 必须以 .zip 结尾:${zipPath}`); + } + + // 落点在产物目录内时,上一次的 zip 会被当成资源打进新包(写盘前已 collect),也会在写盘 + // 中途被覆盖;直接拒绝这种自包含布局。 + const insideBuiltDir = relative(viteBuiltDir, zipPath); + if (insideBuiltDir === '' || (!insideBuiltDir.startsWith('..') && !isAbsolute(insideBuiltDir))) { + throw new Error(`--zip-out 不能落在 --vite-built-dir 内:${zipPath}`); + } + + warnIfWorkdirRelative(cwd, zipPath, options.zipOut); + assertPackedSource(viteBuiltDir); + + // 落点的父目录可能还不存在(`.export/` 是宿主按需创建的),先建再写。 + mkdirSync(dirname(zipPath), { recursive: true }); + + makeZip(viteBuiltDir, zipPath, rootName); + return 0; +} + +export default packTaonier; + +function isMainModule() { + const entry = process.argv[1]; + if (!entry) return false; + try { + if (realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) return true; + } catch { + /* fall through to URL comparison */ + } + try { + return import.meta.url === pathToFileURL(entry).href; + } catch { + return false; + } +} + +if (isMainModule()) { + const raw = process.argv.slice(2); + + if (raw.includes('--help') || raw.includes('-h')) { + console.log(USAGE); + process.exit(0); + } + + // 取走一个参数和它的值;剩下的非空参数说明调用方传了本脚本不认的东西。 + const takeFlag = (name) => { + const idx = raw.indexOf(name); + if (idx === -1) return undefined; + const value = raw[idx + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${name} 后面要跟一个值`); + } + raw.splice(idx, 2); + return value; + }; + + let viteBuiltDir; + let zipOut; + let rootDir; + try { + viteBuiltDir = takeFlag('--vite-built-dir'); + zipOut = takeFlag('--zip-out') || DEFAULT_ZIP_OUT; + // 不给就是 null=不套外层文件夹;给了才套。 + rootDir = takeFlag('--root-dir'); + // 不认识的参数当场拒绝:否则调用方以为它生效了,产物落到别处还查不出来。 + if (raw.length > 0) { + throw new Error(`不认识的参数:${raw.join(' ')}`); + } + } catch (err) { + console.error(err instanceof Error ? err.message : err); + console.error(USAGE); + process.exit(2); + } + + try { + packTaonier({ viteBuiltDir, zipOut, rootDir }); + process.exit(0); + } catch (err) { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs new file mode 100644 index 000000000..23a3e450f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs @@ -0,0 +1,149 @@ +/** + * 陶泥儿 H5 托管 Vite 配置(可直接作为项目 vite.config 使用): + * + * vite build --config <此文件路径> + * + * 或在项目自己的 vite.config 里合并: + * + * import { defineTaonierConfig } from '<此文件路径>'; + * export default defineTaonierConfig({ plugins: [...] }); + * + * 构建边界只有一条:**本配置负责把 dist 变成一个可托管的 H5 根目录,pack.mjs 只负责按契约打 zip。** + * + * 陶泥儿就是普通 H5 托管(不同于小红书小工具 vite-export-xhs-minitool):平台没有 Chrome 61 + * 基线、IIFE 单入口、禁内联脚本等运行约束,所以本配置不降级语法,也不重写 index.html。只固定 + * 三件与上传契约有关的事: + * + * - base: './' 相对路径。zip 存档根就是 ``,会被托管在任意子路径,根路径引用 + * (/assets/...)在子路径下会 404。 + * - build.outDir 与 pack.mjs 的 --vite-built-dir 是同一个目录(默认 dist-taonier),两处 + * 必须一致。 + * - sourcemap 置 false:不把 .map 打进上传包(pack 也会拒绝构建 sourcemap),也减小包体。 + * + * 收尾插件只做契约校验:dist 根必须有 index.html,且 index.html 不得引用根路径资源。这样 + * 「指错目录」或「误改 base」会在构建结束时就失败,而不是上传后才暴露问题。 + * + * 平台约束:仅支持单个 .zip 压缩包;zip 解压后根目录必须直接包含 index.html(不得有外层文件夹), + * 与 pack.mjs 的默认布局一致。 + */ +import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { defineConfig, mergeConfig } from 'vite'; + +/** 产物目录;必须与 pack.mjs 的 --vite-built-dir 默认值一致。 */ +export const TAONIER_OUT_DIR = 'dist-taonier'; + +/** 收尾插件名;defineTaonierConfig 按它去重,保证插件只跑一次。 */ +const ARTIFACT_PLUGIN_NAME = 'taonier-artifact'; + +/** + * 校验 dist 是否是一个合格的 H5 根目录:根 index.html 存在,且不引用根路径资源。 + * + * 缺失 index.html 时直接抛错——那说明构建配置指错了目录,继续打 zip 只会交付一个平台拒收的包 + * (解压后唯一文件夹下没有 index.html)。出现 `src="/…"` / `href="/…"` 同样抛错:交付物会被 + * 托管在子路径,根路径引用一定 404。 + * + * @param {string} outDir 已解析为绝对路径的构建输出目录 + */ +export function assertH5Root(outDir) { + const htmlPath = join(outDir, 'index.html'); + if (!existsSync(htmlPath)) { + throw new Error(`${htmlPath} 不存在,请先执行 vite build,并确认 build.outDir 指向构建输出目录`); + } + + const html = readFileSync(htmlPath, 'utf8'); + // 同时匹配单/双引号属性,且容忍 `=` 两侧空白;只看双引号会漏掉手写 HTML 或插件产物里的 + // `src='/app.js'`,让误配 base 的产物通过校验、上传后整站 404。 + const absoluteRefs = [...html.matchAll(/\s(?:src|href)\s*=\s*(["'])(\/[^"']*)\1/gi)].map( + (match) => match[2], + ); + if (absoluteRefs.length > 0) { + throw new Error( + `index.html 仍引用根路径资源(${absoluteRefs.join(', ')});` + + "请把 vite base 设为 './',否则 zip 解压到子路径后资源会 404", + ); + } +} + +/** 递归删除空目录;zip 里不留无意义目录条目。 */ +export function pruneEmptyDirs(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + // 用 lstatSync 而不是 statSync:后者会跟随符号链接,可能递归走出构建产物目录, + // 甚至对指向祖先的链接递归到栈溢出;符号链接一律跳过。 + if (lstatSync(path).isDirectory()) { + pruneEmptyDirs(path); + if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); + } + } +} + +/** + * dist 收尾插件:在 bundle 写盘后原地校验产物,让 pack 只需套文件夹、打 zip。 + * + * 用 `closeBundle`(最后一个构建钩子)而不是 `writeBundle`:publicDir 已复制完,且排在其它 + * 插件之后。`apply: 'build'` 保证 dev server 不触发。 + * + * 注意:rollup 在构建失败时**也会**调 `closeBundle`。所以用 `buildEnd(error)` 记下失败, + * 失败就直接返回——否则会用「index.html 不存在」盖掉真正的语法报错。 + * + * @returns {import('vite').Plugin} + */ +export function taonierArtifactPlugin() { + /** @type {string} */ + let outDir = ''; + let buildFailed = false; + + return { + name: ARTIFACT_PLUGIN_NAME, + apply: 'build', + enforce: 'post', + configResolved(config) { + // config.build.outDir 是相对 config.root 的路径,必须自己解析成绝对路径。 + outDir = resolve(config.root, config.build.outDir); + }, + buildEnd(error) { + buildFailed = Boolean(error); + }, + closeBundle() { + if (buildFailed) return; + assertH5Root(outDir); + pruneEmptyDirs(outDir); + }, + }; +} + +export const taonierBaseConfig = { + base: './', + build: { + outDir: TAONIER_OUT_DIR, + emptyOutDir: true, + sourcemap: false, + }, + plugins: [taonierArtifactPlugin()], +}; + +/** + * 合并项目配置,并保证收尾插件一定在 `plugins` 里。 + * + * `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方 + * 若先展开 `taonierBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后追加一个 + * 新实例:无论 overrides 怎么给,插件都在最后且只跑一次。 + * + * @param {import('vite').UserConfig} [overrides] + * @returns {import('vite').UserConfig} + */ +export function defineTaonierConfig(overrides = {}) { + const merged = mergeConfig(defineConfig(taonierBaseConfig), overrides); + // mergeConfig 会用 overrides 里的标量覆盖 build.outDir,而 pack.mjs 的 --vite-built-dir + // 默认值仍是 TAONIER_OUT_DIR;两边一旦不一致就会打包到陈旧/缺失的目录。这里在合并后 + // 重新钉死产物目录,保证构建输出与打包读取始终是同一个目录。 + merged.build = { ...(merged.build ?? {}), outDir: TAONIER_OUT_DIR }; + merged.plugins = [ + ...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME), + taonierArtifactPlugin(), + ]; + return merged; +} + +export default defineTaonierConfig(); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt new file mode 100644 index 000000000..359267b98 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/.selective_rule.txt @@ -0,0 +1,59 @@ +【取舍记录】minitool-zip-builder MUST-only 清理 + +原则 +- references/ 只保留 MUST(必须 / 须 / 禁止 / 不得),非 MUST 的建议、可选、工具用法全部删除。 +- 脚本能确定性判定的内容,不再在 skill 里重复,避免两处漂移。 + +已删除的文件 +- scripts/audit_artifact.mjs + - 原因:体积审计已折进 scripts/validate.mjs。 + - 影响:目录或 zip 的体积门禁改由 validate 检查;pack 打包后会自动复跑。 + +逐文件删除与去处 +- SKILL.md + - 步骤 9:删掉 tsc / Vite 已保证项(语法错误、未定义函数、加载顺序),改为指向 references/MANUAL_CHECKS.md。 + - 步骤 10:删掉 Node / Python / 人工分支,改为运行 validate.mjs 与 pack.mjs。 + - 步骤 3 与 Reference 表:删掉已不存在的「目录结构 / 路径规则 / 打包自检」描述。 + - 被外部精简重写为 vite-export-xhs-minitool,只剩 Reference 表,原步骤 9 / 10 已不存在;Reference 表指向「index.html 要求」。 +- zip-artifact-spec.md + - 删 §1 目录结构与打包方式:pack.mjs 保证 zip 根 index.html、不多套层。 + - 删 §4 路径与引用规则:base:'./' 与 pack.mjs 保证相对路径。 + - 删 §6 打包前自检:validate / pack / MANUAL_CHECKS 已覆盖。 + - 删 §5 表格里「资源全为相对路径」「脚本外置」两行(前者 Vite 保证,后者 §3 CSP 已声明)。 + - 删 §5 的「index.html 模板」整段 HTML 骨架与其中的 reset / 字体 CSS;标题改为「index.html 要求」,只留要求表与 说明。 + - 原因:模板是 pack 之后的 dist 形状,对 Vite 源 index.html 是错的(源需要 <script type="module"> 入口);reset / 字体 CSS 不是容器要求;doctype / charset / lang 由 validate 的 *_MISSING 兜。 + - 删 §5 表格里 viewport(width=device-width / initial-scale=1.0 / viewport-fit=cover)、不用 `<base href>`、不自建 CSP `<meta>` 三行。 + - 原因:validate 的 VIEWPORT_MISSING / VIEWPORT_WIDTH_MISSING / VIEWPORT_SCALE_MISSING / VIEWPORT_FIT_MISSING / BASE_URL / CSP_META 已确定性判定,不在 skill 里重复。 + - 保留 §2 文件类型、§3 容器 CSP、§5 的「不引用外部资源」与 `<title>` 说明。 +- device-capabilities.md + - 删 §6 能力扫描清单:validate 的禁用 API code 已全覆盖。 + - 保留 §1–§5(可用能力、禁用能力与替代、常见交互写法)。 +- js-compatibility.md + - 删 §1 的 ES2017 语法枚举与「避免 lookbehind」:语法转译由 Vite target 保证;lookbehind 已由 validate 报 REGEX_LOOKBEHIND。 + - 删 §2 的 Vite target 片段:已在 vite.config.xhs-minitool.mjs。 + - 删 §4 的日期解析 / Intl 布局余量建议,保留「不依赖对象遍历顺序」。 + - 删 §5 交付检查。 + - 保留 §3 Web API 能力检测、§4 跨内核 MUST。 +- css-compatibility.md + - 删 §5 的两条 Autoprefixer 行(可选工具用法)。 + - 删 §7 交付检查;§6 改为只保留「必须跟随可视高度时」的处理要求。 + - 保留 §1–§4、§6(基线层、Chrome 61 不可唯一实现表、行为检测写法、安全区)。 +- cross-platform-h5.md + - 删「优先 Pointer Events」的优先措辞;删「系统字体栈」建议;删 §5 表格的「建议」列。 + - 保留安全区组合、不写死宽度、图片自适应、§6 自检。 +- performance-budget.md + - 删 §1 的 Node / Python 选择命令与 audit 脚本调用;§1 只留 10 MiB 与单条 Base64 1 MiB 两条硬门禁,指向 validate。 + - 删 §1 的 2 MiB / 100 KiB / 5 MiB 建议阈值(仍在 validate 里作为 WARN,见下)。 + - 删 §6 交付检查;删「优先按顺序缩减」「优先 WebP」「优先短片段」「优先对象 URL」「优先 WebGL 1」「避免无意义绘制」「代码可按设备数据调整」等非 MUST。 + - 保留 §2 分页 / 虚拟化 / 防抖 / 分批、§3 preload+poster+释放 objectURL+不得长 Base64、§4 逐项关闭 / 禁止每帧回读 / visibilitychange / contextlost、§5 不按机型 + 必须兜底。 +- js-api.md + - 未删,全文保留(API 契约本身即硬约束)。文件中少量「建议」是 API 用法提示,未按 MUST-only 过滤。 + +仅保留在代码、未写回文档的非 MUST(如需调整改这里或改脚本) +- validate 的 WARN 阈值:zip > 2 MiB、单条 Base64 > 100 KiB、单个文本 > 2 MiB、文本合计 > 5 MiB。 +- validate 的检查码与禁用 API 模式表。 +- MANUAL_CHECKS.md 的 7 条脚本测不到项。 + +口径说明(避免后续误判为缺失) +- zip-artifact-spec.md 删除 §1 / §4 / §6 后,保留 §2 / §3 / §5,编号故意留空,未重排,避免打断跨文件引用。 +- device-capabilities.md 现止于 §5;performance-budget.md 现止于 §5;js-compatibility.md 现止于 §4;css-compatibility.md 现止于 §6。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md new file mode 100644 index 000000000..769fff1fc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/SKILL.md @@ -0,0 +1,52 @@ +--- +name: vite-export-xhs-minitool +description: >- + vite 项目导出适配小红书小工具的制品 +metadata: + version: "1.7.0" +--- + +# 目标: + +* 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip + 1. 复制 (并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范. + 2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项: + `references/manual-checks.md` + 3. 使用 `scripts/pack.mjs` 打包成zip; `--zip-out` 默认 **`../.export/xhs-minitool.zip`** (假设 cwd 是 `game/` 子工程, + 产物落在 **项目根** `.export/xhs-minitool.zip`; 相对 cwd 解析) + 4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本 + +* 不干扰正常的web构建 + +# 假设和默认项: + +* vite项目目录: game/ +* 各个工具 (包括示例vite配置) 默认cwd 就是 vite 项目根(所以对于非标准的目录结构需要给出显式的参数来适应) + +# 脚本参数: + +| 脚本 | 参数 | +|----------------------------------------|----------------------------------------------------------------------------------------------------------------| +| `scripts/vite.config.xhs-minitool.mjs` | `build.outDir`(默认 `dist-xhs-minitool`;与打包时的 `--vite-built-dir` 必须是同一个目录) | +| `scripts/validate.mjs` | `[project]`(默认 `dist-xhs-minitool`)、`--json` | +| `scripts/pack.mjs` | `--vite-built-dir <dir>`(默认 `dist-xhs-minitool`)、`--zip-out <path>`(默认 `../.export/xhs-minitool.zip`) | + +# 一些情况: + +* 打包大小限制, 需要精简游戏内容/删减资源/压缩素材 请和用户讨论 +* 如果使用了外部能力, 参考platform-abstract skill对项目先重构 +* 小红书的条件可能有变化, 请以用户反馈为准, 并调整本地的构建脚本 + +以下是参考文档;「何时读」命中时必须读: + +## Reference + +| 文档 | 何时读 | +|-------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| [js-api.md](references/js-api.md) | 本地 API 快照; | +| [zip-artifact-spec.md](references/zip-artifact-spec.md) | 写/改 `index.html`、选择文件类型或处理脚本外置 / 容器 CSP 时 | +| [device-capabilities.md](references/device-capabilities.md) | 处理端能力时:哪些 Web 能力可用 / 不可用及替代写法、如何实现常见交互(手势、拍照、选图等) | +| [js-compatibility.md](references/js-compatibility.md) | 写 JS / 选择构建产物时:Chrome 61 硬基线、不可用 API 与替代写法 | +| [css-compatibility.md](references/css-compatibility.md) | 写 CSS / 选择构建产物时:Chrome 61 硬基线、不支持的能力与替代写法 | +| [cross-platform-h5.md](references/cross-platform-h5.md) | 适配多端时:触摸、滚动、安全区、PC 模拟器与真机差异 | +| [performance-budget.md](references/performance-budget.md) | 包体、静态数据、Base64、媒体、长列表与 WebGL 资源控制和降级 | diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md new file mode 100644 index 000000000..e77a10f56 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md @@ -0,0 +1,68 @@ +# 跨端 H5 适配 + +> 小工具同一份 H5 同时跑在 PC 模拟器与真机 WebView。以下是保证两端一致体验的适配要点。 + +CSS 的最低语法与布局能力以 [css-compatibility.md](./css-compatibility.md) 为准;本文件只说明触摸、滚动、安全区和设备形态差异。 + +--- + +## 1. 触摸 + +```css +body { -webkit-touch-callout: none; } +.touchable:active { opacity: 0.7; } +html { touch-action: manipulation; } +``` + +交互用 Pointer Events(`pointerdown/move/up`)统一处理鼠标与触摸;纯触摸场景用 `touchstart/touchmove/touchend`。 + +--- + +## 2. 滚动 + +```css +.scroll-container { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; +} +``` + +纵向回弹由容器控制,HTML 无需额外配置。 + +--- + +## 3. 安全区 + +```css +.custom-nav { padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); } +.bottom-bar { padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); } +``` + +需配合 `<meta name="viewport" ... viewport-fit=cover>`。PC 模拟器不产生真实 `env()`,而是注入 `--safe-area-inset-*` 变量模拟安全区;真机 `env()` 为真实值。用 `var(--safe-area-inset-*, env(...))` 组合,两端都生效。 + +--- + +## 4. 布局与媒体 + +- 页面级容器用 `%` / `flex` / `vw`,勿写死 `width: 375px` +- 图片 `max-width: 100%` + +--- + +## 5. PC 模拟器 vs 真机 + +| 特性 | PC 模拟器 | 真机 | +|--------|-------------------------------------|----------------| +| 触摸 | 鼠标 → touch 模拟 | 原生 touch | +| 安全区 | 注入 `--safe-area-inset-*` 变量模拟 | `env()` 真实值 | +| 软键盘 | 无 | 遮挡输入框 | + +--- + +## 6. 自检 + +- [ ] 交互用 pointer / touch events,未依赖鼠标 hover 才能触发的关键操作 +- [ ] 布局自适应,无写死像素宽度 +- [ ] 安全区用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合,配合 `viewport-fit=cover` +- [ ] 图片自适应且体积受控 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md new file mode 100644 index 000000000..8b1e192ab --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md @@ -0,0 +1,57 @@ +# CSS 兼容性规范 + +> 目标内核固定为 **Android 8.1 出场 Chrome / WebView 61**。晚于 61 的 CSS 能力一律不得出现在最终产物里:浏览器会静默忽略,`validate.mjs` 判为 `CSS_UNSUPPORTED_FEATURE`(ERROR)。 + +## 1. 硬性要求 + +- 最终 zip 内的 CSS 必须能被 Chrome 61 完整解析;不得保留任何晚于 61 的选择器、声明或 at-rule。 +- 浏览器静默丢弃无法解析的 CSS,不像 JS 会抛异常;兼容性以最终产物为准,源码用了构建工具不代表产物已兼容。 +- 不用 UA、Android 版本或机型字符串决定样式能力。 + +## 2. Chrome 61 不支持的能力与替代写法 + +| 能力 | 必须改用的写法 | +|-------------------------------------------------------------|---------------------------------------------------------------------------------------| +| Flex `gap` / `row-gap` / `column-gap` | Flex 用子项单边 `margin`;Grid 用 `grid-gap` | +| `aspect-ratio` | 固定媒体尺寸,或用百分比 `padding-top` 比例盒;内容绝对定位 | +| `min()` / `max()` / `clamp()` | 固定值、百分比或 `calc()`;需要分档时用媒体查询 | +| `inset`、`margin-inline`、`padding-block` 等逻辑属性 / 简写 | `top/right/bottom/left` 与 `margin-left/right`、`padding-top/bottom` 等物理属性 | +| `overflow: clip` | `overflow: hidden` | +| `:focus-visible` | `:focus` | +| `:has()` | 由 JS 在父元素上切换状态 class | +| `@container` | viewport 媒体查询,或由 JS 按容器尺寸切换 class | +| `subgrid` | 普通 Grid、Flex 或显式轨道尺寸 | +| `@layer` / `@property` | 只允许构建期展开;最终产物不得保留 | +| `dvh` / `svh` / `lvh` | `%` / `100vh`;受软键盘影响的全屏高度用 JS 维护 CSS 变量并保留 `100vh` 兜底 | +| `color-mix()`、`oklab()`、`oklch()` 等现代颜色 | `#hex`、`rgb()`、`rgba()` 或 `hsl()` | +| `backdrop-filter` | 先给不依赖模糊的实色 / 半透明背景 | +| `text-wrap: balance` 等现代排版属性 | 保留普通换行;不要让其决定关键区域高度 | + +## 3. 布局与前缀 + +Chrome 61 可使用 Flexbox、基础 Grid、媒体查询、CSS Variables、`calc()`、transform、transition 和 animation。仍须处理以下跨端差异: + +- Flex 子项内有长文本、图片或滚动区域时,按方向显式设置 `min-width: 0` 或 `min-height: 0`,避免内容撑破容器。 +- Flex 间距用子项单边 `margin`;Grid 间距用 `grid-gap`。两者都不要用裸 `gap`。 +- 需要隐藏文本时使用 `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`。多行截断使用 `display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: <行数>; overflow: hidden`,并保证截断失效时页面仍可用。 +- `user-select`、`appearance`、文字截断和毛玻璃等 WebKit 相关能力按需同时写 `-webkit-` 前缀与标准声明。不要机械给所有属性加前缀。 +- 关键操作不能只在 `:hover` 出现;触摸端默认可见,鼠标悬停效果可放 `@media (hover: hover)`。 + +## 4. 视口与安全区 + +- 页面宽度使用 `%`、Flex 或基础 Grid,不写死 `375px` 等单一机型宽度。 +- `100vh` 在移动端地址栏、容器高度变化和软键盘出现时可能不等于可视高度。必须跟随可视高度时,由 JS 监听尺寸变化并维护 `--app-height`,同时保留 `100vh` 回退。 +- 安全区规则须配合 `viewport-fit=cover`,用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合;具体见 [cross-platform-h5.md](./cross-platform-h5.md)。 +- 现代桌面浏览器或 PC 模拟器通过不等于 Chrome 61 通过。能够运行旧内核时,至少检查首屏、滚动区、弹层、表单、横竖屏 / 尺寸变化和核心交互;无法运行时在交付说明中标记“Chrome 61 CSS 兼容性未实测”。 + +## 5. 构建链目标 + +项目已有构建链时,浏览器目标至少设置为: + +```text +Chrome >= 61 +ios_saf >= 18.4 +``` + +- 压缩器也须使用相同浏览器目标,避免把兼容写法重新合并成 Chrome 61 无法解析的现代语法。 +- 交付前检查构建后的 CSS;zip 中只保留最终静态产物,不带 source map 和构建配置。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md new file mode 100644 index 000000000..8b1882f31 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md @@ -0,0 +1,109 @@ +# 小工具能力清单 + +> 小工具运行在受限容器中:**纯本地、不联网**,把它当作一个能力受限的浏览器页面。 +> **以本文为基线**:命中「不可用」项必须移除或改用替代写法。 +> +> **实现优先级**:先获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc)并查找匹配的容器能力;文档明确支持时必须优先使用,仅在没有匹配能力或当前环境不满足文档条件时才采用兼容的 Web 方案。 + +## 目录 + +- §1 可用能力 +- §2 不可用能力(Web API) +- §3 不可用行为 +- §4 WebGL / 图形计算边界 +- §5 常见交互怎么实现 + +--- + +## 1. 可用能力 + +### 页面与渲染 + +标准 HTML / CSS / JS 可用,但最终产物须满足目标内核基线:JS 见 [js-compatibility.md](./js-compatibility.md),CSS 见 [css-compatibility.md](./css-compatibility.md)。可使用基线内的 Flexbox / Grid / 动画 / 媒体查询、Canvas 2D(`getContext('2d')`)、WebGL(`getContext('webgl'/'webgl2')`,能力边界见 §4、性能与低端机降级见 [performance-budget.md](./performance-budget.md) §4–5),文本选择不限制。 + +### 媒体与文件 + +| 能力 | 用法 | 约束 | +|-----------------|--------------------------------------------------------|----------------------------------------------------------------| +| 摄像头 | `navigator.mediaDevices.getUserMedia({ video: true })` | 用户手势触发 + 系统弹窗授权 | +| 麦克风 | `navigator.mediaDevices.getUserMedia({ audio: true })` | 用户手势触发 + 系统弹窗授权 | +| 选择图片 / 拍照 | `<input type="file">` | 系统选择器接管,**仅能选图片和视频**(无论 `accept` 如何设置) | +| 音视频播放 | `<video>` / `<audio>` | 内联播放,媒体文件须打包在内 | + +### 数据存储 + +数据存储方案以[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc)的当前规则为准。数据按小工具隔离,不保证永久持久化。 + +### 交互 + +`alert()` / `confirm()` 可用,以原生 UI 展示。 + +--- + +## 2. 不可用能力(Web API) + +以下 API 已禁用,调用会抛异常、返回空值或被拦截,必须移除或改用替代写法。 + +| 分类 | 涉及 API | 替代方案 | +|----------|--------------------------------------------------------------------------------------------------------------------------|------------------------------------------------| +| 定位 | `navigator.geolocation.getCurrentPosition` / `watchPosition` | 移除 | +| 剪贴板 | `navigator.clipboard.readText` / `writeText`、`document.execCommand('copy'/'cut'/'paste')` | 展示可选中文本,引导用户长按 / 选中手动复制 | +| 硬件连接 | `navigator.bluetooth` / `navigator.usb` / `navigator.hid` / `navigator.serial` | 移除 | +| 传感器 | `new Accelerometer()` / `new Gyroscope()` / `new Magnetometer()`、环境光、`DeviceMotionEvent` / `DeviceOrientationEvent` | 改用触摸 / 指针手势(见 §5),摇一摇类移除 | +| 实时通信 | `new WebSocket()`、`new EventSource()`、`new RTCPeerConnection()` | 移除(不联网,无轮询替代) | +| 后台运行 | Web Worker、SharedWorker、Service Worker(`navigator.serviceWorker.register`) | 移除,逻辑放主线程 | +| 屏幕 | `getDisplayMedia`(屏幕共享)、`Element.requestFullscreen`(全屏由容器统一管理) | 全屏用 CSS 沉浸式布局实现视觉全屏 | +| 设备信息 | `navigator.getBattery`、`navigator.connection`、`navigator.mediaDevices.enumerateDevices` | 移除 | +| 存储进阶 | `navigator.storage.persist`(持久化)、跨域存储访问 | 移除;获取在线文档并按当前能力替代关系选择方案 | +| 凭据 | `navigator.credentials.get` / `create`(WebAuthn)、`navigator.locks` | 移除 | +| 窗口 | `window.open`(弹新窗口)、`window.prompt` | 单页内 JS 切换视图 DOM;输入用页内 Modal | + +移动端 WebView 本身也不支持:支付 `PaymentRequest`、系统通知 / 推送、NFC、MIDI、XR / AR / VR、后台同步 / 下载、PWA 安装、窗口管理、指针 / 键盘锁定。一律移除。 + +--- + +## 3. 不可用行为 + +| 行为 | 说明 | 替代方案 | +|-------------------|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| 网络请求 | `fetch` / `XMLHttpRequest`、加载外部图片 / 字体 / 媒体等一切联网请求 | 所有资源打包在内,改本地相对引用;仅小型配置 / 数据可随包提供,大型只读数据集不适合小工具,见 [performance-budget.md](./performance-budget.md) §2 | +| 动态执行代码 | `eval()`、`new Function()` | 改写为静态逻辑 | +| WebAssembly | WASM 编译执行(依赖 WASM 的库无法运行) | 移除或改用纯 JS 实现 | +| iframe / object | 内嵌 iframe / object,或被外部页面嵌入 | 内容直接写进页面 | +| 表单跳转提交 | `<form>` 提交跳转 | `addEventListener('submit', e => e.preventDefault())` 后用 JS 处理 | +| 文件下载 | `a[download]`、blob 下载 | 移除 | +| 打开外链 / 新窗口 | `target="_blank"`、`window.open`、跳转站外 URL | 单页内 JS 切换视图 DOM | +| 跳转其他小工具 | 小工具间互相跳转 | 移除 | +| 长按菜单 | 系统长按菜单已禁用 | 用自定义交互替代 | +| 插件 | Flash 等浏览器插件 | 移除 | + +--- + +## 4. WebGL / 图形计算边界 + +纯 WebGL 渲染可用,组合能力受限: + +| 场景 | 是否可用 | +|---------------------------------------------------------|---------------------------| +| 包内资源 / Canvas / 内存对象作为纹理 | ✅ | +| 外部域名图片作为纹理 | 🔴 不联网,纹理须打包在内 | +| 依赖 WASM 的加速库(Draco / Basis / ONNX / 抠图算法等) | 🔴 | +| 依赖 Worker 的离屏渲染(OffscreenCanvas + Worker) | 🔴 | +| SharedArrayBuffer 多线程 | 🔴 | + +WebGL 适合用包内资源做本地渲染;AI 图像处理等重计算(需联网或 WASM 模型)无法支持。WebGL 可用不等于低端真机性能足够:DPR、像素、纹理、draw call、几何预算、动态降档与兜底必须遵守 [performance-budget.md](./performance-budget.md) §4–5。 + +--- + +## 5. 常见交互怎么实现 + +| 需求 | 实现 | +|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| 容器提供的功能 | 获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc),优先使用其中匹配的能力;仅在无匹配能力或不满足文档条件时采用兼容的 Web 方案 | +| 手势 / 拖拽 / 滑动 | `addEventListener('touchstart'/'touchmove'/'touchend')` 或 Pointer Events(`pointerdown`/`move`/`up`) | +| 拍照 / 录音 | `getUserMedia(...)`,由按钮点击等用户手势触发 + 授权 | +| 选择图片 / 视频 | `<input type="file">` | +| 复制文本 | 展示可选中文本,引导用户长按 / 选中复制 | +| 视觉全屏 | CSS 布局(`100vh` / flex + 隐藏滚动) | +| 页面跳转 | 单页内用 JS 切换视图 DOM | +| 输入弹窗 | 页内 Modal 组件 | diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-api.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-api.md new file mode 100644 index 000000000..61cda4744 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-api.md @@ -0,0 +1,310 @@ +# 小工具 JS API 本地参考 + +> 本文是 2026-09-23 的本地快照,按 `docs/index.html` 的 JS API 章节复刻,保留参数表、约束与示例。实现时应优先获取[小工具在线文档](https://miniapp-sandbox.xiaohongshu.com/minitool/doc);仅当远程文档无法获取时,才以本文作为 API 契约参考。 + +容器自动注入 `window.xhs`,无需在包内引入 SDK。端能力从 `window.xhs.miniTool` 调用。 + +## 调用约定 + +- 传入 `success`、`fail`、`complete` 任一回调时,API 返回 `undefined`;均不传时返回 Promise。 +- 成功结果包含 `errMsg: "<api>:ok"` 与对应业务字段;失败结果包含 `errMsg: "<api>:fail ..."` 与可选 `errCode`。 +- 调用前检查 `window.xhs`、`window.xhs.miniTool` 和具体方法是否存在,并为低版本或未注入环境提供降级处理。 +- 只调用本文列出的 API,不直接调用原生 bridge。 + +```js +const miniTool = window.xhs && window.xhs.miniTool; + +if (miniTool && typeof miniTool.saveImageToPhotosAlbum === "function") { + try { + await miniTool.saveImageToPhotosAlbum({ filePath }); + } catch (error) { + console.log(error.errMsg, error.errCode); + } +} +``` + +## API 一览 + +| API | 用途 | 最低客户端版本 | +|---------------------------------------------------------------------------------------------------------------------------|--------------------------------|----------------| +| `postNote` | 打开笔记发布页并携带内容和媒体 | — | +| `saveImageToPhotosAlbum` | 保存图片至系统相册 | — | +| `writeTempFile` | 将 base64 写入临时文件 | — | +| `getLaunchOptions` / `window.xhs.launchOptions` | 获取启动参数和环境信息 | — | +| `setStorage` / `getStorage` / `getStorageInfo` / `removeStorage` / `clearStorage` | 小工具本地缓存 | 9.46 | +| `saveFile` / `writeFile` / `appendFile` / `readFile` / `readDir` / `statFile` / `unlink` / `mkdir` / `getFileStorageInfo` | 本地文件系统 | 9.49 | +| `interactionOpenApi` | 唤起评论区并携带评论草稿 | 9.49 | + +图片、视频与封面等媒体字段只接受 `data:` base64 或本地文件路径;容器不联网,网络地址不可用。体积较大的 base64 建议先用 `writeTempFile` 换成 `filePath` 再传递。 + +## 启动参数与版本判断 + +同步读取启动参数: + +```js +const launchOptions = window.xhs && window.xhs.launchOptions; +const userDataPath = launchOptions && launchOptions.miniToolEnv && launchOptions.miniToolEnv.userDataPath; +``` + +同步值不可用时,检查 `getLaunchOptions` 存在后异步读取: + +```js +const miniTool = window.xhs && window.xhs.miniTool; +const launchOptions = await miniTool.getLaunchOptions(); +const { userDataPath } = launchOptions.miniToolEnv; +``` + +`miniToolEnv.userDataPath` 是持久文件目录根路径。仅在它之后拼接相对路径;不要硬编码、解析或改写端上返回的文件句柄。 + +`miniToolEnv.buildVersion` 的末三位为编译序号,判断客户端版本时忽略。例如 `9462004` 代表客户端 `9.46.2`,用于版本比较的值为 `9462`: + +```js +function getClientVersion(buildVersion) { + return Math.floor((Number(buildVersion) || 0) / 1000); +} + +function isClientVersionAtLeast(buildVersion, minimumClientVersion) { + return getClientVersion(buildVersion) >= minimumClientVersion; +} +``` + +完整的同步优先、异步回退读取方式: + +```js +function readBuildVersion(launchOptions) { + const miniToolEnv = launchOptions && launchOptions.miniToolEnv; + return Number(miniToolEnv && miniToolEnv.buildVersion) || 0; +} + +async function getBuildVersion() { + const xhs = window.xhs; + const syncBuildVersion = readBuildVersion(xhs && xhs.launchOptions); + if (syncBuildVersion) return syncBuildVersion; + + const miniTool = xhs && xhs.miniTool; + if (!miniTool || typeof miniTool.getLaunchOptions !== "function") return 0; + + try { + return readBuildVersion(await miniTool.getLaunchOptions()); + } catch (error) { + return 0; + } +} +``` + +## postNote + +打开笔记发布页。`mediaInfo` 必传,`image_resources`、`video_resources`、`live_photo_sources` 至少提供一种。 + +| 字段 | 类型 | 说明 | +|--------------------------------|-----------------------------|------------------------------------------------------------| +| `title` | string | 标题,最长 20 字 | +| `content` | string | 正文,最长 1000 字 | +| `pageType` | string | `video_publish`、`photo_publish` 或 `slides_edit`(9.43+) | +| `mediaInfo.image_resources` | `{ url }[]` | 图片,1–18 张;`url` 为 data URI 或本地路径 | +| `mediaInfo.video_resources` | `{ video_url, cover_url? }` | 单个视频及可选封面 | +| `mediaInfo.live_photo_sources` | `{ url, video_url }[]` | 实况照片,1–18 组(9.43+) | + +```js +await window.xhs.miniTool.postNote({ + title: "我的作品", + content: "用小工具生成的", + pageType: "photo_publish", + mediaInfo: { + image_resources: [{ url: "data:image/png;base64,..." }], + }, +}); +``` + +```js +// 视频笔记 +await window.xhs.miniTool.postNote({ + pageType: "video_publish", + mediaInfo: { + video_resources: { video_url: videoPath, cover_url: coverPath }, + }, +}); + +// 实况笔记(客户端 9.43+) +await window.xhs.miniTool.postNote({ + pageType: "slides_edit", + mediaInfo: { + live_photo_sources: [{ url: coverPath, video_url: videoPath }], + }, +}); +``` + +成功回调只表示发布页已被唤起并由用户点击发布,不代表笔记最终审核通过;不要据此做强一致业务状态。 + +## saveImageToPhotosAlbum + +| 字段 | 类型 | 必填 | 说明 | +|------------|--------|------|--------------------------------------------------------------------------------------| +| `filePath` | string | 是 | 本地图片:`data:` base64 或 `writeTempFile` 返回的路径;不支持 `http(s)://` 网络地址 | + +```js +const dataUrl = canvas.toDataURL("image/png"); +await window.xhs.miniTool.saveImageToPhotosAlbum({ filePath: dataUrl }); +``` + +应由用户点击等主动操作触发;首次调用可能请求相册权限。大图建议先通过 `writeTempFile` 落成文件,再保存至相册。 + +## writeTempFile + +将 Canvas 或选图结果等 base64 数据写为临时文件: + +```js +const { filePath } = await window.xhs.miniTool.writeTempFile({ + data: canvas.toDataURL("image/png"), +}); + +await window.xhs.miniTool.saveImageToPhotosAlbum({ filePath }); +await window.xhs.miniTool.postNote({ + mediaInfo: { image_resources: [{ url: filePath }] }, +}); +``` + +| 字段 | 类型 | 说明 | +|------------|--------|---------------------------------------------------| +| `data` | string | 必填,base64 数据;支持带 `data:` 前缀的 data URI | +| `filePath` | string | 成功返回的临时文件路径 | + +临时文件应即用即弃,不可作为长期持久化路径。支持常见图片与视频类型:png、jpeg、webp、gif、mp4。 + +## Storage 本地缓存 + +Storage API 在客户端 9.46+ 可用。`data` 只支持 JSON 字符串;对象或数组需要先序列化,读取后再解析。 + +| API | 参数 | 结果 / 说明 | +|------------------|----------------------------------------------------|--------------------------------------------------| +| `setStorage` | `key: string`、`data: string`、`encrypt?: boolean` | 写入或覆盖缓存 | +| `getStorage` | `key: string`、`encrypt?: boolean` | 返回 `{ data }`;`encrypt` 与写入时一致 | +| `getStorageInfo` | 无业务参数 | 返回 `{ keys, currentSize, limitSize }`,单位 KB | +| `removeStorage` | `key: string` | 删除指定缓存 | +| `clearStorage` | 无业务参数 | 清空当前小工具缓存 | + +单个 key 最大 1MB,当前小工具总缓存最大 10MB;`encrypt` 默认 `false`。 + +以下封装会在 9.46+ 使用 Storage,并在低版本回退到浏览器存储;调用方必须处理其返回的 `false`,不能假设数据已成功持久化: + +```js +const STORAGE_MIN_CLIENT_VERSION = 9460; + +async function setLocalData(key, data) { + let serializedData; + try { + serializedData = JSON.stringify(data); + } catch (error) { + return false; + } + if (typeof serializedData !== "string") return false; + + const buildVersion = await getBuildVersion(); + const miniTool = window.xhs && window.xhs.miniTool; + + if ( + isClientVersionAtLeast(buildVersion, STORAGE_MIN_CLIENT_VERSION) && + miniTool && + typeof miniTool.setStorage === "function" + ) { + try { + await miniTool.setStorage({ key, data: serializedData }); + return true; + } catch (error) { + return false; + } + } + + try { + localStorage.setItem(key, serializedData); + return true; + } catch (error) { + return false; + } +} +``` + +```js +await window.xhs.miniTool.setStorage({ + key: "profile", + data: JSON.stringify({ nickname: "小红薯" }), +}); + +const { data } = await window.xhs.miniTool.getStorage({ key: "profile" }); +const profile = data === null ? null : JSON.parse(data); +``` + +低版本可以按需降级到浏览器存储,但必须处理读写失败,并容忍数据丢失或被清理。 + +## 文件系统 + +文件系统 API 在客户端 9.49+ 可用。文件和二进制数据使用文件系统保存,目录根路径来自 `miniToolEnv.userDataPath`。 + +| API | 参数 | 结果 / 说明 | +| --- | --- | --- | +| `saveFile` | `tempFilePath: string`、`filePath?: string \| null` | 将临时文件移动至持久目录,返回 `{ savedFilePath }` | +| `writeFile` | `filePath`、`data`、`encoding: "utf8" \| "base64"` | 覆盖写入,返回 `{ writtenBytes }` | +| `appendFile` | `filePath`、`data`、`encoding: "utf8" \| "base64"` | 追加写入,返回 `{ writtenBytes }` | +| `readFile` | `filePath`、`encoding`、`position?`、`length?` | 返回 `{ data, bytesRead, eof }` | +| `readDir` | `dirPath` | 返回 `{ files, truncated }` | +| `statFile` | `filePath` | 返回 `{ size, lastModified, isDir }` | +| `unlink` | `filePath` | 删除持久目录中的文件 | +| `mkdir` | `dirPath`、`recursive?` | 创建持久目录 | +| `getFileStorageInfo` | 无业务参数 | 返回 `{ usedBytes, limitBytes, fileCount, tmpUsedBytes, writeChunkMaxBytes, readChunkMaxBytes }` | + +```js +const options = await window.xhs.miniTool.getLaunchOptions(); +const filePath = options.miniToolEnv.userDataPath + "/drafts/note.json"; + +await window.xhs.miniTool.writeFile({ + filePath, + data: JSON.stringify({ title: "草稿" }), + encoding: "utf8", +}); + +const { data } = await window.xhs.miniTool.readFile({ + filePath, + encoding: "utf8", +}); +``` + +- `writeFile` 为覆盖写,`appendFile` 为追加写。写大文件时,第一片使用 `writeFile`,后续片串行使用 `appendFile`。 +- 分片大小以 `getFileStorageInfo` 返回的 `writeChunkMaxBytes` 和 `readChunkMaxBytes` 为准,不要硬编码。 +- 渲染图片或视频时直接使用文件句柄作为 `img.src`、`video.src` 或 CSS 资源;需要字节时才使用 `readFile`。 +- `usr` 是本地工作区,不是备份空间。卸载、清数据或包清理后可能丢失,重要数据应可重建或由用户导出。 + +## interactionOpenApi 发布评论 + +评论区能力在客户端 9.49+ 可用,应由用户点击等主动操作触发。调用后容器会统一关闭小工具,再拉起评论区。 + +| 字段 | 类型 | 说明 | +|--------------------------------|---------|------------------------------------------------------| +| `payload` | object | 必填,评论草稿;字段由评论侧定义 | +| `payload.action` | string | 发布评论时使用 `post_comment` | +| `payload.content` | string | 可选,评论文本 | +| `payload.media_bean` | array | 可选,有序图片列表;媒体路径使用本地文件句柄 | +| `payload.miniToolSnapshotInfo` | string | 可选,小工具附加状态 JSON 字符串;最大 2KB,超限无效 | +| `saveToAlbum` | boolean | 可选,是否将图片同步保存到相册;默认 `true` | + +```js +const result = await window.xhs.miniTool.interactionOpenApi({ + payload: { + action: "post_comment", + content: "快来和我 PK!", + media_bean: [{ + media_type: "image", + cover_image_url: imageFilePath, + }], + miniToolSnapshotInfo: JSON.stringify({ page: "result" }), + }, + saveToAlbum: true, +}); + +// { routed, savedToAlbum, albumFailReason? } +``` + +- 评论文本和图片均可不传,仍可唤起评论区。 +- 目前仅支持图片:`{ media_type: "image", cover_image_url }`;不支持视频或实况图。 +- 媒体路径必须是容器可访问的本地文件句柄;不支持网络 URL、`data:` URI 或绝对路径。 +- 用户从评论区重新打开小工具时,可读取有效的 `miniToolSnapshotInfo` 以恢复业务状态;恢复逻辑由开发者实现。 +- `routed` 表示评论侧路由是否成功。相册保存失败时,原因通过可选字段 `albumFailReason` 返回。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md new file mode 100644 index 000000000..e1173f30d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md @@ -0,0 +1,42 @@ +# JavaScript 兼容性规范 + +> 目标内核固定为 **Android 8.1 出场 Chrome / WebView 61**。最终代码必须能在 Chrome 61 解析和运行;晚于 Chrome 61 的语法与运行时 API 一律不得出现在最终产物里,`validate.mjs` 判为 ERROR。Chrome 61 完整支持 ES2017,最终代码以 ES2017 为构建目标。 + +## 1. 语法基线 + +最终 zip 中的 JS 须兼容 Chrome 61: + +- ES2018+ 语法须由构建链转译,例如对象 spread、异步迭代、可选链、空值合并、逻辑赋值、class 私有字段、static block、BigInt 字面量和 top-level await;晚于 ES2017 的运行时 API 须改用基线写法。 + +语法不兼容会在脚本解析阶段直接失败,无法通过运行时 `if` 兜底。 + +## 2. 有构建链与无构建链 + +### 直接交付静态三件套 + +没有现成构建链时,不为兼容性临时引入 Babel、core-js 或新的 npm 依赖;直接按 ES2017 编写。 + +### 项目已有构建链 + +构建目标须为 ES2017 / Chrome 61,由 `vite.config.xhs-minitool.mjs` 固化。 + +最终产物须: + +- 转译到 ES2017 / Chrome 61; +- 只把构建后的静态文件放进 zip,不带 `node_modules`、source map 或构建配置。 + +转译只解决语法,不会自动补齐所有运行时 API。不要因为构建成功就假定新 API 可用。 + +## 3. 运行时 API + +- ES2017 内置 API 和基础 DOM API 可直接使用。 +- `String.prototype.replaceAll`、`Array.prototype.at`、`Object.hasOwn`、`structuredClone` 等更新 API 晚于 Chrome 61,最终产物不得直接使用(`validate.mjs` 的 `MODERN_RUNTIME_API` 会拦下);用 `split()/join()`、下标、`Object.prototype.hasOwnProperty.call`、手写深拷贝等基线写法替代。 +- 其余非基础 Web API 使用前做能力检测;不可用时降级为简单替代实现或给出清晰提示。 +- 只补功能实际需要的小型本地 fallback,不引入整套通用 polyfill;所有代码仍须随包离线交付。 +- 能力检测基于对象 / 方法是否存在,不按 UA、机型或系统版本字符串分支。 +- 不为被容器明确禁止的能力添加 polyfill;能力边界仍以 [device-capabilities.md](./device-capabilities.md) 为准。 + +## 4. 跨内核行为 + +- 不依赖对象遍历顺序表达业务优先级;需要顺序时使用数组。 +- 触摸、滚动和安全区规则见 [cross-platform-h5.md](./cross-platform-h5.md)。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md new file mode 100644 index 000000000..c3e3c9eed --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md @@ -0,0 +1,14 @@ +## 端能力 +- 相机 / 麦克风由用户手势触发,并处理系统弹窗授权。 + +## JS 兼容 +- 除 `replaceAll` / `.at` / `Object.hasOwn` / `structuredClone` 这类晚于 Chrome 61 的 API(须用基线写法替代)外,使用非基础 Web API 前先做能力检测;不可用时降级为简单替代实现或给出清晰提示;不按 UA、机型或系统版本字符串分支。 + +## CSS 兼容 +- 最终产物只使用 Chrome 61 可解析的 CSS;`validate.mjs` 已拦下 gap、`min()/max()/clamp()`、逻辑属性、`:focus-visible`、`:has()`、`@container`、`dvh` 等,额外人工确认构建后没有残留。 +- Flex 间距用子项 `margin`,Grid 用 `grid-gap`;不使用 flex `gap`。 +- Flex 子项内有长文本、图片或滚动区域时,按方向显式设置 `min-width: 0` 或 `min-height: 0`。 +- 关键操作不能只在 `:hover` 出现,触摸端默认可见;安全区用 `var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` 组合,配合 `viewport-fit=cover`。 + +## 性能 +- WebGL 有动态降档、页面隐藏暂停、context lost 处理和非 WebGL 兜底。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md new file mode 100644 index 000000000..f514fb557 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md @@ -0,0 +1,98 @@ +# 小工具性能预算与降级规范 + +> 本文提供移动端 WebView 的设计预算与降级要求。Skill 只能检查静态产物和代码设计,不能从源码推断真实帧率、内存或真机表现;没有运行数据时必须明确标记“未实测”。 + +## 目录 + +- §1 交付门禁 +- §2 静态数据与长列表 +- §3 图片、音频与视频 +- §4 WebGL 资源与降级基线 +- §5 运行时降级与兜底 + +--- + +## 1. 交付门禁 + +| 项目 | 门禁 | 原因 | +| --- | --- | --- | +| 最终 zip | **不超过 10 MiB** | 10 MiB 是上传上限,不是性能目标 | +| 单条 Base64 | 解码后不超过 1 MiB;超过 1 MiB 必须改成独立包内文件 | Base64 体积约增加 1/3,还会产生字符串与解码副本 | + +单条 Base64 由 [validate.mjs](../scripts/validate.mjs) 在产物目录检查;最终 zip 包体由 [pack.mjs](../scripts/pack.mjs) 在打包后检查。 + +## 2. 静态数据与长列表 + +`.js` / `.json` 是静态资源,不是数据库。**不要把数万条记录、完整业务库、日志或抓取结果生成成 JS 数组。** JSON 与 JS 分文件只能改善组织,不能消除下载、解析和内存成本。 + +按以下顺序缩减: + +1. 只保留完成核心功能必需的字段和记录,删除重复字段、长描述和历史快照。 +2. 能预计算的统计、搜索索引和分类结果在构建期生成;不要在首屏对全量数据反复遍历。 +3. 大型只读数据集改为摘要、分段样例或让用户通过 `<input type="file">` 按需导入。若完整离线数据不可删且仍超门禁,应明确说明该需求不适合小工具,而不是继续打包。 +4. 用户产生或导入的数据需要持久化时,根据任务开始时从在线文档整理的端能力、最低版本和替代关系选择方案;没有适用于目标客户端的端能力时使用浏览器本地存储。运行期存储不得用于掩盖巨大的内置种子数据。 + +渲染列表时: + +- 首屏只创建可见项;长列表使用分页或虚拟滚动,不一次性拼接整份 `innerHTML`。 +- 搜索输入按交互频率和数据规模采用防抖,避免每次按键都触发完整查询;最终输入应及时执行。数据量较大时可预先建立小型索引,避免反复扫描所有字段。 +- 非首屏工作分批执行并主动让出主线程,避免把解析、计算和渲染集中在同一个同步流程中。 +- 不在循环中反复读写布局属性;批量生成 DOM 后一次挂载。 + +--- + +## 3. 图片、音频与视频 + +- 小型图片、图标等可以使用 Base64;超过 1 MiB 不允许内嵌。 +- 图片按真机展示尺寸缩放并压缩;不要为了 300 px 展示区域打包 4K 原图。 +- 音视频先裁剪时长,再降低分辨率、帧率和码率。5 分钟视频通常不适合随小工具离线交付。 +- 视频设置 `preload="metadata"` 或 `preload="none"`,提供 `poster`;未进入播放页前不要创建或解码媒体。 +- 同时只保留必要的媒体实例;离开页面后暂停播放、清空不再使用的 `src` 并释放对象 URL。 + +`FileReader.readAsDataURL()` 可用于用户刚选择的小文件预览或在线文档明确要求 data URI 的短暂转换;大文件不要把结果持久写回静态源码,并在不用时 `URL.revokeObjectURL()`。注意:即使体积很小,`<video>` / `<audio>` 的 `data:` 媒体源仍不受容器 CSP 支持,应引用包内媒体文件。 + +--- + +## 4. WebGL 资源与降级基线 + +WebGL 是可用能力,不代表所有设备都能稳定运行复杂场景。以下数值是生成代码时采用的保守设计预算,不是 Skill 对实际性能的测量结果。目标体验按 **30 FPS 可交互**设计,60 FPS 仅作为有运行数据支持时的高档增强。 + +普通 Canvas 2D 不纳入本节的 GPU 分档要求;按实际展示尺寸创建画布。只有使用 WebGL 上下文时才执行以下资源预算、降档和 context 兜底。 + +### 初始预算 + +| 指标 | 默认档 | 低档 / 降级档 | +| --- | --- | --- | +| WebGL drawing buffer DPR | `min(devicePixelRatio, 1.5)` | `1` | +| WebGL drawing buffer 像素数 | 不超过约 200 万 | 不超过约 100 万 | +| 单张纹理边长 | 不超过 2048 | 不超过 1024 | +| 估算纹理显存 | 不超过 64 MiB | 不超过 32 MiB | +| 每帧 draw call | 不超过 100 | 不超过 50 | +| 每帧三角形 | 不超过 100k | 不超过 50k | +| 帧率目标 | 稳定 30 FPS,设备有余量再升档 | 稳定 24–30 FPS | + +这些是移动 WebView 的保守初始预算;没有实测数据时保持预算与降级路径,不得声称已经达到某个帧率或通过真机性能验收。 + +### 渲染规则 + +- 兼容 WebGL 1;使用 WebGL 2 特性时必须检测能力并提供 WebGL 1 或非 WebGL 兜底。 +- 初始化先使用低 / 中档,不按高 DPR 直接创建最大缓冲区。尺寸变化时重新计算并继续受像素预算约束。 +- 纹理使用实际需要的尺寸;复用纹理、材质、几何体和 framebuffer。估算 RGBA8 纹理最低占用:`宽 × 高 × 4`,mipmap 还会额外增加约 1/3。 +- 合并可合并的几何与 draw call,视锥 / 距离裁剪不可见对象;粒子、阴影、后处理、透明叠加和实时反射必须能逐项关闭。 +- shader 在初始化或切换场景时编译;纹理上传和模型解析分批进行,不在动画帧中首次集中完成。 +- 不在动画循环内创建对象 / 数组 / 大字符串;禁止每帧 `readPixels()`、`toDataURL()`、大面积 `getImageData()` 或同步回读 GPU。 +- 页面不可见时通过 `visibilitychange` 停止 `requestAnimationFrame`、媒体和定时器;恢复后重置时间差,不补算大量帧。 +- 处理 `webglcontextlost` / `webglcontextrestored`;context 丢失时停止渲染并展示轻量状态,不要无限重建。 + +--- + +## 5. 运行时降级与兜底 + +不要依赖机型名单。以实际帧耗时和能力检测决定档位: + +1. 首次进入采用低 / 中档,逐步加载非必要效果。 +2. 运行时观测到持续掉帧或交互响应变差时,逐级降低 DPR、粒子数、阴影、后处理、可视距离和动画频率。没有运行数据时只确认降级路径存在,不判断是否达到触发条件。 +3. 降到最低档仍不可交互时,停止高成本循环并切换 Canvas 2D、静态图或简化 DOM 视图。 +4. `getContext()` 失败、shader 编译 / 链接失败或 context 反复丢失时,必须进入可理解的兜底界面;不能白屏、死循环重试或持续弹错。 + +如果核心功能并不依赖 3D,默认选择 DOM / CSS / Canvas 2D。WebGL 应解决明确的视觉或计算需求,而不是作为普通表单、列表和信息展示的默认技术栈。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md new file mode 100644 index 000000000..5113cd724 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md @@ -0,0 +1,59 @@ +# 小工具 ZIP 静态包构建规范 + +> 小工具是基于离线 H5 的 app 形式,**纯本地、不联网**,所有资源须打包在 zip 内。窗口样式、导航栏、下拉刷新等外壳行为由**容器**统一控制,无需在包内声明。 + +## 目录 + +- §2 支持的文件类型 +- §3 资源加载规则(容器 CSP) +- §5 index.html 要求 + +--- + +## 2. 支持的文件类型 + +zip 内仅允许以下类型: + +| 类型 | 用途 | +|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------| +| `.html` | 入口,有且只有一个 `index.html` | +| `.css` | 样式文件 | +| `.js` | 脚本文件 | +| `.png` / `.jpg` / `.jpeg` / `.gif` / `.webp` / `.svg` | 图片资源 | +| `.woff` / `.woff2` | 字体文件 | +| `.json` | 小型静态数据 / 配置;不得作为大型内置数据库,体积门禁见 [performance-budget.md](./performance-budget.md) | + +--- + +## 3. 资源加载规则(容器 CSP) + +容器对页面**如何加载各类资源**有强制约束。除包内文件外,按类型另允许 `data:` / `blob:` 等内存来源。 + +| 资源类型 | 允许 | 禁止 | +|------------------------------|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| 脚本 `<script>` | 引用包内脚本 `<script src="./app.js">`(同源外链) | 内联 `<script>...</script>`;行内事件 `onclick="..."`;`javascript:` URI;`eval()` / `new Function()`;WebAssembly;外部域名 / `data:` / `blob:` 脚本 | +| 样式 `<style>` / `<link>` | 内联 `<style>`、行内 `style="..."`、包内样式表 | 外部域名样式表 | +| 图片 `<img>` / CSS 背景图 | 包内图片 `<img src="./a.png">`;`data:` URI(base64 内嵌);`blob:`(`createObjectURL` 内存对象,如选图预览) | 外部域名图片 | +| 字体 `@font-face` | 包内字体文件 | 外部域名字体 | +| 音视频 `<video>` / `<audio>` | 包内媒体文件 | 外部域名媒体、`data:` / `blob:` 媒体 | +| iframe / object | — | 全部禁止 | + +关键点: + +- **脚本必须外置**:容器 CSP 的 `script-src` 不含 `unsafe-inline`,内联 `<script>...</script>`、行内事件 `onclick="..."`、`javascript:` URI 均不可用。JS 写进包内 `.js` 用 `<script src>` 引入,事件用 `addEventListener` 绑定。 +- **脚本必须是经典脚本**:只用 `<script src="./app.js">`,**不要 `type="module"`**,JS 里也不要 `import` / `export`。zip 离线加载、无目录服务,module 的相对 `import` 解析不可靠,典型症状是「页面渲染出来但 JS 完全不执行」。要拆多个 JS 文件时按依赖顺序写多个 `<script src>`,靠 `window` 命名空间协作,并避免 top-level `await`。 +- **脚本须兼容目标 WebView**:直接交付的 JS 可使用 ES2017;已有构建链可使用更新语法,但最终须转译为面向 Chrome 61 的 ES2017 产物,见 [js-compatibility.md](./js-compatibility.md)。 +- **样式可内联**:`<style>` 与 `style="..."` 都能用,无需外置。 +- **样式须兼容目标 WebView**:只使用 Chrome 61 可解析的 CSS;`gap`、`min()/max()/clamp()`、逻辑属性等晚于 61 的能力由 `validate.mjs` 判为 ERROR,见 [css-compatibility.md](./css-compatibility.md)。 +- **选图预览**:`<img src>` 配 `data:`(`FileReader.readAsDataURL`)或 `blob:`(`URL.createObjectURL`)均可显示;大图优先 `blob:` 并及时 `URL.revokeObjectURL()`。静态资源不得转成长 Base64 塞进源码,见 [performance-budget.md](./performance-budget.md)。 +- 外部 CDN 一律加载不到,所有资源全部打包进小工具。 + +--- + +## 5. index.html 要求 + +| 规则 | 原因 | +|-------------------------------------------------|----------------------------------| +| 不引用任何外部资源(图片 / CSS / JS / 字体) | 外部资源加载不到,须全部打进 zip | + +- `<title>` 仅影响文档标题;导航栏标题由容器 UI 配置。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs new file mode 100644 index 000000000..2804d7bb4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test, { after } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { auditZipFile, packMinitool } from './pack.mjs'; +import { findUnsupportedFiles } from './validate.mjs'; + +const PACK = fileURLToPath(new URL('./pack.mjs', import.meta.url)); +const BASE = mkdtempSync(join(tmpdir(), 'xhs-pack-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const VALID_HTML = '<!doctype html><html lang="zh-CN"><head>' + + '<meta charset="UTF-8">' + + '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">' + + '</head><body><script src="./app.js"></script></body></html>'; + +/** 造一个最小的 vite 构建产物目录;extra 用来放白名单外的文件。 */ +function writeBuiltDir(name, extra = {}) { + const root = join(BASE, name); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'index.html'), VALID_HTML, 'utf8'); + writeFileSync(join(root, 'app.js'), "console.log('ok');\n", 'utf8'); + for (const [relativePath, content] of Object.entries(extra)) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } + return root; +} + +test('zip budget is classified', () => { + assert.deepEqual(auditZipFile('tool.zip', 1024), []); + assert.equal(auditZipFile('tool.zip', 3 * 1024 * 1024)[0].code, 'ZIP_OVER_RECOMMENDED'); + assert.equal(auditZipFile('tool.zip', 11 * 1024 * 1024)[0].code, 'ZIP_OVER_HARD_LIMIT'); +}); + +test('findUnsupportedFiles 只报白名单外的扩展名', () => { + const root = writeBuiltDir('precheck', { + 'notes.md': 'readme\n', + 'src/config.ts': 'export const x = 1;\n', + }); + const found = findUnsupportedFiles(root).map((item) => item.path).sort(); + assert.deepEqual(found, ['notes.md', 'src/config.ts']); +}); + +test('存在不支持文件时打包整体失败,且不删除任何文件', () => { + const root = writeBuiltDir('no-delete', { + 'notes.md': 'readme\n', + 'style.scss': 'body { color: red; }\n', + }); + assert.throws( + () => packMinitool({ + cwd: BASE, + viteBuiltDir: 'no-delete', + zipOut: 'no-delete-out/xhs-minitool.zip', + }), + /没有删除任何文件/, + ); + assert.ok(existsSync(join(root, 'notes.md')), 'notes.md 被删了'); + assert.ok(existsSync(join(root, 'style.scss')), 'style.scss 被删了'); + assert.equal(existsSync(join(BASE, 'no-delete-out')), false, '失败时不应写出 zip'); +}); + +test('makeZip 的校验也发生在落盘前,失败时不留 zip', () => { + // 缺 index.html:文件扩展名都合法,预检会放行,失败点在 makeZip。 + const noIndex = writeBuiltDir('no-index'); + rmSync(join(noIndex, 'index.html')); + const noIndexOut = join(BASE, 'no-index-out', 'xhs-minitool.zip'); + assert.throws( + () => packMinitool({ cwd: BASE, viteBuiltDir: 'no-index', zipOut: noIndexOut }), + /缺少 index\.html/, + ); + assert.equal(existsSync(noIndexOut), false, '缺 index.html 时不应写出 zip'); + + // 含禁止条目:vite.config.js 扩展名在白名单里,但命中 FORBIDDEN_ENTRY。 + writeBuiltDir('forbidden-entry', { 'vite.config.js': 'export default {};\n' }); + const forbiddenOut = join(BASE, 'forbidden-entry-out', 'xhs-minitool.zip'); + assert.throws( + () => packMinitool({ + cwd: BASE, + viteBuiltDir: 'forbidden-entry', + zipOut: forbiddenOut, + }), + /禁止文件/, + ); + assert.equal(existsSync(forbiddenOut), false, '含禁止条目时不应写出 zip'); +}); + +test('干净产物目录仍能正常打包', () => { + writeBuiltDir('clean'); + const zipOut = join(BASE, 'clean-out', 'xhs-minitool.zip'); + const code = packMinitool({ + cwd: BASE, + viteBuiltDir: 'clean', + zipOut, + }); + assert.equal(code, 0); + assert.ok(existsSync(zipOut), 'zip 未生成'); +}); + +test('--zip-out 默认 ../.export/xhs-minitool.zip(假设 cwd 是 game/)', () => { + const game = join(BASE, 'default-layout', 'game'); + writeBuiltDir(join('default-layout', 'game', 'dist-xhs-minitool')); + assert.equal(packMinitool({ cwd: game }), 0); + assert.ok( + existsSync(join(BASE, 'default-layout', '.export', 'xhs-minitool.zip')), + '默认落点不在项目根的 .export/', + ); +}); + +test('pack 只打 zip,不再改写产物目录', () => { + const root = writeBuiltDir('no-rewrite'); + // 故意留一份「构建后还没收尾」的 index.html:type=module / crossorigin / 根路径脚本。 + const unfinalized = '<!doctype html><html lang="zh-CN"><head>' + + '<meta charset="UTF-8">' + + '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">' + + '<script type="module" crossorigin src="/app.js"></script>' + + '</head><body></body></html>'; + writeFileSync(join(root, 'index.html'), unfinalized, 'utf8'); + + const zipOut = join(BASE, 'no-rewrite-out', 'xhs-minitool.zip'); + assert.equal(packMinitool({ cwd: BASE, viteBuiltDir: 'no-rewrite', zipOut }), 0); + assert.equal( + readFileSync(join(root, 'index.html'), 'utf8'), + unfinalized, + 'pack 改写了 index.html;收尾应属于 vite.config.xhs-minitool.mjs 的插件', + ); +}); + +test('--zip-out 落在 cwd 的 .export 下时给出 workdir 警告', () => { + writeBuiltDir('workdir'); + const warnings = []; + const original = console.warn; + console.warn = (line) => warnings.push(String(line)); + try { + // 可疑:相对 cwd 解析成 <cwd>/.export/...;cwd 若是 game/ 子工程就落错地方。 + packMinitool({ cwd: BASE, viteBuiltDir: 'workdir', zipOut: '.export/xhs-minitool.zip' }); + // 别的相对落点(不落在 cwd 的 .export 下)不提醒。 + packMinitool({ + cwd: BASE, + viteBuiltDir: 'workdir', + zipOut: join('other', 'xhs-minitool.zip'), + }); + } finally { + console.warn = original; + } + assert.equal( + warnings.filter((line) => line.includes('--zip-out')).length, + 1, + warnings.join('\n'), + ); +}); + +test('CLI 拒绝不认识的参数,不写出 zip', () => { + const dir = writeBuiltDir('cli'); + const zipOut = join(BASE, 'cli-out.zip'); + const result = spawnSync( + process.execPath, + [PACK, '--vite-built-dir', dir, '--out-dir', dir, '--zip-out', zipOut], + { cwd: BASE, encoding: 'utf8' }, + ); + assert.equal(result.status, 2); + assert.match(result.stderr, /不认识的参数/); + assert.equal(existsSync(zipOut), false, '参数被拒时不应写出 zip'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs new file mode 100644 index 000000000..6ef18d7b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.validate.test.mjs @@ -0,0 +1,424 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test, { after } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { splitUrl, unquote, validate } from './validate.mjs'; + +const VALIDATOR = fileURLToPath(new URL('./validate.mjs', import.meta.url)); +const BASE = mkdtempSync(join(tmpdir(), 'xhs-validator-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const VALID_HTML = '<!doctype html><html lang="zh-CN"><head>' + + '<meta charset="UTF-8">' + + '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">' + + '<link rel="stylesheet" href="./styles.css">' + + '</head><body>' + + '<img src="./icon.svg" alt="">' + + '<script src="./app.js"></script>' + + '</body></html>'; +const VALID_FILES = { + 'styles.css': ".hero { background: url('./icon.svg'); }\n", + 'app.js': "console.log('ok');\n", + 'icon.svg': '<svg xmlns="http://www.w3.org/2000/svg"></svg>\n', +}; + +function writeProject(root, html, extra = {}) { + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'index.html'), html, 'utf8'); + for (const [name, content] of Object.entries(extra)) { + const path = join(root, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, 'utf8'); + } +} + +function codes(root) { + return new Set(validate(root).findings.map((item) => item.code)); +} + +function runCli(root, ...args) { + return spawnSync(process.execPath, [VALIDATOR, root, ...args], { + encoding: 'utf8', + }); +} + +function expectCodes(name, html, extra, expected) { + const root = join(BASE, name); + writeProject(root, html, extra); + const actual = codes(root); + const missing = [...expected].filter((code) => !actual.has(code)); + assert.deepEqual(missing, [], `${name}: missing ${missing}; actual=${[...actual].sort()}`); +} + +test('valid project has no findings', () => { + const root = join(BASE, 'valid'); + writeProject(root, VALID_HTML, VALID_FILES); + assert.deepEqual(validate(root).findings, []); +}); + +test('inline and unclosed scripts are rejected', () => { + expectCodes( + 'inline_script', + "<!doctype html><script>console.log('inline')</script>", + {}, + ['INLINE_SCRIPT'], + ); + expectCodes( + 'unclosed_inline_script', + "<!doctype html><script>console.log('inline')", + {}, + ['INLINE_SCRIPT', 'UNCLOSED_SCRIPT_TAG'], + ); +}); + +test('resource paths must stay packaged and relative', () => { + expectCodes( + 'resource_escape', + '<!doctype html><script src="../outside.js"></script>', + {}, + ['RESOURCE_OUTSIDE_ROOT'], + ); + expectCodes( + 'absolute_resource', + '<!doctype html><script src="/app.js"></script>', + {}, + ['ABSOLUTE_RESOURCE_PATH'], + ); + expectCodes( + 'missing_css_url', + '<!doctype html><link rel="stylesheet" href="./styles.css">', + { 'styles.css': ".hero { background: url('./missing.png'); }\n" }, + ['MISSING_LOCAL_RESOURCE'], + ); +}); + +test('navigation and embedded content are rejected', () => { + expectCodes( + 'mailto_navigation', + '<!doctype html><a href="mailto:test@example.com">mail</a>', + {}, + ['LINK_NAVIGATION'], + ); + expectCodes( + 'meta_refresh', + '<!doctype html><meta http-equiv="refresh" content="0;url=next.html">', + {}, + ['META_REFRESH'], + ); + expectCodes( + 'base_url', + '<!doctype html><base href="./assets/">', + {}, + ['BASE_URL'], + ); + expectCodes( + 'form_navigation', + '<!doctype html><form action="javascript:void(0)"></form>', + {}, + ['FORM_NAVIGATION'], + ); +}); + +test('unsupported mobile APIs are flagged', () => { + expectCodes( + 'unsupported_mobile_apis', + '<!doctype html><script src="./app.js"></script>', + { + 'app.js': 'new PaymentRequest([], {});\n' + + 'Notification.requestPermission();\n' + + 'new NDEFReader();\n' + + 'navigator.requestMIDIAccess();\n' + + "navigator.xr.requestSession('inline');\n" + + 'document.body.requestPointerLock();\n' + + 'new SyncManager();\n', + }, + [ + 'PAYMENT_REQUEST', 'NOTIFICATION_API', 'NFC_API', + 'MIDI_API', 'XR_API', 'POINTER_KEYBOARD_LOCK', 'BACKGROUND_SYNC', + ], + ); +}); + +test('network, storage, filesystem and download APIs are flagged', () => { + expectCodes( + 'network_storage_filesystem_download', + '<!doctype html><script src="./app.js"></script>', + { + 'app.js': "navigator.sendBeacon('/log', 'x');\n" + + "new WebTransport('https://example.com');\n" + + 'navigator.storage.persist();\n' + + 'showSaveFilePicker();\n' + + "anchor.download = 'file.txt';\n", + }, + [ + 'NETWORK_BEACON', 'NETWORK_WEBTRANSPORT', 'PERSISTENT_STORAGE', + 'FILE_SYSTEM_ACCESS', 'PROGRAMMATIC_DOWNLOAD', + ], + ); +}); + +test('PWA manifest, plugins and area navigation are rejected', () => { + expectCodes( + 'pwa_plugin_and_area_navigation', + '<!doctype html><link rel="manifest" href="./manifest.json">' + + '<applet></applet><map><area href="./next.html"></map>', + { 'manifest.json': '{}\n' }, + ['PWA_MANIFEST', 'EMBEDDED_CONTENT', 'LINK_NAVIGATION'], + ); +}); + +test('data/blob, javascript: and dynamic paths are classified', () => { + expectCodes( + 'data_blob', + '<!doctype html><img src="data:image/png;base64,AA">' + + '<source srcset="data:image/png;base64,AA">' + + '<script src="blob:abc"></script>', + {}, + ['IMAGE_DATA_BLOB_VERSION', 'DISALLOWED_DATA_BLOB'], + ); + expectCodes( + 'javascript_uri', + '<!doctype html><a href="javascript:void(0)">x</a><img src="javascript:alert(1)">', + {}, + ['JAVASCRIPT_URI'], + ); + expectCodes( + 'dynamic_path', + '<!doctype html><script src="./{{name}}.js"></script>', + {}, + ['DYNAMIC_RESOURCE_PATH'], + ); +}); + +test('inline events, file accept limits and form review produce warnings', () => { + const root = join(BASE, 'warnings'); + writeProject( + root, + '<!doctype html><body onload="x()">' + + '<input type="file" accept="image/*,.txt">' + + '<form action="#"></form>' + + '</body>', + ); + const actual = codes(root); + for (const code of ['INLINE_EVENT', 'FILE_ACCEPT_LIMIT', 'FORM_REVIEW']) { + assert.ok(actual.has(code), `missing ${code}; actual=${[...actual].sort()}`); + } +}); + +test('multiple or missing entry HTML files are rejected', () => { + const many = join(BASE, 'multi_html'); + writeProject(many, '<!doctype html>', { 'other.html': '<!doctype html>' }); + assert.ok(codes(many).has('HTML_ENTRY_COUNT')); + + const none = join(BASE, 'no_html'); + mkdirSync(none, { recursive: true }); + writeFileSync(join(none, 'app.js'), 'console.log(1);\n'); + assert.ok(codes(none).has('HTML_ENTRY_COUNT')); +}); + +test('entry must be root index.html', () => { + const renamed = join(BASE, 'renamed_entry'); + mkdirSync(renamed, { recursive: true }); + writeFileSync(join(renamed, 'main.html'), '<!doctype html>', 'utf8'); + assert.ok(codes(renamed).has('ENTRY_NOT_INDEX_HTML')); + + const nested = join(BASE, 'nested_entry'); + mkdirSync(join(nested, 'app'), { recursive: true }); + writeFileSync(join(nested, 'app', 'index.html'), '<!doctype html>', 'utf8'); + assert.ok(codes(nested).has('ENTRY_NOT_INDEX_HTML')); +}); + +test('html template and classic-script requirements are enforced', () => { + expectCodes( + 'html_template', + '<!doctype html><html><head>' + + '<meta http-equiv="Content-Security-Policy" content="default-src \'self\'">' + + '</head><body><script type="module" src="./app.js"></script></body></html>', + { 'app.js': 'export const a = 1;\nconst b = obj?.c ?? 2;\n' }, + [ + 'CHARSET_MISSING', 'VIEWPORT_MISSING', 'HTML_LANG_MISSING', 'CSP_META', + 'MODULE_SCRIPT', 'ESM_SYNTAX', 'ES2018_PLUS_SYNTAX', + ], + ); +}); + +test('Chrome 61 不支持的 CSS 与运行时 API 直接报 ERROR', () => { + const root = join(BASE, 'modern_css'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'styles.css': '.a { display: flex; gap: 8px; width: min(100%, 20rem); color: oklch(0.5 0.1 20); }\n', + 'app.js': "list.replaceAll('a', 'b');\nObject.hasOwn({}, 'x');\n", + }); + const findings = validate(root).findings; + const css = findings.find((item) => item.code === 'CSS_UNSUPPORTED_FEATURE'); + assert.ok(css, `missing CSS_UNSUPPORTED_FEATURE; actual=${[...codes(root)].sort()}`); + assert.equal(css.severity, 'ERROR'); + const runtime = findings.find((item) => item.code === 'MODERN_RUNTIME_API'); + assert.ok(runtime, `missing MODERN_RUNTIME_API; actual=${[...codes(root)].sort()}`); + assert.equal(runtime.severity, 'ERROR'); +}); + +test('grid-gap / -webkit- 前缀写法不算 Chrome 61 不支持', () => { + const root = join(BASE, 'legacy_gap'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'styles.css': '.grid { display: grid; grid-gap: 8px; }\n' + + '.cols { -webkit-column-gap: 8px; }\n', + }); + assert.ok(!codes(root).has('CSS_UNSUPPORTED_FEATURE'), `actual=${[...codes(root)].sort()}`); +}); + +test('字符串与注释里的现代语法 / #hex / #选择器不算命中', () => { + const root = join(BASE, 'js_noise'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': '// obj?.c ?? 2\n' + + 'var color = "#e8f4ff";\n' + + "var hit = document.querySelector('#hit');\n" + + '// import.meta 也只出现在注释里\n', + }); + assert.ok(!codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`); +}); + +test('注释与字符串里的 API 名不算命中,但字符串参数规则仍生效', () => { + const noisy = join(BASE, 'api_noise'); + writeProject(noisy, VALID_HTML, { + ...VALID_FILES, + // `fetch(` / `XMLHttpRequest` / `new Function(` / `eval(` 只出现在注释与字符串里:不是调用。 + 'app.js': '// 不能用 fetch( 和 XMLHttpRequest 请求网络\n' + + 'var note = "new Function( 也不允许";\n' + + "var evalNote = 'eval(\\'x\\')';\n", + }); + const noisyCodes = codes(noisy); + assert.ok(!noisyCodes.has('NETWORK_FETCH'), `actual=${[...noisyCodes].sort()}`); + assert.ok(!noisyCodes.has('NETWORK_XHR'), `actual=${[...noisyCodes].sort()}`); + assert.ok(!noisyCodes.has('DYNAMIC_FUNCTION'), `actual=${[...noisyCodes].sort()}`); + assert.ok(!noisyCodes.has('DYNAMIC_EVAL'), `actual=${[...noisyCodes].sort()}`); + + const real = join(BASE, 'api_real'); + writeProject(real, VALID_HTML, { + ...VALID_FILES, + // 这条规则要匹配的就是字符串参数:剥掉字符串就会漏检。 + 'app.js': "document.querySelector('a').setAttribute('download', '');\n", + }); + const realCodes = codes(real); + assert.ok(realCodes.has('PROGRAMMATIC_DOWNLOAD'), `actual=${[...realCodes].sort()}`); +}); + +test('CSS 里的外部 URL 仍然报错(不会被当 // 注释抹掉)', () => { + const root = join(BASE, 'css_external_url'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'styles.css': ".hero { background: url('https://cdn.evil.example/bg.png'); }\n", + }); + assert.ok(codes(root).has('EXTERNAL_URL'), `actual=${[...codes(root)].sort()}`); +}); + +test('JS 字符串里的外部 URL 仍然报错', () => { + const root = join(BASE, 'js_external_url'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': "fetch('https://api.evil.example/data');\n", + }); + const actual = codes(root); + assert.ok(actual.has('NETWORK_FETCH'), `actual=${[...actual].sort()}`); + assert.ok(actual.has('EXTERNAL_URL'), `actual=${[...actual].sort()}`); +}); + +test('真正的 class 私有字段仍然报错', () => { + const root = join(BASE, 'private_field_real'); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': 'class Counter { #count = 0; }\n', + }); + assert.ok(codes(root).has('ES2018_PLUS_SYNTAX'), `actual=${[...codes(root)].sort()}`); +}); + +test('oversized inline base64 is warned', () => { + const root = join(BASE, 'base64_budget'); + const payload = 'A'.repeat(200 * 1024); + writeProject(root, VALID_HTML, { + ...VALID_FILES, + 'app.js': `var img = "data:image/png;base64,${payload}";\n`, + }); + assert.ok(codes(root).has('BASE64_LARGE'), `actual=${[...codes(root)].sort()}`); +}); + +test('unsupported file types and invalid UTF-8 text are rejected', () => { + const unsupported = join(BASE, 'unsupported_file'); + writeProject(unsupported, '<!doctype html>', { 'app.ts': 'let x = 1;\n' }); + assert.ok(codes(unsupported).has('UNSUPPORTED_FILE')); + + const invalid = join(BASE, 'invalid_utf8'); + mkdirSync(invalid, { recursive: true }); + writeFileSync(join(invalid, 'index.html'), Buffer.from([ + 0x3c, 0x21, 0x64, 0x6f, 0x63, 0x74, 0x79, 0x70, 0x65, 0x20, 0xff, 0xfe, + ])); + assert.ok(codes(invalid).has('TEXT_DECODE')); +}); + +test('symlinks are rejected', (t) => { + const root = join(BASE, 'symlink_case'); + const outside = join(BASE, 'outside.js'); + writeProject(root, '<!doctype html><title>ok'); + writeFileSync(outside, "console.log('outside')\n"); + try { + symlinkSync(outside, join(root, 'linked.js')); + } catch { + t.skip('symlink creation not permitted on this platform'); + return; + } + assert.ok(codes(root).has('SYMLINK')); +}); + +test('CLI exit codes: pass, warning and error', () => { + const validRoot = join(BASE, 'valid_cli'); + writeProject(validRoot, VALID_HTML, VALID_FILES); + const validCli = runCli(validRoot, '--json'); + assert.equal(validCli.status, 0, validCli.stderr); + const payload = JSON.parse(validCli.stdout); + assert.deepEqual(payload.summary, { errors: 0, warnings: 0 }); + + const warningRoot = join(BASE, 'warning_cli'); + writeProject(warningRoot, '' + + '' + + ''); + // 有 WARNING 也照样通过:退出码只由 ERROR 决定,没有 --strict 这种「警告即失败」的模式。 + assert.equal(runCli(warningRoot).status, 0); + + const errorRoot = join(BASE, 'error_cli'); + writeProject(errorRoot, ''); + assert.equal(runCli(errorRoot).status, 1); +}); + +test('CLI rejects missing directories and bad arguments', () => { + assert.equal(runCli(join(BASE, 'does-not-exist')).status, 2); + const bad = spawnSync(process.execPath, [VALIDATOR, '--nope'], { encoding: 'utf8' }); + assert.equal(bad.status, 2); +}); + +test('splitUrl mirrors urlsplit for packaged references', () => { + assert.deepEqual(splitUrl('./a.png'), { scheme: '', netloc: '', path: './a.png' }); + assert.deepEqual(splitUrl('//cdn.example.com/a.png'), { + scheme: '', netloc: 'cdn.example.com', path: '/a.png', + }); + assert.deepEqual(splitUrl('https://example.com/a.png'), { + scheme: 'https', netloc: 'example.com', path: '/a.png', + }); + assert.deepEqual(splitUrl('mailto:a@b.c'), { scheme: 'mailto', netloc: '', path: 'a@b.c' }); + assert.deepEqual(splitUrl('?x=1'), { scheme: '', netloc: '', path: '' }); +}); + +test('unquote tolerates malformed escapes', () => { + assert.equal(unquote('a%20b.png'), 'a b.png'); + assert.equal(unquote('100%25.png'), '100%.png'); + assert.equal(unquote('bad%zz.png'), 'bad%zz.png'); +}); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs new file mode 100644 index 000000000..1ebc6451d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.vite.config.xhs-minitool.test.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * vite.config.xhs-minitool.mjs 的收尾插件端到端测试:真跑一遍 `vite build`, + * 验证 dist 在 build 结束时已经合规(相对路径、无 module 痕迹、脚本在 body 末尾、 + * 空目录被清掉、icon 兜底),而 pack 只负责打 zip。 + * + * 这是本地开发辅助(`.` 前缀),不随 Skill Pack 发布。 + */ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { after } from 'node:test'; + +import { packMinitool } from './pack.mjs'; +import { findUnsupportedFiles, validate } from './validate.mjs'; +import { defineMinitoolConfig, pruneEmptyDirs } from './vite.config.xhs-minitool.mjs'; + +const BASE = mkdtempSync(join(tmpdir(), 'xhs-vite-config-tests-')); + +after(() => { + rmSync(BASE, { recursive: true, force: true }); +}); + +const SOURCE_HTML = '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' fixture\n' + + ' \n' + + ' \n' + + '
\n' + + ' \n' + + ' \n' + + '\n'; + +/** 造一个最小 vite 项目;返回项目根。 */ +function writeProject(name) { + const root = join(BASE, name); + mkdirSync(join(root, 'src'), { recursive: true }); + mkdirSync(join(root, 'public', 'icons'), { recursive: true }); + mkdirSync(join(root, 'public', 'empty-dir'), { recursive: true }); + writeFileSync(join(root, 'index.html'), SOURCE_HTML, 'utf8'); + writeFileSync( + join(root, 'src', 'main.js'), + "import './style.css';\ndocument.getElementById('app').textContent = 'ok';\n", + 'utf8', + ); + writeFileSync(join(root, 'src', 'style.css'), 'body { color: red; }\n', 'utf8'); + for (const icon of ['icon-192.svg', 'icon-512.svg']) { + writeFileSync( + join(root, 'public', 'icons', icon), + '\n', + 'utf8', + ); + } + return root; +} + +test('vite 构建收尾:dist 在 closeBundle 后即可直接打 zip', async () => { + const root = writeProject('finalized'); + const { build } = await import('vite'); + const config = defineMinitoolConfig({ root, logLevel: 'silent' }); + + await build({ ...config, configFile: false }); + const outDir = join(root, 'dist-xhs-minitool'); + + assert.ok(existsSync(join(outDir, 'index.html')), 'index.html 未生成'); + assert.ok(existsSync(join(outDir, 'app.js')), 'app.js 未生成'); + assert.ok(existsSync(join(outDir, 'app.css')), 'app.css 未生成'); + + const html = readFileSync(join(outDir, 'index.html'), 'utf8'); + const head = html.slice(0, html.search(/<\/head>/i)); + assert.doesNotMatch(html, /type="module"/i, '仍残留 type="module"'); + assert.doesNotMatch(html, /crossorigin/i, '仍残留 crossorigin'); + assert.doesNotMatch(html, /rel="modulepreload"/i, '仍残留 modulepreload'); + assert.doesNotMatch(html, /\b(?:src|href)="\//, '仍有根路径资源引用'); + assert.doesNotMatch(head, /`); + return ''; + }, + ); + + if (scripts.length) { + if (/<\/body>/i.test(html)) { + html = html.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n `); + } else { + html += `\n${scripts.join('\n')}\n`; + } + } + + writeFileSync(htmlPath, html); +} + +/** + * publicDir 被关掉(build.copyPublicDir: false)时,把小红书要求的 icon 兜底复制进 dist。 + * + * @param {string} outDir + * @param {string} publicDir + */ +export function copyFallbackIcons(outDir, publicDir) { + if (!publicDir) return; + for (const icon of FALLBACK_ICONS) { + const dest = join(outDir, 'icons', icon); + const src = join(publicDir, 'icons', icon); + if (!existsSync(dest) && existsSync(src)) { + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + } + } +} + +/** 递归删除空目录;zip 里不留无意义目录条目。 */ +export function pruneEmptyDirs(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + pruneEmptyDirs(path); + if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); + } + } +} + +/** + * dist 收尾插件:在 bundle 写盘后原地整理产物,让 pack 只需打 zip。 + * + * 用 `closeBundle`(最后一个构建钩子)而不是 `writeBundle`:publicDir 已复制完, + * 且排在其它插件之后,不会和还在写文件的插件抢目录。`apply: 'build'` 保证 dev server 不触发。 + * + * 注意:rollup 在构建失败时**也会**调 `closeBundle`。所以用 `buildEnd(error)` 记下失败, + * 失败就直接返回——否则会留下只复制了 icon 的半成品 dist,还会用「index.html 不存在」 + * 覆盖掉真正的语法报错。 + * + * @returns {import('vite').Plugin} + */ +export function xhsMinitoolArtifactPlugin() { + /** @type {string} */ + let outDir = ''; + /** @type {string} */ + let publicDir = ''; + let buildFailed = false; + + return { + name: ARTIFACT_PLUGIN_NAME, + apply: 'build', + enforce: 'post', + configResolved(config) { + // config.build.outDir 是相对 config.root 的路径,必须自己解析成绝对路径。 + outDir = resolve(config.root, config.build.outDir); + publicDir = config.publicDir; + }, + buildEnd(error) { + buildFailed = Boolean(error); + }, + closeBundle() { + if (buildFailed) return; + // 先校验 index.html:指错目录的成功构建在这里就失败,不会先建出一堆 icon。 + patchIndexHtml(outDir); + copyFallbackIcons(outDir, publicDir); + pruneEmptyDirs(outDir); + }, + }; +} + +export const minitoolBaseConfig = { + base: './', + build: { + target: MINITOOL_JS_TARGET, + cssTarget: MINITOOL_CSS_TARGET, + outDir: 'dist-xhs-minitool', + emptyOutDir: true, + sourcemap: false, + assetsInlineLimit: 0, + cssCodeSplit: false, + modulePreload: false, + rollupOptions: { + output: { + format: 'iife', + inlineDynamicImports: true, + entryFileNames: 'app.js', + chunkFileNames: 'chunk-[name].js', + assetFileNames: (assetInfo) => { + const name = assetInfo.name || ''; + if (name.endsWith('.css')) return 'app.css'; + if (name.endsWith('.woff2') || name.endsWith('.woff')) { + return 'fonts/[name][extname]'; + } + if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) { + return 'icons/[name][extname]'; + } + return 'assets/[name][extname]'; + }, + }, + }, + }, + plugins: [xhsMinitoolArtifactPlugin()], +}; + +/** + * 合并项目配置,并保证收尾插件一定在 `plugins` 里。 + * + * `mergeConfig` 对数组是拼接,正常传 `{ plugins: [...] }` 不会丢掉 base 的插件;但调用方 + * 若先展开 `minitoolBaseConfig` 再覆盖 `plugins`,插件就被挤掉了。这里按 name 去重后 + * 追加一个新实例:无论 overrides 怎么给,插件都在最后且只跑一次。 + * + * @param {import('vite').UserConfig} [overrides] + * @returns {import('vite').UserConfig} + */ +export function defineMinitoolConfig(overrides = {}) { + const merged = mergeConfig(defineConfig(minitoolBaseConfig), overrides); + merged.plugins = [ + ...(merged.plugins ?? []).filter((plugin) => plugin?.name !== ARTIFACT_PLUGIN_NAME), + xhsMinitoolArtifactPlugin(), + ]; + return merged; +} + +export default defineMinitoolConfig(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index 07682aafc..34a4dd31a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -16,7 +16,7 @@ use shared_contracts::runtime::{ ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, }; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -37,7 +37,35 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建账户网络客户端失败".to_string()) + .map_err(|error| format!("创建账户网络客户端失败:{error}")) +} + +fn describe_account_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } } fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result { @@ -87,30 +115,55 @@ fn error_code(body: &str) -> Option { error_field(body, "code") } +fn safe_error_body_detail(body: &str) -> Option { + let value = serde_json::from_str::(body).ok(); + let detail = value + .as_ref() + .and_then(|value| value.get("error").or(Some(value))) + .and_then(|value| serde_json::to_string(value).ok()) + .or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?; + if matches!(detail.trim(), "{}" | "null" | "\"\"") { + return None; + } + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); + let server_detail = error_message(body) + .or_else(|| error_code(body)) + .or_else(|| safe_error_body_detail(body)); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match server_detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( "permission-denied: {}", - error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } - let detail = error_message(body) - .or_else(|| error_code(body)) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } fn unwrap_envelope(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?; + let value: Value = serde_json::from_str(body) + .map_err(|error| format!("{fallback}:服务端响应不是合法 JSON:{error}"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { let detail = error_message(body) .or_else(|| error_code(body)) - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + .or_else(|| safe_error_body_detail(body)) + .unwrap_or_else(|| "服务端返回 ok=false,但未提供 error/code/message 详情".to_string()); return Err(format!("{fallback}:{detail}")); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -158,22 +211,21 @@ async fn request_json( request = request.json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_account_request_failure(&error) + ) })?; let status = response.status(); let text = response .text() .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + .map_err(|error| format!("{fallback}:读取响应失败:{error}"))?; if !status.is_success() { return Err(map_http_error(status, &text, fallback)); } let data = unwrap_envelope(&text, fallback)?; - serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效")) + serde_json::from_value(data).map_err(|error| format!("{fallback}:响应格式无效:{error}")) } #[tauri::command] @@ -344,6 +396,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::FORBIDDEN, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index db9db4abf..9770d524f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -45,7 +45,7 @@ pub mod tool; pub(crate) use art_manifest::*; use claude_code_cli::*; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_error, direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index ca5c15cc9..3d4660094 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -5,6 +5,7 @@ //! protocols are never mixed. use super::*; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; @@ -14,6 +15,20 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; const CLAUDE_AGENT_SIDECAR_ENV: &str = "GENARRATIVE_CLAUDE_AGENT_SIDECAR"; const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE"; +/// DirectProject 的一次 CC 回合可能包含代码生成、试玩、修复和再次复核;8 次会在真实 +/// 游戏链路的修复阶段提前收口成 `Reached maximum number of turns (8)`。 +const CLAUDE_DIRECT_MAX_TURNS: u32 = 16; + +/// cc 侧 loopback MCP 工具调用的硬超时(毫秒)。 +/// +/// Claude Code 的 MCP 工具调用默认只有 60 秒硬墙,且"进度通知不会延长"它。AGC 的工具里 +/// 包含真实付费与长耗时操作:图片生成一次约 56~70 秒,浏览器试玩、构建、验证也都在分钟级。 +/// 现场证据(2026-10-06,项目 gameagent-cfd26650):模型在 13:05:30.760 发起两个 +/// `agc_generate_image`,13:06:30.767(正好 60.0 秒)MCP 客户端返回 +/// `{"content":"The operation timed out.","is_error":true}`,随后同轮的第二个调用被判 +/// `direct-execution-interrupted: 原执行未正常结算`,本轮账本落成 `phase=interrupted`。 +/// Codex 侧对同一套工具桥已设 `tool_timeout_sec=6600`(110 分钟),这里按同一口径对齐。 +const CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024; const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const CLAUDE_CODE_MODEL_MAX_CHARS: usize = 200; @@ -32,6 +47,17 @@ const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30); /// cc 回合的绝对上限。`requestTimeoutMs` 只算静默预算,事件流假活(一直有心跳但永远 /// 不出终态)靠这个兜底。 const CLAUDE_CODE_TURN_MAX_DURATION: Duration = Duration::from_secs(45 * 60); +/// cc 一轮用户回合最多尝试几次(含首次)。 +/// +/// 现场(2026-10-07,项目 `gameagent-f1893fc4`):平台网关偶发挂住,sidecar 收到 3 个事件 +/// 后连续 180000 ms 一个字节都没有,回合直接失败,用户只能手动点「重试」;而那一次手动重试 +/// 58 秒就成功了。这种挂起是可重放的,不该让用户自己点一次。 +const CLAUDE_DIRECT_TURN_MAX_ATTEMPTS: usize = 3; +/// 自动重试前的冷却,避免对同一个挂住的上游连续施压。 +const CLAUDE_DIRECT_RETRY_BACKOFF: Duration = Duration::from_secs(2); +/// 失败详情里的重试次数标记:`claude_code_failure_to_turn_error` 靠它把「已尝试 N 次」 +/// 还原成结构化 attempts,并把标记从用户可见详情里摘掉。 +const CLAUDE_DIRECT_RETRY_ATTEMPTS_MARKER: &str = ";本轮已自动重试 "; struct ActiveClaudeCodeTurn { client_turn_id: String, @@ -58,20 +84,110 @@ fn claude_project_key(root: &Path) -> PathBuf { std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()) } -fn kill_claude_code_process_tree(pid: u32) { +/// Claude Code 把每个会话的轨迹持久化在 `/projects/<项目目录名>/.jsonl`, +/// 目录名由 cwd 派生:非字母数字字符统一替换成 `-`。 +/// +/// 实例:`C:\Users\...\projects\gameagent-0514673b` 对应 +/// `C--Users-...-projects-gameagent-0514673b`。 +fn claude_code_project_dir_name(root: &Path) -> String { + root.to_string_lossy() + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() +} + +/// 返回某个会话目录里 mtime 最新的一份 cc 轨迹:`(修改时间, sessionId)`。 +fn newest_claude_code_session_in(dir: &Path) -> Option<(std::time::SystemTime, String)> { + let mut newest: Option<(std::time::SystemTime, String)> = None; + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if path.extension().and_then(std::ffi::OsStr::to_str) != Some("jsonl") { + continue; + } + let Some(session_id) = path + .file_stem() + .and_then(std::ffi::OsStr::to_str) + .filter(|stem| !stem.is_empty()) + else { + continue; + }; + let Ok(modified) = entry.metadata().and_then(|metadata| metadata.modified()) else { + continue; + }; + if newest + .as_ref() + .map(|(at, _)| modified > *at) + .unwrap_or(true) + { + newest = Some((modified, session_id.to_string())); + } + } + newest +} + +/// 从隔离 home 里找出本项目最近的 cc 会话 id,供 Claude Agent SDK `resume` 使用。 +/// +/// 只认本项目目录(目录名由 cwd 派生,`resume` 也只会在那里查这份轨迹):进程内会话表 +/// 在用户"离开项目再进入"或重启客户端后必然为空,此时必须回退到磁盘,否则 cc 执行器 +/// 会以全新会话开工,模型看不到任何历史对话。现场:2026-10-07 用户反馈"离开后再进入 +/// 项目,cc 那边看不到历史对话",项目 `gameagent-0514673b` 的隔离 home 里留下了两份 +/// 独立轨迹(14:52 与 15:05),第二份的首次用户输入只有一句"现在呢"。 +fn latest_claude_code_session_id_at(home: &Path, root: &Path) -> Option { + let projects = home.join("claude").join("projects"); + let preferred = projects.join(claude_code_project_dir_name(root)); + newest_claude_code_session_in(&preferred).map(|(_, session_id)| session_id) +} + +/// 解析本回合要恢复的 cc 会话:进程内会话表优先,没有就回退到磁盘上最新的会话轨迹。 +/// +/// 返回 `(会话 id, 来源)`,来源只进应用日志,供"cc 看不到历史对话"这类反馈一眼分辨是 +/// 内存命中、磁盘回放,还是本项目根本没有历史会话。 +fn resolve_claude_direct_resume( + root: &Path, + home: &Path, +) -> Result<(Option, &'static str), String> { + let session_key = claude_project_key(root); + let in_memory = claude_direct_sessions() + .lock() + .map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())? + .get(&session_key) + .cloned(); + if let Some(session_id) = in_memory { + return Ok((Some(session_id), "memory")); + } + match latest_claude_code_session_id_at(home, root) { + Some(session_id) => Ok((Some(session_id), "disk")), + None => Ok((None, "none")), + } +} + +fn kill_claude_code_process_tree(pid: u32) -> Result<(), String> { #[cfg(unix)] - unsafe { - libc::kill(-(pid as i32), libc::SIGKILL); + { + let result = unsafe { libc::kill(-(pid as i32), libc::SIGKILL) }; + if result != 0 { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:{}", + std::io::Error::last_os_error() + )); + } } #[cfg(windows)] { - let _ = std::process::Command::new("taskkill") + let status = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status(); + .status() + .map_err(|error| format!("启动 taskkill 终止 Claude Code sidecar 失败:{error}"))?; + if !status.success() { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:taskkill 返回 {status}" + )); + } } + Ok(()) } struct ActiveClaudeCodeTurnGuard { @@ -158,17 +274,34 @@ fn configure_sidecar_command( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - configure_claude_code_environment(command, llm, home); + configure_claude_code_environment(command, llm, home)?; configure_claude_code_process(command); Ok(()) } #[derive(Debug)] struct SidecarTurnResult { - events: Vec, result: serde_json::Value, } +/// 把 cc sidecar 的进程树退出证明写进当前回合账本。 +/// +/// 交付封口(`finish_sealing`)要求 `executor_stopped`,而这项证明过去只有 Codex app-server +/// 会写:cc 回合因此永远停在 sealing(`deliveryReviews` 烧到上限后落成 interrupted), +/// 下一轮的修改与付费调用再被 `direct-execution-interrupted` 拦住。这里让 cc 执行器按同一 +/// 口径上报"后台子树确实已退出",证明缺失时如实上报失败而不是缺席。 +fn record_claude_sidecar_exit_proof(root: &Path, proven: Option) { + let Some(proven) = proven else { + return; + }; + app_log!("agent.direct_codex.claude_sidecar_exit_proof proven={proven}"); + if let Ok(session) = super::direct_execution::current(root) { + if let Err(error) = session.record_process_exit_proof(proven) { + app_log!("agent.direct_codex.claude_sidecar_exit_proof_unrecorded detail={error}"); + } + } +} + /// sidecar 回合的失败分类:静默超时必须先杀进程树再收口,普通失败直接透传。 enum SidecarTurnFailure { Silent { silent_ms: u64, events: usize }, @@ -188,6 +321,7 @@ async fn run_sidecar_turn( request: serde_json::Value, timeout_ms: u64, active_turn: Option<&str>, + mut on_event: Option<&mut (dyn FnMut(&serde_json::Value) + Send)>, ) -> Result { let node = resolve_claude_node(root); let mut command = tokio::process::Command::new(node); @@ -198,6 +332,18 @@ async fn run_sidecar_turn( let pid = child .id() .ok_or_else(|| "Claude Agent SDK sidecar 进程缺少 PID".to_string())?; + // 与 Codex app-server 同一套 Job Object 归属:只有把 sidecar 及其子进程树纳入受控 + // 归属,回合结束时才拿得到"子树已退出"的可信证明,交付封口才有条件完成。 + let owned_tree = match super::codex_app_server::process_tree::OwnedProcessTree::attach_with_role( + &child, + "ClaudeCode", + ) { + Ok(tree) => Some(tree), + Err(error) => { + app_log!("agent.direct_codex.claude_sidecar_tree_unowned detail={error}"); + None + } + }; let active_key = active_turn.map(|_| claude_project_key(root)); let active_alive = Arc::new(AtomicBool::new(true)); if let (Some(key), Some(client_turn_id)) = (active_key.clone(), active_turn) { @@ -249,10 +395,11 @@ async fn run_sidecar_turn( let mut timeout_reason: Option = None; let turn = async { let mut lines = BufReader::new(stdout).lines(); - let mut events = Vec::new(); + let mut event_count = 0_usize; let mut terminal = None; let mut idle_rounds = 0_u64; let mut silent_ms = 0_u64; + let mut last_event_detail = None; loop { let line = match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line()) @@ -274,12 +421,12 @@ async fn run_sidecar_turn( app_log!( "agent.direct_codex.claude_sidecar_idle idleSeconds={} events={}", CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds, - events.len() + event_count ); if silent_ms >= silence_budget_ms { return Err(SidecarTurnFailure::Silent { silent_ms, - events: events.len(), + events: event_count, }); } continue; @@ -288,15 +435,30 @@ async fn run_sidecar_turn( if line.trim().is_empty() { continue; } - let value: serde_json::Value = serde_json::from_str(&line).map_err(|_| { - SidecarTurnFailure::message( - "Claude Agent SDK sidecar 输出不是有效 JSON".to_string(), - ) + let value: serde_json::Value = serde_json::from_str(&line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &line, + 480, + ); + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 输出不是有效 JSON:{error};原文={detail}" + )) })?; + last_event_detail = serde_json::to_string(&value).ok().map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ) + }); match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { if let Some(event) = value.get("event") { - events.push(event.clone()); + event_count = event_count.saturating_add(1); + if let Some(on_event) = on_event.as_deref_mut() { + on_event(event); + } } } Some("done") => { @@ -304,13 +466,18 @@ async fn run_sidecar_turn( break; } Some("error") => { - kill_claude_code_process_tree(pid); + if let Err(error) = kill_claude_code_process_tree(pid) { + app_log!("agent.direct_codex.claude_sidecar_kill_failed detail={error}"); + } + let detail = claude_result_error_detail(&value).or_else(|| { + last_event_detail + .clone() + .filter(|detail| !detail.trim().is_empty()) + }); return Err(SidecarTurnFailure::message( - value - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Claude Agent SDK sidecar 执行失败") - .to_string(), + detail + .map(|detail| format!("Claude Agent SDK sidecar 执行失败:{detail}")) + .unwrap_or_else(|| "Claude Agent SDK sidecar 执行失败".to_string()), )); } _ => {} @@ -319,15 +486,34 @@ async fn run_sidecar_turn( let status = child.wait().await.map_err(|error| { SidecarTurnFailure::message(format!("等待 Claude Agent SDK sidecar 结束失败:{error}")) })?; + let exit_status = status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()); let result = terminal.ok_or_else(|| { - SidecarTurnFailure::message("Claude Agent SDK sidecar 缺少终态".to_string()) + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 缺少终态;exitStatus={exit_status};已收到 {} 个事件;最后事件={}", + event_count, + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + )) })?; if !status.success() { - return Err(SidecarTurnFailure::message( - "Claude Agent SDK sidecar 非零退出".to_string(), - )); + return Err(SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 非零退出;exitStatus={exit_status}" + ))); } - Ok(SidecarTurnResult { events, result }) + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); + Ok(SidecarTurnResult { result }) }; let outcome = match tokio::time::timeout(CLAUDE_CODE_TURN_MAX_DURATION, turn).await { Ok(Ok(result)) => Ok(result), @@ -349,24 +535,69 @@ async fn run_sidecar_turn( let result = match outcome { Ok(result) => result, Err(message) => { - let Some(reason) = timeout_reason else { - return Err(message); + // 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有 + // 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉 + // sidecar 给出的真正原因,最终只剩一条 transport 通用句。 + // 失败路径同样要收口受控子树并留下退出证明:证明确认时不必再叠加 taskkill, + // 证明缺失时回退到原有的进程树强杀,保证不留后台执行器。 + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); + let kill_detail = match proven { + Some(true) => None, + _ => kill_claude_code_process_tree(pid).err(), }; - // 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着, - // stderr 等不到 EOF,这一轮就永远出不了终态。 - kill_claude_code_process_tree(pid); let stderr_detail = match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await { - Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(), - _ => None, + Ok(Ok(Ok(bytes))) => match String::from_utf8(bytes) { + Ok(value) => Some(value), + Err(error) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{};原始字节数={}", + error.utf8_error(), + error.as_bytes().len() + )), + }, + Ok(Ok(Err(error))) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{error}" + )), + Ok(Err(error)) => Some(format!( + "等待 Claude Agent SDK sidecar stderr 读取任务失败:{error}" + )), + Err(_) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 超时({} 秒)", + CLAUDE_CODE_STDERR_DRAIN_TIMEOUT.as_secs() + )), } .map(|value| value.trim().chars().take(512).collect::()) .filter(|value| !value.is_empty()) - // 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。 - .filter(|value| !value.contains("EPIPE")); + // 断开 stdout 之后 sidecar 自己会抛 EPIPE;过滤该噪声行,但保留同一份 + // stderr 中其它真正的 provider / Node 错误。 + .map(|value| { + value + .lines() + .filter(|line| !line.contains("EPIPE")) + .collect::>() + .join(" ") + }) + .filter(|value| !value.trim().is_empty()); + let reason = timeout_reason.unwrap_or_else(|| { + if message.trim().is_empty() { + "Claude Agent SDK sidecar 执行失败".to_string() + } else { + message + } + }); let detail = stderr_detail + .into_iter() + .chain(kill_detail) .map(|value| format!(":{value}")) - .unwrap_or_default(); + .collect::(); return Err(format!("{reason}{detail}")); } }; @@ -394,7 +625,9 @@ pub(crate) fn cancel_direct_claude_code_turn_at( } } active.alive.store(false, Ordering::Release); - kill_claude_code_process_tree(active.pid); + if let Err(error) = kill_claude_code_process_tree(active.pid) { + return Err(error); + } let client_turn_id = active.client_turn_id.clone(); Ok(Some(super::codex_app_server::TurnCancelView { outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), @@ -409,7 +642,6 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result Result, isolated_home: &Path, -) { +) -> Result<(), String> { command.env_clear(); for name in [ "PATH", @@ -584,7 +828,8 @@ fn configure_claude_code_environment( copy_env(command, name); } let isolated_claude_home = isolated_home.join("claude"); - let _ = std::fs::create_dir_all(&isolated_claude_home); + std::fs::create_dir_all(&isolated_claude_home) + .map_err(|error| format!("准备 Claude Code 隔离目录失败:{error}"))?; command .env("HOME", isolated_home) // Windows 上 Node 的 `os.homedir()` 只看 `USERPROFILE`;不隔离它,Claude Code 会去读 @@ -596,7 +841,13 @@ fn configure_claude_code_environment( .env("DISABLE_AUTOUPDATER", "1") .env("CI", "1") .env("NO_COLOR", "1") - .env("TERM", "dumb"); + .env("TERM", "dumb") + // 不设这一项,CC 的 MCP 工具调用会回落到 60 秒硬超时,把分钟级的付费生成/试玩 + // 直接取消掉,并让本轮租约未结算、后续工具全部被拒。 + .env( + "MCP_TOOL_TIMEOUT", + CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string(), + ); let session = crate::platform_session::current_platform_session(); let route = claude_code_route(llm, session.as_ref()); app_log!( @@ -632,25 +883,93 @@ fn configure_claude_code_environment( command.env("ANTHROPIC_MODEL", llm.model.trim()); } } + Ok(()) +} + +/// 把 Claude Agent SDK 的 `system/api_error`(自动重试)事件投影成一条过程条目。 +/// +/// SDK 每次自动重试都会带上真实上游状态与正文,但此前这类事件在 `observe` 里被整段丢弃, +/// 结果是重试期间界面只有“正在处理”,直到重试终于成功(或耗尽)才落一条结果——用户看到 +/// 的现象就是“中间过程没有任何输出”。现场证据(2026-10-07 项目 gameagent-0514673b): +/// 14:23:34 起连续 10 次 409「泥点余额不足」重试,14:30:11 才出现唯一一条最终文本, +/// CC 会话轨迹里除这 10 条 `system/api_error` 外没有任何 assistant / tool 事件。 +/// +/// 返回 `(item_id, text)`:同一 attempt 复用同一个 id,重复事件只更新同一条过程行。 +fn claude_api_error_retry_item(event: &serde_json::Value) -> Option<(String, String)> { + let error = event.get("error")?; + let attempt = event + .get("retryAttempt") + .and_then(serde_json::Value::as_u64); + let max_retries = event.get("maxRetries").and_then(serde_json::Value::as_u64); + let status = error + .get("status") + .and_then(serde_json::Value::as_u64) + .or_else(|| event.get("status").and_then(serde_json::Value::as_u64)); + // 优先用 SDK 的 `formatted`(人读形态,例如 `409 泥点余额不足`);它缺失时才回落到 + // `message`(往往是整段上游 JSON,直接给用户看没有价值)。 + let detail = error + .get("formatted") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| claude_result_error_detail(error)) + .unwrap_or_default(); + let detail = match status { + Some(status) if detail.starts_with(&format!("{status} ")) => { + detail[format!("{status} ").len()..].to_string() + } + _ => detail, + }; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + detail.trim(), + 300, + ); + let mut text = String::from("上游请求失败"); + if let Some(status) = status { + text.push_str(&format!("(HTTP {status})")); + } + if !detail.trim().is_empty() { + text.push_str(&format!(":{}", detail.trim())); + } + if let (Some(attempt), Some(max)) = (attempt, max_retries) { + text.push_str(&format!(";正在自动重试(第 {attempt}/{max} 次)")); + } + let item_id = format!("direct-cc:api-error:{}", attempt.unwrap_or(0)); + Some((item_id, text)) } fn claude_result_error_detail(value: &serde_json::Value) -> Option { + fn value_detail(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(detail) => { + let detail = detail.trim(); + (!detail.is_empty()).then(|| detail.chars().take(512).collect()) + } + serde_json::Value::Object(object) => { + let detail = ["message", "detail", "error", "type", "code"] + .into_iter() + .filter_map(|field| object.get(field).and_then(value_detail)) + .collect::>() + .join(";"); + (!detail.is_empty()).then_some(detail) + } + _ => None, + } + } let candidates = [ - value.get("result").and_then(serde_json::Value::as_str), - value.get("error").and_then(serde_json::Value::as_str), - value.get("message").and_then(serde_json::Value::as_str), + value.get("result").and_then(value_detail), + value.get("error").and_then(value_detail), + value.get("message").and_then(value_detail), value .get("errors") .and_then(serde_json::Value::as_array) .and_then(|errors| errors.first()) - .and_then(serde_json::Value::as_str), + .and_then(value_detail), ]; candidates .into_iter() .flatten() - .map(str::trim) .find(|detail| !detail.is_empty()) - .map(|detail| detail.chars().take(512).collect()) } fn configure_claude_code_process(command: &mut tokio::process::Command) { @@ -755,6 +1074,15 @@ fn parse_usage(value: &serde_json::Value) -> Option /// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / /// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + let normalized = detail.to_ascii_lowercase(); + if normalized.contains("maximum number of turns") + || normalized.contains("max turns") + || normalized.contains("最大回合数") + { + return platform_llm::LlmError::InvalidRequest(format!( + "codex-app-server-error:session-budget-exceeded detail={detail}" + )); + } if let Some(status_code) = claude_code_failure_status_code(&detail) { return platform_llm::LlmError::Upstream { status_code, @@ -762,7 +1090,9 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm:: }; } if detail.contains("Claude Agent SDK sidecar 回合超时") { - return platform_llm::LlmError::Timeout { attempts: 1 }; + return platform_llm::LlmError::Timeout { + attempts: claude_direct_retry_attempts(&detail).0, + }; } if detail.contains("Claude Code 缺少最终回复") { return platform_llm::LlmError::EmptyResponse; @@ -774,9 +1104,43 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm:: platform_llm::LlmError::Transport(detail) } +/// 把 cc 的失败投影到 Direct 回合错误;平台 `LlmError::Timeout` / `EmptyResponse` 本身没有 +/// detail 字段,因此这两类必须在这里把 sidecar 的原始原因留在统一 `ModelCallFailed.detail` 中, +/// 否则 stderr、超时阶段和缺失字段会在类型转换时丢掉,前端又只能显示固定兜底句。 +pub(crate) fn claude_code_failure_to_turn_error( + detail: String, +) -> super::codex_app_server::turn_error::TurnError { + use super::codex_app_server::turn_error::{ModelCallFailed, ModelCallKind, TurnError}; + + // 宿主自动重试的次数是结构化事实(`attempts`),不能留在给用户看的详情里。 + let (attempts, detail) = claude_direct_retry_attempts(&detail); + let llm_error = claude_code_failure_to_llm_error(detail.clone()); + match llm_error { + platform_llm::LlmError::Timeout { .. } => TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::ResponseTimedOut { attempts }, + detail, + }), + platform_llm::LlmError::EmptyResponse => TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::EmptyResponse, + detail, + }), + other => TurnError::from_model_call(&other), + } +} + fn claude_code_failure_status_code(detail: &str) -> Option { - ["Request rejected (", "HTTP "].iter().find_map(|marker| { - let tail = detail.split_once(marker)?.1; + let normalized = detail.to_ascii_lowercase(); + [ + "request rejected (", + "http ", + "status ", + "status=", + "status_code=", + "statuscode=", + ] + .iter() + .find_map(|marker| { + let tail = normalized.split_once(marker)?.1; let digits = tail .chars() .take_while(|character| character.is_ascii_digit()) @@ -793,8 +1157,15 @@ fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, ) -> Result { - let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|_| { - platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string()) + let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(stdout), + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code JSON 输出无效:{error};原文={detail}" + )) })?; if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { let detail = claude_result_error_detail(&value) @@ -821,20 +1192,33 @@ fn parse_claude_code_result( } (result, Vec::new()) } else { - let envelope = structured - .or_else(|| serde_json::from_str(&result).ok()) - .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺失".to_string(), - ) - })?; + let envelope = match structured { + Some(value) => value, + None => serde_json::from_str(&result).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &result, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output JSON 无效:{error};原文={detail}" + )) + })?, + }; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺少 toolCalls".to_string(), - ) + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".into()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output 缺少 toolCalls;原文={detail}" + )) })?; let calls = calls .iter() @@ -844,17 +1228,20 @@ fn parse_claude_code_result( .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 name".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 name;原文={}", + index.saturating_add(1), + serde_json::to_string(call).unwrap_or_else(|_| "<不可序列化>".into()) + )) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 arguments".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( @@ -924,6 +1311,7 @@ pub(in crate::agent) async fn request_game_creator_agent_claude_code_cli( .request_timeout_ms .unwrap_or(config.llm.request_timeout_ms), None, + None, ) .await .map_err(platform_llm::LlmError::Transport)?; @@ -950,98 +1338,640 @@ pub(crate) async fn direct_game_creator_claude_code_home_chat( .map_err(|error| error.to_string()) } -/// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback -/// MCP,关闭所有内置工具。它先作为最小可用执行器接入,后续可在同一观察协议上补充 -/// 更细的 Claude tool_use 活动投影。 -pub(crate) async fn direct_game_creator_claude_code_chat_at( - root: &Path, - llm: &GameCreatorLlmConfig, - system_prompt: String, - user_prompt: String, - client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, -) -> Result { - direct_turn_trace("claude-executor-enter"); - let (mcp_url, mcp_token) = - start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; - direct_turn_trace("claude-mcp-loopback-ready"); - let _mcp_guard = ExternalMcpStopGuard { - root: root.to_path_buf(), - token: mcp_token.clone(), +#[derive(Debug)] +struct ClaudeCodeToolState { + name: String, + arguments: String, + started: bool, + completed: bool, +} + +#[derive(Default, Debug)] +struct ClaudeCodeStreamState { + current_message_id: String, + /// 内容块 → 过程条目 id。 + /// + /// 键必须带内容块类型:Claude Code 会把同一条 message 的内容块拆成多条 `assistant` + /// 事件(每条只含一个块),这些事件在各自数组里的下标都是 0。只按 `(message_id, index)` + /// 记忆,会让同一条消息里的 thinking 与 text 抢同一个 id(现场:`…:thinking:0` 先落盘, + /// 随后 text 复用该 id 且内容不同,历史层判冲突并让整轮变成“过程历史保存失败”)。 + block_item_ids: HashMap<(String, String, usize), String>, + block_tool_ids: HashMap<(String, usize), String>, + tool_calls: HashMap, + history_error: Option, + partial_history: DirectProjectHistoryAccumulator, + assistant_texts: Vec, + result_text: Option, + result_error: Option, +} + +fn claude_value_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Array(parts) => parts + .iter() + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"), + serde_json::Value::Object(object) => object + .get("text") + .or_else(|| object.get("thinking")) + .or_else(|| object.get("content")) + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .unwrap_or_else(|| serde_json::to_string_pretty(value).unwrap_or_default()), + other => serde_json::to_string_pretty(other).unwrap_or_default(), + } +} + +fn claude_content_text(block: &serde_json::Value) -> String { + block + .get("text") + .or_else(|| block.get("thinking")) + .or_else(|| block.get("content")) + .map(claude_value_text) + .unwrap_or_default() +} + +fn claude_tool_arguments(value: Option<&serde_json::Value>) -> String { + let Some(value) = value else { + return String::new(); }; - let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; - std::fs::create_dir_all(&home) - .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?; - let session_key = claude_project_key(root); - let resume = claude_direct_sessions() - .lock() - .ok() - .and_then(|sessions| sessions.get(&session_key).cloned()); - direct_turn_trace("claude-sidecar-start"); - let payload = serde_json::json!({ - "type": "turn", - "id": client_turn_id.unwrap_or("claude-direct-turn"), - "sessionKey": format!("agc-direct:{}", session_key.display()), - "resume": resume, - "prompt": user_prompt, - "systemPrompt": system_prompt, - "cwd": root, - "model": llm.model.trim(), - "maxTurns": 8, - "mcpServers": { - "agc": { - "type": "http", - "url": mcp_url, - "headers": { "Authorization": format!("Bearer {mcp_token}") } + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + other => serde_json::to_string(other).unwrap_or_default(), + } +} + +fn claude_stream_observe( + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + observation: TurnObservation, +) { + if let Some(observer) = observer.as_deref_mut() { + observer(observation); + } +} + +/// 过程条目 id 撞车时的兜底条目:保留原内容,只把 id 换成带唯一后缀的取值。 +/// +/// 历史层只接受“同 id 同内容”的重复写;同 id 不同内容一律判冲突。Claude Code 会把同一条 +/// message 的内容块拆成多条 `assistant` 事件,因此仍有无法用 `(message_id, kind, index)` +/// 区分的情况(例如同一条消息里的两个 text 块)。撞车时宁可多留一条过程历史,也不能让已经 +/// 跑完的整轮被判成失败。缺失 id 时给一个自说明的兜底 id,保证追加一定能拿到唯一键。 +fn completed_item_conflict_fallback(item: &serde_json::Value) -> serde_json::Value { + let mut retried = item.clone(); + let fallback_id = match item.get("id").and_then(serde_json::Value::as_str) { + Some(id) if !id.trim().is_empty() => format!("{id}:{}", uuid::Uuid::new_v4()), + _ => format!("direct-cc:item:{}", uuid::Uuid::new_v4()), + }; + if let Some(object) = retried.as_object_mut() { + object.insert("id".to_string(), serde_json::Value::String(fallback_id)); + } + retried +} + +impl ClaudeCodeStreamState { + /// 已完成过程与 Codex 共用项目历史出口,收到时即保存;终态失败不能撤掉前面已发生的事实。 + fn persist_completed_item(&mut self, root: &Path, item: &serde_json::Value) { + match append_direct_project_history_item_at(root, item) { + Ok(()) => {} + Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => { + // 同一个 message id 的内容块会在多条 `assistant` 事件里重复出现,条目 id 一旦 + // 与已有条目“同 id 不同内容”,历史层会判冲突。这里按收尾回复的既有做法补一个 + // 唯一后缀:把 id 撞车降级成多留一条过程历史,而不是让整轮变成过程历史保存失败。 + let retried = completed_item_conflict_fallback(item); + if let Err(retry_error) = append_direct_project_history_item_at(root, &retried) { + self.history_error.get_or_insert(format!( + "过程历史追加失败:{retry_error}(首个条目冲突:{error})" + )); + } + } + Err(error) => { + self.history_error.get_or_insert(error); } } - }); - let sidecar = run_sidecar_turn( - root, - Some(llm), - &home, - payload, - llm.request_timeout_ms, - client_turn_id, - ) - .await?; - direct_turn_trace("claude-sidecar-done"); - if let Some(session_id) = sidecar - .result - .get("session_id") - .and_then(serde_json::Value::as_str) - { - if let Ok(mut sessions) = claude_direct_sessions().lock() { - sessions.insert(session_key, session_id.to_string()); + } + + fn persist_partial_history(&mut self, root: &Path) { + if let Err(error) = persist_direct_project_partial_items_at(root, &mut self.partial_history) + { + self.history_error.get_or_insert(error); } } - let mut stream = sidecar - .events - .into_iter() - .chain(std::iter::once(sidecar.result)) - .filter_map(|event| serde_json::to_string(&event).ok()) - .collect::>(); - stream.push(String::new()); - direct_turn_trace("claude-parse-start"); - let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer); - match &parsed { - Ok(text) => { - direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())); - // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 - // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 - // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 - let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text) - { - Ok(item_id) => item_id, - Err(error) => { - eprintln!("[agc-cc-direct] persist assistant failed: {error}"); - direct_turn_trace("claude-parse-error"); - return Err(format!("写入本项目对话历史失败:{error}")); + + fn block_item_id(&mut self, message_id: &str, index: usize, kind: &str) -> String { + self.block_item_ids + .entry((message_id.to_string(), kind.to_string(), index)) + .or_insert_with(|| format!("direct-cc:{message_id}:{kind}:{index}")) + .clone() + } + + fn append_delta( + &mut self, + root: &Path, + item_id: String, + kind: ThreadDeltaKind, + delta: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + if delta.is_empty() { + return; + } + self.partial_history.observe_delta(&item_id, &delta); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_delta(item_id, kind, delta.clone()), + ); + match kind { + ThreadDeltaKind::Message => { + claude_stream_observe(observer, TurnObservation::AccumulatedText(delta)); + } + ThreadDeltaKind::Reasoning => { + claude_stream_observe(observer, TurnObservation::Reasoning(delta)); + } + } + } + + fn start_tool( + &mut self, + root: &Path, + tool_id: String, + name: String, + arguments: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let arguments = if arguments.trim().is_empty() || arguments.trim() == "{}" { + String::new() + } else { + arguments + }; + let entry = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: name.clone(), + arguments: String::new(), + started: false, + completed: false, + }); + if !name.trim().is_empty() { + entry.name = name; + } + let needs_update = !arguments.is_empty() && entry.arguments != arguments; + if !arguments.is_empty() { + entry.arguments = arguments; + } + if entry.completed || (entry.started && !needs_update) { + return; + } + entry.started = true; + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_started( + ThreadItem::McpToolCall { + item_id: tool_id, + tool: entry.name.clone(), + arguments: entry.arguments.clone(), + output: None, + status: Some("inProgress".to_string()), + at, + }, + at, + ), + ); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn complete_tool( + &mut self, + root: &Path, + tool_id: String, + output: String, + failed: bool, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let state = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: "MCP 工具".to_string(), + arguments: "{}".to_string(), + started: false, + completed: false, + }); + if state.completed { + return; + } + state.completed = true; + let status = if failed { "failed" } else { "completed" }; + let at = crate::agent::now_ms(); + let item = ThreadItem::McpToolCall { + item_id: tool_id.clone(), + tool: state.name.clone(), + arguments: state.arguments.clone(), + output: Some(output.clone()), + status: Some(status.to_string()), + at, + }; + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed(item, at), + ); + let history_item = serde_json::json!({ + "type": "mcpToolCall", + "id": tool_id, + "tool": state.name.clone(), + "arguments": state.arguments.clone(), + "result": output, + "status": status, + }); + self.persist_completed_item(root, &history_item); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn handle_stream_event( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(raw_event) = event.get("event") else { + return; + }; + match raw_event.get("type").and_then(serde_json::Value::as_str) { + Some("message_start") => { + self.current_message_id = raw_event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + } + Some("content_block_start") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(block) = raw_event.get("content_block") else { + return; + }; + let kind = block + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("block"); + if kind == "tool_use" { + let tool_id = block + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if tool_id.is_empty() { + return; + } + self.block_tool_ids + .insert((self.current_message_id.clone(), index), tool_id.clone()); + self.start_tool( + root, + tool_id, + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } else if !self.current_message_id.is_empty() { + self.block_item_id(&self.current_message_id.clone(), index, kind); } + } + Some("content_block_delta") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(delta) = raw_event.get("delta") else { + return; + }; + match delta.get("type").and_then(serde_json::Value::as_str) { + Some("text_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "text"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Message, + delta + .get("text") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("thinking_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "thinking"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Reasoning, + delta + .get("thinking") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("input_json_delta") => { + let Some(tool_id) = self + .block_tool_ids + .get(&(self.current_message_id.clone(), index)) + .cloned() + else { + return; + }; + if let Some(partial) = delta + .get("partial_json") + .and_then(serde_json::Value::as_str) + { + if let Some(tool) = self.tool_calls.get_mut(&tool_id) { + if tool.arguments == "{}" { + tool.arguments.clear(); + } + tool.arguments.push_str(partial); + } + } + } + _ => {} + } + } + _ => {} + } + } + + fn handle_assistant( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let message_id = event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| { + event + .get("uuid") + .and_then(serde_json::Value::as_str) + .unwrap_or("cc-message") + }); + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for (index, block) in content.iter().enumerate() { + match block.get("type").and_then(serde_json::Value::as_str) { + Some("text") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "text"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id.clone()})); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id: item_id.clone(), + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }), + ); + self.assistant_texts.push(text); + claude_stream_observe( + observer, + TurnObservation::AccumulatedText( + self.assistant_texts.last().cloned().unwrap_or_default(), + ), + ); + } + Some("thinking") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "thinking"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id})); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Reasoning { + item_id: item_id.clone(), + text: text.clone(), + at, + }, + at, + ), + ); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "reasoning", + "id": item_id, + "text": text, + }), + ); + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + Some("tool_use") => { + let Some(tool_id) = block.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + self.start_tool( + root, + tool_id.to_string(), + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } + _ => {} + } + } + } + + fn handle_user( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for block in content { + if block.get("type").and_then(serde_json::Value::as_str) != Some("tool_result") { + continue; + } + let Some(tool_id) = block + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + else { + continue; }; - // 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed - // 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。 - // 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。 + self.complete_tool( + root, + tool_id.to_string(), + claude_content_text(block), + block + .get("is_error") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + observer, + ); + } + } + + fn observe( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + match event.get("type").and_then(serde_json::Value::as_str) { + Some("stream_event") => self.handle_stream_event(root, event, observer), + Some("assistant") => self.handle_assistant(root, event, observer), + Some("user") => self.handle_user(root, event, observer), + Some("tool_progress") => { + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + Some("system") => match event.get("subtype").and_then(serde_json::Value::as_str) { + Some("compact_boundary") => { + claude_stream_observe( + observer, + TurnObservation::Activity("context-compaction"), + ); + } + Some("permission_denied") => { + if let Some(tool_id) = event + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + { + self.complete_tool( + root, + tool_id.to_string(), + event + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("工具调用被拒绝") + .to_string(), + true, + observer, + ); + } + } + Some("thinking_tokens") => { + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + Some("api_error") => { + // 重试/上游失败必须有一条可见的过程行:否则重试期间聊天区完全静默, + // 用户只会看到“正在处理”,拿不到 HTTP 状态与上游正文(见本函数上方注释)。 + if let Some((item_id, text)) = claude_api_error_retry_item(event) { + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "system".to_string(), + text, + at, + }, + at, + ), + ); + } + } + _ => {} + }, + Some("result") => { + if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { + self.result_error = claude_result_error_detail(event); + } else { + self.result_text = event + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + } + _ => {} + } + } + + fn finish( + mut self, + root: &Path, + terminal: &serde_json::Value, + client_turn_id: Option<&str>, + ) -> Result { + if self.result_error.is_none() + && terminal + .get("is_error") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + self.result_error = claude_result_error_detail(terminal); + } + if let Some(detail) = self.result_error.clone() { + self.persist_partial_history(root); + return Err(self.with_history_error(format!("Claude Code 返回失败终态:{detail}"))); + } + if let Some(error) = &self.history_error { + return Err(format!("Claude Code 过程历史保存失败:{error}")); + } + let text = self + .result_text + .or_else(|| { + terminal + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Claude Code 缺少最终回复".to_string())?; + if self.assistant_texts.last().map(String::as_str) != Some(text.as_str()) { + let item_id = persist_direct_claude_assistant_reply_at(root, client_turn_id, &text)?; let at = crate::agent::now_ms(); crate::agent::append_thread_event( &crate::agent::thread_id_for_project(root), @@ -1056,14 +1986,369 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( ), ); } + Ok(text) + } + + fn with_history_error(&self, original: String) -> String { + match &self.history_error { + Some(error) => format!("{original};Claude Code 过程历史保存失败:{error}"), + None => original, + } + } + + /// 这一轮值不值得宿主自动重放。 + /// + /// 只有"侧车回合超时"且"从头到尾没有请求过任何工具"两个条件同时成立才是安全的:模型一旦 + /// 发过 tool_use,重跑就可能把付费生成、构建、试玩这些带副作用的调用再执行一遍(哪怕结果 + /// 还没回来,操作也可能已经在途)。其余失败属于凭据 / 额度 / 协议这类重试无意义的分类。 + /// + /// 只认**静默超时**(连续 N 毫秒一个事件都没有 = 上游挂住);45 分钟硬上限是失控回合, + /// 叠三次重试会把一次用户回合拖到两小时以上。 + fn is_auto_retryable_timeout(&self, error: &str) -> bool { + error.contains("Claude Agent SDK sidecar 回合超时:连续 ") + && error.contains("没有任何事件") + && self.tool_calls.is_empty() + } +} + +/// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback +/// MCP,关闭所有内置工具。CC 的 SDK 事件在读取时投影到与 Codex 相同的 Thread Manager 合同。 +/// DirectProject 的 CC 回合请求载荷。 +/// +/// 模型的全部工具能力只靠 `mcpServers` 下发:宿主把 loopback MCP 端点交给 Claude Agent +/// SDK,SDK 再以 `mcp__agc__*` 暴露给模型。载荷一旦漏掉这项,模型就退化成"没有任何工具、 +/// 只能把代码贴在聊天里"的纯聊天执行器——现场证据:2026-10-07 安装版 0.1.227 的 CC 回合, +/// 会话轨迹里零 tool_use,模型原话"我这里没有可用的工具:❌ 写文件 / ❌ 读取你的项目目录"。 +/// 这里把载荷抽成纯函数并配单测:构建或合并再丢掉这项接线时,测试会直接失败。 +#[allow(clippy::too_many_arguments)] +fn claude_direct_turn_payload( + client_turn_id: Option<&str>, + session_key: &str, + resume: Option, + user_prompt: &str, + system_prompt: &str, + cwd: &Path, + model: &str, + max_turns: u32, + mcp_url: &str, + mcp_token: &str, +) -> serde_json::Value { + serde_json::json!({ + "type": "turn", + "id": client_turn_id.unwrap_or("claude-direct-turn"), + "sessionKey": session_key, + "resume": resume, + "prompt": user_prompt, + "systemPrompt": system_prompt, + "cwd": cwd, + "model": model, + "maxTurns": max_turns, + "mcpServers": { + "agc": { + "type": "http", + "url": mcp_url, + "headers": { "Authorization": format!("Bearer {mcp_token}") } + } + } + }) +} + +pub(crate) async fn direct_game_creator_claude_code_chat_at( + root: &Path, + llm: &GameCreatorLlmConfig, + system_prompt: String, + user_prompt: String, + client_turn_id: Option<&str>, + mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, +) -> Result { + direct_turn_trace("claude-executor-enter"); + let mut attempt = 1_usize; + loop { + let outcome = claude_direct_turn_once( + root, + llm, + &system_prompt, + &user_prompt, + client_turn_id, + &mut observer, + ) + .await; + let failure = match outcome { + Ok(text) => return Ok(text), + Err(failure) => failure, + }; + let retries = attempt.saturating_sub(1); + match claude_direct_failure_action(&failure, attempt) { + ClaudeDirectFailureAction::Retry => { + attempt += 1; + app_log!( + "agent.direct_codex.claude_turn_retry attempt={} max={}", + attempt, + CLAUDE_DIRECT_TURN_MAX_ATTEMPTS + ); + emit_claude_direct_process_notice( + root, + client_turn_id, + &format!( + "等待模型回执超时,正在自动重试(第 {attempt}/{} 次)", + CLAUDE_DIRECT_TURN_MAX_ATTEMPTS + ), + "retry", + attempt, + ); + tokio::time::sleep(CLAUDE_DIRECT_RETRY_BACKOFF).await; + continue; + } + ClaudeDirectFailureAction::Report => { + return Err(failure.message); + } + action => { + // 停下来时也要留一条可见过程行:别让"没有重试"看起来像宿主又悄悄兜底了。 + let notice = match action { + ClaudeDirectFailureAction::StopRetriesExhausted => { + format!("等待模型回执超时,已自动重试 {retries} 次仍未成功;请稍后重试") + } + ClaudeDirectFailureAction::StopToolsRequested => { + "等待模型回执超时,但本轮已经请求过工具,不会自动重放;请手动重试" + .to_string() + } + _ => "等待模型回执超时,已超过本轮硬上限,不再自动重试;请稍后重试".to_string(), + }; + emit_claude_direct_process_notice(root, client_turn_id, ¬ice, "stop", attempt); + return Err(with_claude_direct_retry_attempts(failure.message, retries)); + } + } + } +} + +/// 一次 cc 回合的失败事实。 +/// +/// `retryable` 只在"这一轮从头到尾没有请求过任何工具"时才成立:重跑一轮会重新触发付费生成、 +/// 构建、试玩这些带副作用的调用,所以只有完全没碰过工具的超时才允许宿主自动重放。 +struct ClaudeDirectTurnFailure { + message: String, + kind: ClaudeDirectFailureKind, + retryable: bool, + /// 本轮是否已经请求过工具(哪怕结果还没回来)。 + tools_requested: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ClaudeDirectFailureKind { + /// sidecar 回合超时(静默超时或硬上限)。 + Timeout, + /// 其它失败:sidecar 报错、非零退出、解析失败、启动失败等。 + Other, +} + +impl ClaudeDirectTurnFailure { + fn fatal(message: String) -> Self { + Self { + message, + kind: ClaudeDirectFailureKind::Other, + retryable: false, + tools_requested: false, + } + } +} + +/// 宿主对一次 cc 回合失败的处理决定。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ClaudeDirectFailureAction { + /// 自动重放这一轮。 + Retry, + /// 用满尝试次数后停下。 + StopRetriesExhausted, + /// 本轮请求过工具,重放会重新触发副作用,交回用户决定。 + StopToolsRequested, + /// 不可重放的超时(例如 45 分钟硬上限)。 + StopNotReplayable, + /// 非超时失败:原样上报,不改写既有分类。 + Report, +} + +/// 重试策略是纯函数:什么情况重放、什么情况停下都能被单测逐格钉住。 +fn claude_direct_failure_action( + failure: &ClaudeDirectTurnFailure, + attempt: usize, +) -> ClaudeDirectFailureAction { + if failure.kind != ClaudeDirectFailureKind::Timeout { + return ClaudeDirectFailureAction::Report; + } + if failure.retryable { + return if attempt < CLAUDE_DIRECT_TURN_MAX_ATTEMPTS { + ClaudeDirectFailureAction::Retry + } else { + ClaudeDirectFailureAction::StopRetriesExhausted + }; + } + if failure.tools_requested { + ClaudeDirectFailureAction::StopToolsRequested + } else { + ClaudeDirectFailureAction::StopNotReplayable + } +} + +/// 每次自动重试都留一条可见过程行:用户看到的是"在重试",而不是一个卡住或莫名失败的回合。 +fn emit_claude_direct_process_notice( + root: &Path, + client_turn_id: Option<&str>, + text: &str, + tag: &str, + attempt: usize, +) { + let at = crate::agent::now_ms(); + let item_id = match client_turn_id { + Some(client_turn_id) => { + format!("direct-codex:{client_turn_id}:claude-turn-{tag}:{attempt}") + } + None => format!( + "direct-codex:{}:claude-turn-{tag}:{attempt}", + uuid::Uuid::new_v4() + ), + }; + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "system".to_string(), + text: text.to_string(), + at, + }, + at, + ), + ); +} + +/// 把"自动重试了几次"写进失败详情,供上层还原成结构化的 `attempts`。 +fn with_claude_direct_retry_attempts(message: String, retries: usize) -> String { + if retries == 0 { + return message; + } + format!("{message}{CLAUDE_DIRECT_RETRY_ATTEMPTS_MARKER}{retries} 次") +} + +/// 解出 `(已尝试次数, 去掉标记的失败详情)`:标记是宿主自己写的,用户可见详情里不该出现。 +fn claude_direct_retry_attempts(detail: &str) -> (u32, String) { + match detail.split_once(CLAUDE_DIRECT_RETRY_ATTEMPTS_MARKER) { + Some((head, tail)) => { + let retries = tail + .split(' ') + .next() + .and_then(|digits| digits.parse::().ok()) + .unwrap_or(0); + (retries.saturating_add(1).max(1), head.to_string()) + } + None => (1, detail.to_string()), + } +} + +/// 单次 cc 回合:起 loopback MCP、跑 sidecar、投影事件、收口成回复文本。 +async fn claude_direct_turn_once( + root: &Path, + llm: &GameCreatorLlmConfig, + system_prompt: &str, + user_prompt: &str, + client_turn_id: Option<&str>, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, +) -> Result { + let (mcp_url, mcp_token) = + start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled) + .await + .map_err(ClaudeDirectTurnFailure::fatal)?; + direct_turn_trace("claude-mcp-loopback-ready"); + let _mcp_guard = ExternalMcpStopGuard { + root: root.to_path_buf(), + token: mcp_token.clone(), + }; + let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home") + .map_err(ClaudeDirectTurnFailure::fatal)?; + std::fs::create_dir_all(&home) + .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}")) + .map_err(ClaudeDirectTurnFailure::fatal)?; + let session_key = claude_project_key(root); + let (resume, resume_source) = + resolve_claude_direct_resume(root, &home).map_err(ClaudeDirectTurnFailure::fatal)?; + app_log!( + "agent.direct_codex.claude_resume source={} session={}", + resume_source, + resume + .as_deref() + .map(|session_id| session_id.chars().take(8).collect::()) + .unwrap_or_else(|| "none".to_string()) + ); + direct_turn_trace("claude-sidecar-start"); + let payload = claude_direct_turn_payload( + client_turn_id, + &format!("agc-direct:{}", session_key.display()), + resume, + user_prompt, + system_prompt, + root, + llm.model.trim(), + CLAUDE_DIRECT_MAX_TURNS, + &mcp_url, + &mcp_token, + ); + let mut stream_state = ClaudeCodeStreamState::default(); + let mut on_event = |event: &serde_json::Value| { + stream_state.observe(root, event, observer); + }; + let sidecar_result = run_sidecar_turn( + root, + Some(llm), + &home, + payload, + llm.request_timeout_ms, + client_turn_id, + Some(&mut on_event), + ) + .await; + let sidecar = match sidecar_result { + Ok(sidecar) => sidecar, Err(error) => { - // 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉, - // 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。 - eprintln!("[agc-cc-direct] parse failed: {error}"); + let kind = if error.contains("Claude Agent SDK sidecar 回合超时") { + ClaudeDirectFailureKind::Timeout + } else { + ClaudeDirectFailureKind::Other + }; + let tools_requested = !stream_state.tool_calls.is_empty(); + let retryable = stream_state.is_auto_retryable_timeout(&error); + stream_state.persist_partial_history(root); + return Err(ClaudeDirectTurnFailure { + message: stream_state.with_history_error(error), + kind, + retryable, + tools_requested, + }); + } + }; + direct_turn_trace("claude-sidecar-done"); + if let Some(session_id) = sidecar + .result + .get("session_id") + .and_then(serde_json::Value::as_str) + { + if let Ok(mut sessions) = claude_direct_sessions().lock() { + sessions.insert(session_key, session_id.to_string()); + } + } + direct_turn_trace("claude-parse-start"); + let parsed = stream_state.finish(root, &sidecar.result, client_turn_id); + match &parsed { + Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())), + Err(error) => { + // 统一进入应用日志的精确脱敏出口;不再把原始 parse detail 直接写 stderr,避免 + // 凭据/路径随 cc 收尾错误绕过 Runtime 诊断合同。 + app_log!( + "agent.direct_codex.claude_parse_failed detail={}", + crate::agent::redact_agent_runtime_error(root, &error, 600) + ); direct_turn_trace("claude-parse-error"); } } - parsed + parsed.map_err(ClaudeDirectTurnFailure::fatal) } fn persist_direct_claude_assistant_reply_at( @@ -1098,58 +2383,6 @@ fn persist_direct_claude_assistant_reply_at( } } -fn parse_direct_stream_result( - stdout: &[u8], - mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, -) -> Result { - let text = std::str::from_utf8(stdout) - .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; - let mut result = None; - for line in text.lines().filter(|line| !line.trim().is_empty()) { - let event: serde_json::Value = serde_json::from_str(line) - .map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?; - let event_type = event.get("type").and_then(serde_json::Value::as_str); - if event_type == Some("assistant") { - let content = event - .pointer("/message/content") - .and_then(serde_json::Value::as_array); - if let Some(content) = content { - let visible = content - .iter() - .filter_map(|part| { - (part.get("type").and_then(serde_json::Value::as_str) == Some("text")).then( - || { - part.get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - }, - ) - }) - .collect::(); - if !visible.is_empty() { - if let Some(observer) = observer.as_deref_mut() { - observer(TurnObservation::AgentMessageSegment(visible)); - } - } - } - } else if event_type == Some("result") { - if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - let detail = claude_result_error_detail(&event) - .map(|detail| format!(":{detail}")) - .unwrap_or_default(); - return Err(format!("Claude Code 返回失败终态{detail}")); - } - result = event - .get("result") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - } - } - result - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Claude Code 缺少最终回复".to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -1168,6 +2401,119 @@ mod tests { request } + #[test] + fn claude_direct_turn_payload_wires_loopback_mcp_tools() { + let payload = claude_direct_turn_payload( + Some("turn-1"), + "agc-direct:C:/project", + None, + "做个废土风扫雷", + "系统提示", + Path::new("C:/project"), + "claude-opus-5-5", + 16, + "http://127.0.0.1:1234/mcp-abc", + "token-1", + ); + + assert_eq!(payload["type"], "turn"); + assert_eq!(payload["id"], "turn-1"); + assert_eq!(payload["sessionKey"], "agc-direct:C:/project"); + assert_eq!(payload["maxTurns"], 16); + // 工具能力只靠这一项下发:漏了就退化成“没有工具的纯聊天”, + // 模型再也无法写文件 / 跑构建(见函数上方现场记录)。 + assert_eq!(payload["mcpServers"]["agc"]["type"], "http"); + assert_eq!( + payload["mcpServers"]["agc"]["url"], + "http://127.0.0.1:1234/mcp-abc" + ); + assert_eq!( + payload["mcpServers"]["agc"]["headers"]["Authorization"], + "Bearer token-1" + ); + } + + #[test] + fn claude_api_error_retry_item_surfaces_status_and_upstream_body() { + let event = serde_json::json!({ + "type": "system", + "subtype": "api_error", + "error": { + "message": "409 {\"error\":{\"code\":\"MUD_POINTS_INSUFFICIENT\",\"message\":\"泥点余额不足\"}}", + "status": 409, + "formatted": "409 泥点余额不足" + }, + "retryAttempt": 3, + "maxRetries": 10, + "source": "request_retry" + }); + + let (item_id, text) = + claude_api_error_retry_item(&event).expect("api_error 事件必须投影出过程行"); + assert_eq!(item_id, "direct-cc:api-error:3"); + assert!(text.contains("HTTP 409"), "{text}"); + assert!(text.contains("泥点余额不足"), "{text}"); + assert!(text.contains("第 3/10 次"), "{text}"); + // 同一次尝试复用同一条过程行:事件重放不会多出一条。 + assert_eq!( + claude_api_error_retry_item(&event).map(|item| item.0), + Some(item_id) + ); + // 没有错误载荷的事件不投影,避免造出一条没有正文的失败行。 + assert!(claude_api_error_retry_item( + &serde_json::json!({ "type": "system", "subtype": "api_error" }) + ) + .is_none()); + } + + #[test] + fn claude_block_item_id_keeps_thinking_and_text_apart() { + let mut state = ClaudeCodeStreamState::default(); + let thinking = state.block_item_id("msg_1", 0, "thinking"); + let text = state.block_item_id("msg_1", 0, "text"); + + assert_eq!(thinking, "direct-cc:msg_1:thinking:0"); + assert_eq!(text, "direct-cc:msg_1:text:0"); + assert_ne!(thinking, text); + // 同一个块重复出现(Claude Code 按块分事件重发同一 message)必须复用同一个 id。 + assert_eq!(state.block_item_id("msg_1", 0, "thinking"), thinking); + assert_eq!(state.block_item_id("msg_1", 0, "text"), text); + } + + #[test] + fn claude_completed_item_conflict_fallback_keeps_content_and_unique_id() { + let item = serde_json::json!({ + "type": "reasoning", + "id": "direct-cc:msg_1:thinking:0", + "text": "已经落盘过的思考内容", + }); + let fallback = completed_item_conflict_fallback(&item); + + assert_eq!(fallback["type"], item["type"]); + assert_eq!(fallback["text"], item["text"]); + let fallback_id = fallback["id"].as_str().expect("fallback id"); + assert_ne!(fallback_id, "direct-cc:msg_1:thinking:0"); + assert!( + fallback_id.starts_with("direct-cc:msg_1:thinking:0:"), + "兜底 id 必须保留原 id 便于排查:{fallback_id}" + ); + assert_ne!( + fallback["id"], + completed_item_conflict_fallback(&item)["id"], + "兜底 id 每次都必须是新的唯一取值" + ); + + let without_id = serde_json::json!({ "type": "reasoning", "text": "无 id" }); + let fallback_without_id = completed_item_conflict_fallback(&without_id); + assert!( + fallback_without_id["id"] + .as_str() + .expect("fallback id") + .starts_with("direct-cc:item:"), + "缺少 id 时也要给出自说明的兜底 id" + ); + } + #[test] fn parses_claude_code_text_result() { let response = parse_claude_code_result( @@ -1202,6 +2548,63 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn preserves_claude_invalid_json_and_object_error_details() { + let invalid = parse_claude_code_result( + br#"{"is_error":false,"result":"unterminated""#, + &request(false), + ) + .expect_err("invalid JSON must be rejected"); + let invalid = invalid.to_string(); + assert!(invalid.contains("Claude Code JSON 输出无效"), "{invalid}"); + assert!(invalid.contains("line"), "{invalid}"); + assert!(invalid.contains("unterminated"), "{invalid}"); + + let object_error = parse_claude_code_result( + br#"{"is_error":true,"error":{"type":"invalid_request_error","message":"HTTP 422: bad schema"}}"#, + &request(false), + ) + .expect_err("object error must be rejected"); + assert!( + object_error.to_string().contains("HTTP 422: bad schema"), + "{object_error}" + ); + } + + #[test] + fn cc_failure_projection_preserves_timeout_and_empty_response_detail() { + let timeout = super::claude_code_failure_to_turn_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件;stderr=OOM".into(), + ); + match timeout { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::ResponseTimedOut { + attempts: 1 + } + )); + assert!(payload.detail.contains("180000")); + assert!(payload.detail.contains("OOM")); + } + other => panic!("expected timed out model failure, got {other:?}"), + } + + let empty = super::claude_code_failure_to_turn_error( + "Claude Code 缺少最终回复;sidecar result 缺少 result 字段".into(), + ); + match empty { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::EmptyResponse + )); + assert!(payload.detail.contains("缺少 result 字段")); + } + other => panic!("expected empty response model failure, got {other:?}"), + } + } + #[test] fn claude_failure_reuses_codex_error_categories() { assert!(matches!( @@ -1223,36 +2626,202 @@ mod tests { claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), platform_llm::LlmError::EmptyResponse )); - } - - #[test] - fn parses_direct_stream_result_and_final_text() { - let mut observed = Vec::new(); - let text = parse_direct_stream_result( - r#"{"type":"system","subtype":"init","session_id":"s"} -{"type":"assistant","uuid":"a","message":{"content":[{"type":"text","text":"中间"}]}} -{"type":"result","is_error":false,"result":"最终回复"} -"# - .as_bytes(), - Some(&mut |event| observed.push(event)), - ) - .expect("stream result"); - assert_eq!(text, "最终回复"); assert!(matches!( - observed.as_slice(), - [TurnObservation::AgentMessageSegment(_)] + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:HTTP 409 session already active".into() + ), + platform_llm::LlmError::Upstream { + status_code: 409, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK 返回错误:status_code=401 invalid token".into() + ), + platform_llm::LlmError::Upstream { + status_code: 401, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:Reached maximum number of turns (8)".into() + ), + platform_llm::LlmError::InvalidRequest(message) + if message.contains("codex-app-server-error:session-budget-exceeded") )); } #[test] - fn preserves_direct_stream_terminal_error_detail() { - let error = parse_direct_stream_result( - br#"{"type":"result","is_error":true,"result":"Authentication failed"} -"#, - None, - ) - .expect_err("failed terminal must be rejected"); - assert!(error.contains("Authentication failed")); + fn projects_claude_stream_text_and_mcp_tool_lifecycle_into_thread_events() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-stream", "CC 流式投影") + .expect("init project"); + let thread_id = crate::agent::thread_id_for_project(root.path()); + crate::agent::append_thread_event( + &thread_id, + ThreadEvent::turn_started(crate::agent::now_ms()), + ); + let subscription = crate::agent::thread_manager::subscribe_thread(&thread_id); + let mut observed = Vec::new(); + let mut observer_fn = |event| observed.push(event); + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + let mut state = ClaudeCodeStreamState::default(); + + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "message-1"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "正在处理"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-1", "content": [{"type": "text", "text": "正在处理"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-2", "content": [{"type": "tool_use", "id": "tool-1", "name": "mcp__agc__agc_read_project_context", "input": {"path": "game/index.html"}}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": [{"type": "text", "text": "读取成功"}]}]} + }), + &mut observer, + ); + + let events = crate::agent::thread_manager::consume_thread(&subscription.subscription_id) + .expect("consume projected events") + .events; + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemDelta { kind: ThreadDeltaKind::Message, delta, .. } + if delta == "正在处理" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemStarted { item: ThreadItem::McpToolCall { item_id, .. }, .. } + if item_id == "tool-1" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemCompleted { item: ThreadItem::McpToolCall { item_id, output, status, .. }, .. } + if item_id == "tool-1" + && output.as_deref() == Some("读取成功") + && status.as_deref() == Some("completed") + ))); + assert!(observed.iter().any(|event| matches!( + event, + TurnObservation::AccumulatedText(text) if text == "正在处理" + ))); + assert!(observed + .iter() + .any(|event| matches!(event, TurnObservation::Activity("controlled-tool")))); + let history = read_direct_project_history_items_at(root.path()) + .expect("read process history before result"); + assert_eq!(history.len(), 2); + } + + #[test] + fn failed_claude_turn_keeps_completed_and_partial_process_history() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-failure-history", "CC 失败历史") + .expect("init project"); + let mut state = ClaudeCodeStreamState::default(); + let mut observer_fn = |_event| {}; + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "partial-message"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "已经读取项目"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "tool-message", "content": [{"type": "tool_use", "id": "tool-without-args", "name": "mcp__agc__agc_read_project_context"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-without-args", "content": [{"type": "text", "text": "读取完成"}]}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({"type": "result", "is_error": true, "error": "上游 429"}), + &mut observer, + ); + + let failure = state + .finish( + root.path(), + &serde_json::json!({"is_error": true}), + Some("cc-failure-turn"), + ) + .expect_err("the terminal result is a failure"); + assert!(failure.contains("Claude Code 返回失败终态")); + let history = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(history.len(), 2); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("message") + && item.to_string().contains("已经读取项目") + })); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("mcpToolCall") + && item.to_string().contains("读取完成") + })); } #[test] @@ -1372,7 +2941,7 @@ mod tests { fn sidecar_environment_is_isolated_from_the_local_claude_code_home() { let home = std::env::temp_dir().join("agc-cc-isolated-home-test"); let mut command = tokio::process::Command::new("node"); - configure_claude_code_environment(&mut command, None, &home); + configure_claude_code_environment(&mut command, None, &home).unwrap(); let envs = command .as_std() .get_envs() @@ -1399,5 +2968,210 @@ mod tests { ] { assert!(!envs.contains_key(name), "{name} 不应下发到 sidecar"); } + // MCP 工具调用必须显式抬高超时:默认 60 秒会把分钟级的付费生成/试玩取消掉, + // 让本轮租约未结算并拒绝后续工具调用。 + assert_eq!( + envs.get("MCP_TOOL_TIMEOUT"), + Some(&Some(CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string())) + ); + } + + #[test] + fn claude_code_session_lookup_uses_the_project_directory_and_newest_trace() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("projects").join("gameagent-0514673b"); + std::fs::create_dir_all(&root).unwrap(); + let home = temp.path().join("home"); + let dir_name = claude_code_project_dir_name(&root); + // 目录名必须与 Claude Code 自己的派生规则一致:非字母数字统一换成 `-`, + // 否则 resume 会去一个不存在的目录里找会话。 + assert!(dir_name.contains("gameagent-0514673b")); + assert!(!dir_name.contains([':', '\\', '/'])); + let sessions = home.join("claude").join("projects").join(&dir_name); + std::fs::create_dir_all(&sessions).unwrap(); + + // 本项目还没有任何轨迹时不能凭空造出 resume。 + assert_eq!(latest_claude_code_session_id_at(&home, &root), None); + + let older = sessions.join("24c434aa-8f92-4615-9c74-46433eaa3a85.jsonl"); + let newer = sessions.join("f79dae28-ff58-4da3-8908-7a86a2a368e4.jsonl"); + std::fs::write(&older, "{}\n").unwrap(); + std::fs::write(&newer, "{}\n").unwrap(); + std::fs::write(sessions.join("notes.md"), "x").unwrap(); + std::fs::File::options() + .write(true) + .open(&older) + .unwrap() + .set_modified(std::time::SystemTime::now() - Duration::from_secs(600)) + .unwrap(); + std::fs::File::options() + .write(true) + .open(&newer) + .unwrap() + .set_modified(std::time::SystemTime::now()) + .unwrap(); + assert_eq!( + latest_claude_code_session_id_at(&home, &root).as_deref(), + Some("f79dae28-ff58-4da3-8908-7a86a2a368e4") + ); + + // 其他项目的轨迹再新也不能顶替本项目:SDK 只会在本项目的目录里查这份轨迹。 + let other = home + .join("claude") + .join("projects") + .join("C--other-project"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write( + other.join("00000000-0000-0000-0000-000000000000.jsonl"), + "{}\n", + ) + .unwrap(); + assert_eq!( + latest_claude_code_session_id_at(&home, &root).as_deref(), + Some("f79dae28-ff58-4da3-8908-7a86a2a368e4") + ); + } + + #[test] + fn claude_direct_resume_falls_back_to_disk_and_prefers_the_process_map() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let home = temp.path().join("home"); + let sessions = home + .join("claude") + .join("projects") + .join(claude_code_project_dir_name(&root)); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write(sessions.join("disk-session.jsonl"), "{}\n").unwrap(); + + // 冷启动(离开项目再进入 / 重启客户端)时进程表必然为空,此时必须回放磁盘轨迹, + // 否则 cc 执行器以全新会话开工,模型看不到任何历史对话。 + assert_eq!( + resolve_claude_direct_resume(&root, &home).unwrap(), + (Some("disk-session".to_string()), "disk") + ); + + let key = claude_project_key(&root); + claude_direct_sessions() + .lock() + .unwrap() + .insert(key.clone(), "memory-session".to_string()); + assert_eq!( + resolve_claude_direct_resume(&root, &home).unwrap(), + (Some("memory-session".to_string()), "memory") + ); + claude_direct_sessions().lock().unwrap().remove(&key); + } + + #[test] + fn claude_direct_timeout_is_only_auto_replayed_without_tool_requests() { + let mut state = ClaudeCodeStreamState::default(); + let timeout = + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件(已收到 3 个事件)"; + // 从没请求过工具的超时可以自动重放(现场:网关挂住 3 分钟,用户手动重试 58 秒就成功了)。 + assert!(state.is_auto_retryable_timeout(timeout)); + // 非超时失败不自动重放:凭据 / 额度 / 协议这类重试没有意义。 + assert!(!state.is_auto_retryable_timeout("Claude Agent SDK sidecar 非零退出;exitStatus=1")); + // 45 分钟硬上限是失控回合,不叠重试(否则一次用户回合能被拖到两小时以上)。 + assert!(!state + .is_auto_retryable_timeout("Claude Agent SDK sidecar 回合超时:超过 45 分钟硬上限")); + // 模型一旦请求过工具,重放就可能把付费生成 / 构建 / 试玩再执行一遍——哪怕结果还没回来, + // 操作也可能已经在途,所以只按"没碰过工具"这一条放行。 + state.tool_calls.insert( + "tool-1".to_string(), + ClaudeCodeToolState { + name: "agc_generate_image".to_string(), + arguments: String::new(), + started: true, + completed: false, + }, + ); + assert!(!state.is_auto_retryable_timeout(timeout)); + } + + #[test] + fn claude_direct_retry_policy_retries_only_safe_timeouts() { + let timeout = |retryable, tools_requested| ClaudeDirectTurnFailure { + message: "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".to_string(), + kind: ClaudeDirectFailureKind::Timeout, + retryable, + tools_requested, + }; + // 静默超时、没碰过工具:第 1、2 次都继续重放,第 3 次(用满尝试次数)停下。 + let replayable = timeout(true, false); + assert_eq!( + claude_direct_failure_action(&replayable, 1), + ClaudeDirectFailureAction::Retry + ); + assert_eq!( + claude_direct_failure_action(&replayable, 2), + ClaudeDirectFailureAction::Retry + ); + assert_eq!( + claude_direct_failure_action(&replayable, CLAUDE_DIRECT_TURN_MAX_ATTEMPTS), + ClaudeDirectFailureAction::StopRetriesExhausted + ); + // 请求过工具 / 硬上限一类的不可重放超时:一次都不重放,并且各自的说明不同。 + assert_eq!( + claude_direct_failure_action(&timeout(false, true), 1), + ClaudeDirectFailureAction::StopToolsRequested + ); + assert_eq!( + claude_direct_failure_action(&timeout(false, false), 1), + ClaudeDirectFailureAction::StopNotReplayable + ); + // 非超时失败原样上报,不改写既有分类。 + let mut fatal = + ClaudeDirectTurnFailure::fatal("Claude Agent SDK sidecar 非零退出".to_string()); + fatal.kind = ClaudeDirectFailureKind::Other; + assert_eq!( + claude_direct_failure_action(&fatal, 1), + ClaudeDirectFailureAction::Report + ); + } + + #[test] + fn claude_direct_retry_attempts_reach_the_structured_error_without_leaking() { + use crate::agent::codex_app_server::turn_error::{ModelCallKind, TurnError}; + + let detail = + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件(已收到 3 个事件)" + .to_string(); + // 没重试过时保持既有行为:1 次、详情原样。 + assert_eq!(claude_direct_retry_attempts(&detail), (1, detail.clone())); + match claude_code_failure_to_turn_error(detail.clone()) { + TurnError::ModelCallFailed(failure) => assert!(matches!( + failure.kind, + ModelCallKind::ResponseTimedOut { attempts: 1 } + )), + other => panic!("超时必须收口成 ModelCallFailed:{other:?}"), + } + + // 三次尝试(两次自动重试)后:attempts 是结构化事实,标记不能留在用户可见详情里。 + let with_retries = with_claude_direct_retry_attempts(detail.clone(), 2); + assert_eq!( + claude_direct_retry_attempts(&with_retries), + (3, detail.clone()) + ); + assert!(matches!( + claude_code_failure_to_llm_error(with_retries.clone()), + platform_llm::LlmError::Timeout { attempts: 3 } + )); + match claude_code_failure_to_turn_error(with_retries) { + TurnError::ModelCallFailed(failure) => { + assert!(matches!( + failure.kind, + ModelCallKind::ResponseTimedOut { attempts: 3 } + )); + assert!( + !failure.detail.contains("本轮已自动重试"), + "{}", + failure.detail + ); + assert!(failure.detail.contains("连续 180000 ms 没有任何事件")); + } + other => panic!("超时必须收口成 ModelCallFailed:{other:?}"), + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 118184ae4..6bea3606d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -367,7 +367,7 @@ impl ExecutionAdapter { Ok::<_, String>((session.root.clone(), session)) }) .await - .map_err(|_| "宿主执行身份读取中断")??; + .map_err(|error| format!("宿主执行身份读取中断:{error}"))??; Ok(Self::new( root, session, @@ -1031,9 +1031,13 @@ impl ExecutionAdapter { } fn report(&self) -> String { - direct_delivery::terminal_report(&self.session).unwrap_or_else(|| { - "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into() - }) + match direct_delivery::terminal_report(&self.session) { + Ok(Some(report)) => report, + Ok(None) => "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into(), + Err(error) => format!( + "本轮执行已停止,读取宿主交付状态失败:{error};请核对保留的证据与未完成项。" + ), + } } async fn drain(&self) { @@ -1132,8 +1136,9 @@ impl ExecutionAdapter { self.interrupt("收尾仍有未结算操作,交付尚未完成。").await; HostOutcome::Report(self.report()) } - Err(_) => { - self.interrupt("交付复核失败,已停止本轮并保留证据。").await; + Err(error) => { + let message = format!("交付复核失败:{error};已停止本轮并保留证据。"); + self.interrupt(&message).await; HostOutcome::Report(self.report()) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index cd6a725f4..14243723d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -11,7 +11,7 @@ use tokio::sync::{mpsc, oneshot, Mutex, Notify}; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; -mod process_tree; +pub(crate) mod process_tree; use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; @@ -315,6 +315,7 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( error: &serde_json::Value, ) -> platform_llm::LlmError { let mut fields = Vec::new(); + let mut preserved_detail = false; if let Some(object) = error.as_object() { if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) { if !code.is_empty() @@ -343,6 +344,71 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( .collect::>(); if !keys.is_empty() { fields.push(format!("fields={}", keys.join(","))); + // `codexErrorInfo` is often the only field returned by app-server for a + // failed turn. Keeping just its key (the old behaviour) turns a real + // HTTP/connection failure into `other detail=fields=codexErrorInfo`. + // Preserve the bounded structured value as diagnostic text; the same + // redaction boundary below removes credentials, URLs and paths while + // retaining status codes and actionable provider fields. + let structured_details = keys + .iter() + .filter_map(|key| { + let value = object.get(key)?; + let serialized = serde_json::to_string(value).ok()?; + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + (!safe.trim().is_empty()).then(|| format!("{key}={safe}")) + }) + .collect::>(); + preserved_detail |= !structured_details.is_empty(); + fields.extend(structured_details); + } + let detail = ["message", "additionalDetails"] + .into_iter() + .filter_map(|field| object.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";"); + if !detail.is_empty() { + // 先在 app-server 投影边界做一次无根目录脱敏;终态载荷还会按项目根目录再脱敏。 + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + fields.push(format!("detail={safe_detail}")); + preserved_detail = true; + } + } else if !error.is_null() { + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() { + fields.push(format!("error={safe_detail}")); + preserved_detail = true; + } + } + if !preserved_detail { + // Unknown object shapes and machine-only errors must still carry a bounded + // safe payload. Returning only the native kind recreates the old opaque + // fallback when app-server adds a field this version does not know yet. + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() && safe_detail != "{}" { + fields.push(format!("error={safe_detail}")); } } let suffix = if fields.is_empty() { @@ -369,18 +435,52 @@ fn game_creator_codex_app_server_error_http_status( fn game_creator_codex_app_server_connection_error( info: &serde_json::Value, field: &str, + error: &serde_json::Value, ) -> platform_llm::LlmError { match game_creator_codex_app_server_error_http_status(info, field) { - Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"), - Some(413) => game_creator_codex_app_server_error_kind("request-too-large"), - Some(status_code) => platform_llm::LlmError::Upstream { - status_code, - message: "Codex app-server 连接上游失败".to_string(), - }, - None => platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + Some(status_code @ (401 | 403)) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + else { + unreachable!("native unauthorized projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP {status_code}")) + } + Some(413) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ) + else { + unreachable!("native request-too-large projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP 413")) + } + Some(status_code) => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(error) + } else { + detail + }; + platform_llm::LlmError::Upstream { + status_code, + message: format!("Codex app-server 连接上游失败({field}):{detail}"), + } + } + None => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(info) + } else { + detail + }; + platform_llm::LlmError::Connectivity { + attempts: 1, + message: format!("Codex app-server 连接失败({field}):{detail}"), + } + } } } @@ -422,6 +522,54 @@ fn game_creator_codex_app_server_error_detail(error: &serde_json::Value) -> Stri .to_ascii_lowercase() } +fn game_creator_codex_app_server_error_display_detail(error: &serde_json::Value) -> String { + let Some(error) = error.as_object() else { + return String::new(); + }; + ["message", "additionalDetails", "code"] + .into_iter() + .filter_map(|field| error.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";") +} + +/// JSON-RPC 请求失败时不要只取可选的 `message`:有些上游只返回 `code` / `data`, +/// 丢掉整个 error 对象会把真实协议错误再次压成“未知错误”。这里保留结构化字段, +/// 最后仍由回合错误投影按项目根目录做一次精确脱敏。 +fn game_creator_codex_app_server_json_rpc_error_detail(error: &serde_json::Value) -> String { + let detail = error + .as_object() + .map(|object| { + ["code", "message", "additionalDetails", "data"] + .into_iter() + .filter_map(|field| object.get(field).map(|value| (field, value))) + .filter_map(|(field, value)| { + let value = match value.as_str() { + Some(value) => value.to_string(), + None if !value.is_null() => serde_json::to_string(value).ok()?, + _ => return None, + }; + let value = value.trim().to_string(); + (!value.is_empty()).then(|| format!("{field}={value}")) + }) + .collect::>() + .join(";") + }) + .unwrap_or_default(); + let detail = if detail.is_empty() { + serde_json::to_string(error).unwrap_or_else(|_| "无法序列化 JSON-RPC error".to_string()) + } else { + detail + }; + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ) +} + fn game_creator_codex_app_server_error_detail_indicates_stream_requirement( error: &serde_json::Value, ) -> bool { @@ -485,7 +633,15 @@ fn game_creator_codex_app_server_failed_turn_error( turn: &serde_json::Value, ) -> platform_llm::LlmError { let Some(error) = turn.get("error").filter(|error| !error.is_null()) else { - return game_creator_codex_app_server_error_kind("other"); + let status = turn + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + // app-server 有时只给 failed 状态、不带 error 对象。仍然保留协议事实,不能 + // 把“失败终态缺少 error 载荷”伪装成没有任何原因的 other。 + return platform_llm::LlmError::InvalidRequest(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}other detail=turn/completed status={status} error=missing" + )); }; if game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(error) { return platform_llm::LlmError::Upstream { @@ -494,19 +650,28 @@ fn game_creator_codex_app_server_failed_turn_error( }; } if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) { - return game_creator_codex_app_server_error_kind("request-too-large"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_stream_requirement(error) { - return game_creator_codex_app_server_error_kind("stream-required"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "stream-required", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_timeout(error) { return platform_llm::LlmError::Connectivity { attempts: 1, - message: "Codex app-server 上游请求超时".to_string(), + message: format!( + "Codex app-server 上游请求超时:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }; } if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { - return game_creator_codex_app_server_error_kind("unauthorized"); + return game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error); } let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else { return game_creator_codex_app_server_error_kind_with_machine_detail("other", error); @@ -514,25 +679,44 @@ fn game_creator_codex_app_server_failed_turn_error( if let Some(kind) = info.as_str() { return match kind { "contextWindowExceeded" => { - game_creator_codex_app_server_error_kind("context-window-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "context-window-exceeded", + error, + ) } "sessionBudgetExceeded" => { - game_creator_codex_app_server_error_kind("session-budget-exceeded") - } - "usageLimitExceeded" => { - game_creator_codex_app_server_error_kind("usage-limit-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "session-budget-exceeded", + error, + ) } + "usageLimitExceeded" => game_creator_codex_app_server_error_kind_with_machine_detail( + "usage-limit-exceeded", + error, + ), "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream { status_code: 503, - message: "Codex app-server 上游服务暂时不可用".to_string(), + message: format!( + "Codex app-server 上游服务暂时不可用:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }, - "cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"), - "unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"), - "badRequest" => game_creator_codex_app_server_error_kind("bad-request"), - "threadRollbackFailed" => { - game_creator_codex_app_server_error_kind("thread-rollback-failed") + "cyberPolicy" => { + game_creator_codex_app_server_error_kind_with_machine_detail("cyber-policy", error) + } + "unauthorized" => { + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + } + "badRequest" => { + game_creator_codex_app_server_error_kind_with_machine_detail("bad-request", error) + } + "threadRollbackFailed" => game_creator_codex_app_server_error_kind_with_machine_detail( + "thread-rollback-failed", + error, + ), + "sandboxError" => { + game_creator_codex_app_server_error_kind_with_machine_detail("sandbox-error", error) } - "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), "other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), _ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), }; @@ -544,7 +728,7 @@ fn game_creator_codex_app_server_failed_turn_error( "responseTooManyFailedAttempts", ] { if info.get(field).is_some() { - return game_creator_codex_app_server_connection_error(info, field); + return game_creator_codex_app_server_connection_error(info, field, error); } } if info.get("activeTurnNotSteerable").is_some() { @@ -700,13 +884,13 @@ pub(super) fn resolve_direct_codex_project_authority( return Err("AGC 直连项目根目录必须是绝对路径".to_string()); } let project_metadata = std::fs::metadata(project_root) - .map_err(|_| "AGC 直连项目根目录不存在或无法读取".to_string())?; + .map_err(|error| format!("AGC 直连项目根目录不存在或无法读取:{error}"))?; if !project_metadata.is_dir() { return Err("AGC 直连项目根目录不是目录".to_string()); } let project_root = project_root .canonicalize() - .map_err(|_| "AGC 直连项目根目录无法安全解析".to_string())?; + .map_err(|error| format!("AGC 直连项目根目录无法安全解析:{error}"))?; // 用户选择的项目目录就是 Codex 工作区根,不再强制要求 game/ 子目录。 // 原生文件工具仍由项目文件层拒绝 .agent/**、.git/**、密钥等控制面路径。 Ok((project_root.clone(), project_root)) @@ -1604,8 +1788,8 @@ async fn stage_codex_app_server_image( let (extension, encoded) = image_data_url_parts(image_url)?; let bytes = base64::engine::general_purpose::STANDARD .decode(encoded) - .map_err(|_| { - platform_llm::LlmError::InvalidRequest("多模态图片 base64 内容无效".to_string()) + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!("多模态图片 base64 内容无效:{error}")) })?; if bytes.is_empty() || bytes.len() > GAME_CREATOR_CODEX_APP_SERVER_IMAGE_MAX_BYTES { return Err(platform_llm::LlmError::InvalidRequest( @@ -2351,7 +2535,9 @@ impl CodexAppServerConnection { Ok::<_, String>((executable, version)) }) .await - .map_err(|_| platform_llm::LlmError::InvalidConfig("Codex 执行器身份核验中断".into()))? + .map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!("Codex 执行器身份核验中断:{error}")) + })? .map_err(platform_llm::LlmError::InvalidConfig)?; if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { execution::validate_approval_version(&codex_cli_version) @@ -2421,8 +2607,10 @@ impl CodexAppServerConnection { platform_llm::LlmError::InvalidRequest("Direct 连接缺少项目目录".into()) })? .canonicalize() - .map_err(|_| { - platform_llm::LlmError::InvalidRequest("Direct 项目目录不可用".into()) + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!( + "Direct 项目目录不可用:{error}" + )) })?; let bind_root = root.clone(); let bind_executable = executable.clone(); @@ -2436,7 +2624,9 @@ impl CodexAppServerConnection { session.bind_codex_executor(&bind_executable, &bind_version) }) .await - .map_err(|_| platform_llm::LlmError::InvalidRequest("Direct 执行器绑定中断".into()))? + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!("Direct 执行器绑定中断:{error}")) + })? .map_err(platform_llm::LlmError::InvalidRequest)?; Some(root) } else { @@ -2886,8 +3076,10 @@ impl CodexAppServerConnection { let private_home = isolated_codex_home.clone(); tokio::task::spawn_blocking(move || handoff.remember(&private_home)) .await - .map_err(|_| { - platform_llm::LlmError::InvalidConfig("私有 OAuth 轮换状态保存中断".into()) + .map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "私有 OAuth 轮换状态保存中断:{error}" + )) })? .map_err(platform_llm::LlmError::InvalidConfig)?; } @@ -4664,10 +4856,15 @@ async fn read_game_creator_codex_app_server_stdout( } let message = match serde_json::from_slice::(&buffer) { Ok(message) => message, - Err(_) => { + Err(error) => { + let raw = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(&buffer), + 480, + ); fail_game_creator_codex_app_server_connection( &inner, - "Codex app-server 返回无效 JSON-RPC".to_string(), + format!("Codex app-server 返回无效 JSON-RPC:{error};原文={raw}"), ) .await; return; @@ -4821,10 +5018,7 @@ async fn read_game_creator_codex_app_server_stdout( let result = if let Some(error) = message.get("error") { Err(format!( "Codex app-server JSON-RPC 失败:{}", - error - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("未知错误") + game_creator_codex_app_server_json_rpc_error_detail(error) )) } else { Ok(message @@ -5300,9 +5494,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| { - TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) - }); + .map_err(crate::agent::claude_code_failure_to_turn_error); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { TurnError::EnvironmentNotReady(EnvironmentNotReady { @@ -5321,9 +5513,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|state| state.client_turn_id) }) .await - .map_err(|_| { + .map_err(|error| { TurnError::HostStateUnavailable(HostStateUnavailable { - detail: "宿主 CLI 回合身份读取中断".to_string(), + detail: format!("宿主 CLI 回合身份读取中断:{error}"), }) })? .map_err(|detail| TurnError::HostStateUnavailable(HostStateUnavailable { detail }))?; @@ -5530,6 +5722,22 @@ pub(crate) fn build_direct_codex_history_prompt( #[cfg(test)] mod tests { + #[test] + fn json_rpc_error_keeps_structured_detail_and_redacts_secret() { + let detail = + super::game_creator_codex_app_server_json_rpc_error_detail(&serde_json::json!({ + "code": -32001, + "data": { + "reason": "out of memory (ENOMEM)", + "api_key": "provider-secret", + }, + })); + + assert!(detail.contains("code=-32001"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(detail.contains("api_key"), "{detail}"); + assert!(!detail.contains("provider-secret"), "{detail}"); + } use super::*; #[test] @@ -6454,6 +6662,18 @@ mod tests { assert!(std::path::Path::new(staged_path).is_file()); } + #[tokio::test] + async fn app_server_invalid_base64_preserves_decoder_detail() { + let temp = tempfile::tempdir().expect("temp dir"); + let error = + stage_codex_app_server_image(temp.path(), "data:image/png;base64,not-base64", 0) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("多模态图片 base64 内容无效"), "{error}"); + assert!(error.contains("Invalid"), "{error}"); + } + #[test] fn app_server_multimodal_validation_rejects_system_images() { let request = LlmRunRequest::new(vec![LlmMessage::multimodal( @@ -6956,52 +7176,84 @@ mod tests { } }); let error = game_creator_codex_app_server_failed_turn_error(&failed_turn); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded".to_string() - ) - ); let visible = error.to_string(); + assert!(visible.starts_with("codex-app-server-error:context-window-exceeded")); assert!(!visible.contains(&secret)); assert!(!visible.contains("provider.example")); assert!(!visible.contains("victim")); } + #[test] + fn codex_app_server_failed_turn_keeps_nested_error_info_details() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { + "codexErrorInfo": { + "httpConnectionFailed": { + "httpStatusCode": 400, + "message": "provider rejected the request" + }, + "responseStreamConnectionFailed": { + "retryable": false + } + } + } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("httpStatusCode"), "{visible}"); + assert!(visible.contains("400"), "{visible}"); + assert!( + visible.contains("provider rejected the request"), + "{visible}" + ); + } + + #[test] + fn codex_app_server_failed_turn_keeps_machine_only_error_info_value() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { "codexErrorInfo": "other" } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("fields=codexErrorInfo"), "{visible}"); + assert!(visible.contains("codexErrorInfo=\"other\""), "{visible}"); + } + + #[test] + fn codex_app_server_failed_turn_keeps_non_object_error_body() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": "provider returned a structured failure body" + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!( + visible.contains("provider returned a structured failure body"), + "{visible}" + ); + } + #[test] fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { - for (info, expected) in [ + for (info, expected_prefix) in [ ( serde_json::json!("usageLimitExceeded"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:usage-limit-exceeded".to_string(), - ), + "codex-app-server-error:usage-limit-exceeded", ), ( serde_json::json!("unauthorized"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string(), - ), + "codex-app-server-error:unauthorized", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ), + "codex-app-server-error:request-too-large", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), - platform_llm::LlmError::Upstream { - status_code: 429, - message: "Codex app-server 连接上游失败".to_string(), - }, + "上游返回 429", ), ( serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}), - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + "Codex app-server 连接失败", ), ] { let turn = serde_json::json!({ @@ -7012,17 +7264,15 @@ mod tests { "codexErrorInfo": info } }); - assert_eq!( - game_creator_codex_app_server_failed_turn_error(&turn), - expected - ); + let actual = game_creator_codex_app_server_failed_turn_error(&turn).to_string(); + assert!(actual.contains(expected_prefix), "{actual}"); } - assert_eq!( - game_creator_codex_app_server_failed_turn_error( - &serde_json::json!({"status":"failed","error":null}) - ), - platform_llm::LlmError::InvalidRequest("codex-app-server-error:other".to_string()) + let missing_error = game_creator_codex_app_server_failed_turn_error( + &serde_json::json!({"status":"failed","error":null}), ); + assert!(missing_error + .to_string() + .contains("status=failed error=missing")); } #[test] @@ -7040,12 +7290,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string() - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:unauthorized")); } } @@ -7065,12 +7312,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:request-too-large")); } } @@ -7083,12 +7327,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - stream_error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:stream-required".to_string() - ) - ); + assert!(stream_error + .to_string() + .starts_with("codex-app-server-error:stream-required")); let timeout_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ "status": "failed", "error": { @@ -7096,13 +7337,12 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - timeout_error, - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 上游请求超时".to_string() - } - ); + assert!(timeout_error + .to_string() + .contains("Codex app-server 上游请求超时")); + assert!(timeout_error + .to_string() + .contains("provider request timed out")); } #[test] @@ -7358,7 +7598,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es printf '%s\n' '{"id":2,"result":{}}' IFS= read -r skills_list case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac -printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' +printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"},{"name":"vite-export-taonier"}],"errors":[]}]}}' while IFS= read -r line; do :; done "#, ) @@ -8103,7 +8343,7 @@ while IFS= read -r line; do case "$line" in *'"method":"initialize"'*) printf '{{"id":%s,"result":{{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}}}\n' "$id" ;; *'"method":"skills/extraRoots/set"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; - *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}}],"errors":[]}}]}}}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{{"id":%s,"result":{{"data":[{{"skills":[{{"name":"agc-browser-playtest"}},{{"name":"agc-client-projection"}},{{"name":"agc-game-production-workflow"}},{{"name":"agc-godot-editor"}},{{"name":"agc-project-structure"}},{{"name":"agc-unity-editor"}},{{"name":"agc-web-game-development"}},{{"name":"taonier-art-assets"}},{{"name":"platform-abstract"}},{{"name":"vite-export-xhs-minitool"}},{{"name":"vite-export-taonier"}}],"errors":[]}}]}}}}\n' "$id" ;; *'"method":"thread/start"'*) printf '{{"id":%s,"result":{{"thread":{{"id":"thread-1"}}}}}}\n' "$id" ;; *'"method":"thread/inject_items"'*) printf '{{"id":%s,"result":{{}}}}\n' "$id" ;; *'"method":"turn/start"'*) @@ -8273,7 +8513,7 @@ while IFS= read -r line; do case "$line" in *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; - *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-godot-editor"},{"name":"agc-unity-editor"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"},{"name":"platform-abstract"},{"name":"vite-export-xhs-minitool"},{"name":"vite-export-taonier"}],"errors":[]}]}}\n' "$id" ;; *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; *'"method":"turn/start"'*) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs index 68adf5720..8687ca23f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs @@ -35,7 +35,7 @@ pub(super) fn source_for_credential( return Ok(CatalogSource::Bundled); }; let auth: Value = serde_json::from_slice(auth_json) - .map_err(|_| "model-catalog-auth-invalid: 认证桥接内容无效")?; + .map_err(|error| format!("model-catalog-auth-invalid: 认证桥接内容无效:{error}"))?; // 与 SDK resolved_mode 一样,显式 auth_mode 优先;旧文件才根据 API Key 判定。 let api_key_mode = match auth.get("auth_mode").and_then(Value::as_str) { Some(mode) => mode == "apikey", @@ -105,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul let count = stream .read(&mut buffer) .await - .map_err(|_| "model-catalog-read-failed")?; + .map_err(|error| format!("model-catalog-read-failed: {error}"))?; if count == 0 { return Ok(output); } @@ -132,36 +132,51 @@ async fn export_catalog( if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) { return Err("model-catalog-cancelled".into()); } - let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?; + let mut child = command + .spawn() + .map_err(|error| format!("model-catalog-spawn-failed: {error}"))?; let tree = match OwnedProcessTree::attach(&child) { Ok(tree) => tree, - Err(_) => { + Err(error) => { let _ = child.start_kill(); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; - return Err("model-catalog-process-owner-unavailable".into()); + return Err(format!("model-catalog-process-owner-unavailable: {error}")); } }; let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) { let collected = async { - let (stdout, _, status) = tokio::try_join!( + let (stdout, stderr, status) = tokio::try_join!( read_bounded(stdout, MAX_CATALOG_BYTES), read_bounded(stderr, 64 * 1024), async { child .wait() .await - .map_err(|_| "model-catalog-wait-failed".to_string()) + .map_err(|error| format!("model-catalog-wait-failed: {error}")) }, )?; if !status.success() { - return Err("model-catalog-export-failed".into()); + let detail = String::from_utf8_lossy(&stderr).trim().to_string(); + let detail = crate::sanitize_diagnostic_message( + &detail, + Some(Path::new("__agc_no_project_root__")), + ); + return Err(if detail.is_empty() { + format!("model-catalog-export-failed: exitStatus={status}") + } else { + format!( + "model-catalog-export-failed: exitStatus={status}; stderr={}", + detail.chars().take(1200).collect::() + ) + }); } - serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into()) + serde_json::from_slice(&stdout) + .map_err(|error| format!("model-catalog-json-invalid: {error}")) }; tokio::select! { biased; _ = cancelled(cancel) => Err("model-catalog-cancelled".into()), - result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err("model-catalog-timeout".into())), + result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err(format!("model-catalog-timeout: exceeded {} ms", CAPTURE_TIMEOUT.as_millis()))), } } else { Err("model-catalog-stdio-unavailable".into()) @@ -169,7 +184,7 @@ async fn export_catalog( let proof = tree .shutdown(&mut child) .await - .map_err(|_| "model-catalog-process-exit-unconfirmed")?; + .map_err(|error| format!("model-catalog-process-exit-unconfirmed: {error}"))?; if !proof.main_process_exited || !proof.observed_tree_empty { return Err("model-catalog-process-exit-unconfirmed".into()); } @@ -329,7 +344,7 @@ pub(super) async fn capture( if source == CatalogSource::Authenticated && cache_path .try_exists() - .map_err(|_| "model-catalog-source-unavailable")? + .map_err(|error| format!("model-catalog-source-unavailable: {error}"))? { return Err("model-catalog-source-not-fresh: 当前回合必须使用新隔离目录".into()); } @@ -354,9 +369,11 @@ pub(super) async fn capture( "模型目录来源", MAX_CATALOG_BYTES as u64, ) - .map_err(|_| "model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源")?; - let cache: Value = - serde_json::from_str(&cache_text).map_err(|_| "model-catalog-source-invalid")?; + .map_err(|error| { + format!("model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源:{error}") + })?; + let cache: Value = serde_json::from_str(&cache_text) + .map_err(|error| format!("model-catalog-source-invalid: {error}"))?; verify_authenticated_source(&cache, &original, &bundled, started_ms, now_ms())?; let evidence = json!({"cacheSha256":format!("{:x}", Sha256::digest(cache_text.as_bytes())), "fetchedAt":cache.get("fetched_at"),"etagSha256":cache.get("etag").and_then(Value::as_str).map(|etag|format!("{:x}",Sha256::digest(etag.as_bytes())))}); @@ -371,8 +388,10 @@ pub(super) async fn capture( { return Err("model-catalog-cancelled".into()); } - let original_bytes = serde_json::to_vec(&original).map_err(|_| "model-catalog-json-invalid")?; - let derived_bytes = serde_json::to_vec(&derived).map_err(|_| "model-catalog-json-invalid")?; + let original_bytes = serde_json::to_vec(&original) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; + let derived_bytes = serde_json::to_vec(&derived) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; if derived_bytes.len() > MAX_CATALOG_BYTES { return Err("model-catalog-output-limit".into()); } @@ -386,7 +405,8 @@ pub(super) async fn capture( "changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":cache_evidence}); crate::write_game_creator_private_file( &isolated_home.join("direct-model-catalog-receipt.json"), - &serde_json::to_vec(&receipt).map_err(|_| "model-catalog-receipt-invalid")?, + &serde_json::to_vec(&receipt) + .map_err(|error| format!("model-catalog-receipt-invalid: {error}"))?, "模型目录来源回执", ) .map_err(|error| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs index c73c92f18..3db0ac57b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs @@ -47,9 +47,9 @@ fn oauth_identity(auth: &Value) -> Result { .ok_or("model-catalog-auth-handoff-identity")?; let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(payload.trim_end_matches('=')) - .map_err(|_| "model-catalog-auth-handoff-identity")?; - let claims: Value = - serde_json::from_slice(&payload).map_err(|_| "model-catalog-auth-handoff-identity")?; + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; + let claims: Value = serde_json::from_slice(&payload) + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; let claims_auth = &claims["https://api.openai.com/auth"]; let token_account = auth .pointer("/tokens/account_id") @@ -87,12 +87,13 @@ fn oauth_bytes(bytes: &[u8]) -> Result<(OAuthIdentity, Vec), String> { if bytes.len() > MAX_AUTH_BYTES as usize { return Err("model-catalog-auth-handoff-size".into()); } - let mut auth: Value = - serde_json::from_slice(bytes).map_err(|_| "model-catalog-auth-handoff-invalid")?; + let mut auth: Value = serde_json::from_slice(bytes) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; let identity = oauth_identity(&auth)?; // 显式 OAuth 文件可能还残留一个 API Key;私有轮换缓存从不携带它。 auth["OPENAI_API_KEY"] = Value::Null; - let bytes = serde_json::to_vec(&auth).map_err(|_| "model-catalog-auth-handoff-invalid")?; + let bytes = serde_json::to_vec(&auth) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; Ok((identity, bytes)) } @@ -181,7 +182,9 @@ impl OAuthHandoff { MAX_AUTH_BYTES, ) }) - .map_err(|_| "model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态".to_string()) + .map_err(|error| { + format!("model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态:{error}") + }) .and_then(|text| oauth_bytes(text.as_bytes())) .and_then(|(identity, bytes)| { if identity != self.identity { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs index d0703aa3d..54fc35f92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs @@ -30,7 +30,7 @@ impl ProcessTreeExitProof { } } -pub(super) struct OwnedProcessTree { +pub(crate) struct OwnedProcessTree { pid: u32, #[cfg(unix)] start_identity: String, @@ -41,20 +41,26 @@ pub(super) struct OwnedProcessTree { impl OwnedProcessTree { /// 调用方只能在此成功后发送 initialize,保证所有受控模型执行都在归属内。 - pub(super) fn attach(child: &Child) -> Result { + pub(crate) fn attach(child: &Child) -> Result { + Self::attach_with_role(child, "Codex") + } + + /// 具名角色版本:Claude Code sidecar 与 Codex app-server 都是受控执行器, + /// 共用同一套 Job Object 归属与退出证明,只是 Job 名字要能区分来源便于排障。 + pub(crate) fn attach_with_role(child: &Child, role: &str) -> Result { let pid = child.id().ok_or("app-server-process-owner-missing")?; #[cfg(unix)] let start_identity = crate::process_identity::external_agent_runner_process_start_identity(pid) - .map_err(|_| "app-server-process-identity-unknown")? + .map_err(|error| format!("app-server-process-identity-unknown:{error}"))? .filter(|value| !value.is_empty()) - .ok_or("app-server-process-identity-unknown")?; + .ok_or_else(|| "app-server-process-identity-unknown:启动身份为空".to_string())?; #[cfg(windows)] let job = crate::process_session::WindowsProcessJob::assign_tokio_named( child, - &format!("Local\\AGCCodex-{}", uuid::Uuid::new_v4()), + &format!("Local\\AGC{role}-{}", uuid::Uuid::new_v4()), ) - .map_err(|_| "app-server-process-job-unavailable")?; + .map_err(|error| format!("app-server-process-job-unavailable:{error}"))?; #[cfg(not(any(windows, unix)))] return Err("app-server-process-control-unsupported".into()); #[cfg(any(windows, unix))] @@ -68,7 +74,7 @@ impl OwnedProcessTree { }) } - pub(super) async fn shutdown(&self, child: &mut Child) -> Result { + pub(crate) async fn shutdown(&self, child: &mut Child) -> Result { let mut stored = self.exit_proof.lock().await; if let Some(proof) = stored.as_ref() { return proof.clone(); @@ -78,7 +84,7 @@ impl OwnedProcessTree { result } - pub(super) async fn recorded_exit_proof(&self) -> Result { + pub(crate) async fn recorded_exit_proof(&self) -> Result { self.exit_proof .lock() .await @@ -91,16 +97,26 @@ impl OwnedProcessTree { return Err("app-server-process-owner-mismatch".into()); } let deadline = tokio::time::Instant::now() + STOP_TIMEOUT; - if self.terminate_owned_tree().is_err() { + if let Err(error) = self.terminate_owned_tree() { // 只清理仍持有的直属 Child 句柄;不能据此报告子树已回收。 - let _ = child.start_kill(); - let _ = tokio::time::timeout_at(deadline, child.wait()).await; - return Err("app-server-process-tree-termination-uncertain".into()); + let kill_error = child + .start_kill() + .err() + .map(|error| format!(";直属进程终止失败:{error}")) + .unwrap_or_default(); + let wait_error = match tokio::time::timeout_at(deadline, child.wait()).await { + Ok(Ok(_)) => String::new(), + Ok(Err(error)) => format!(";等待直属进程退出失败:{error}"), + Err(_) => ";等待直属进程退出超时".to_string(), + }; + return Err(format!( + "app-server-process-tree-termination-uncertain:{error}{kill_error}{wait_error}" + )); } tokio::time::timeout_at(deadline, child.wait()) .await - .map_err(|_| "app-server-process-exit-timeout")? - .map_err(|_| "app-server-process-exit-unconfirmed")?; + .map_err(|_| "app-server-process-exit-timeout:等待直属进程退出超过 5 秒".to_string())? + .map_err(|error| format!("app-server-process-exit-unconfirmed:{error}"))?; loop { if self.observed_tree_empty()? { break; @@ -127,7 +143,7 @@ impl OwnedProcessTree { { self.job .is_empty() - .map_err(|_| "app-server-process-tree-state-unknown".into()) + .map_err(|error| format!("app-server-process-tree-state-unknown:{error}")) } #[cfg(unix)] { @@ -145,7 +161,10 @@ impl OwnedProcessTree { if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { Ok(true) } else { - Err("app-server-process-tree-state-unknown".into()) + Err(format!( + "app-server-process-tree-state-unknown:{}", + std::io::Error::last_os_error() + )) } } #[cfg(not(any(windows, unix)))] @@ -159,7 +178,7 @@ impl OwnedProcessTree { { self.job .terminate() - .map_err(|_| "app-server-process-tree-termination-uncertain".into()) + .map_err(|error| format!("app-server-process-tree-termination-uncertain:{error}")) } #[cfg(unix)] { @@ -169,7 +188,7 @@ impl OwnedProcessTree { // leader 已消失或 PID 被复用时不盲杀 PGID;保留不确定状态。 let current = crate::process_identity::external_agent_runner_process_start_identity(self.pid) - .map_err(|_| "app-server-process-identity-unknown")?; + .map_err(|error| format!("app-server-process-identity-unknown:{error}"))?; if current.as_deref() != Some(self.start_identity.as_str()) { return Err("app-server-process-owner-mismatch".into()); } @@ -177,7 +196,10 @@ impl OwnedProcessTree { if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { Ok(()) } else { - Err("app-server-process-tree-termination-uncertain".into()) + Err(format!( + "app-server-process-tree-termination-uncertain:{}", + std::io::Error::last_os_error() + )) } } #[cfg(not(any(windows, unix)))] @@ -270,7 +292,9 @@ fn linux_process_group_members( impl Drop for OwnedProcessTree { fn drop(&mut self) { // Drop 只做应急回收,永远不伪造完成证明;正式终态必须 await shutdown。 - let _ = self.terminate_owned_tree(); + if let Err(error) = self.terminate_owned_tree() { + app_log!("agent.codex_app_server.process_tree.drop_cleanup_failed error={error}"); + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 745a9c1c2..4cdb326c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -805,7 +805,10 @@ impl TurnError { native: native_kind(&detail), } } - LlmError::Upstream { status_code, .. } if *status_code == 409 => { + LlmError::Upstream { + status_code, + message, + } if *status_code == 409 && is_mud_points_upstream_message(message) => { ModelCallKind::PaidCreditsInsufficient } LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed { @@ -836,6 +839,15 @@ impl TurnError { } } +fn is_mud_points_upstream_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + message.contains("泥点余额不足") + || message.contains("可消费泥点不足") + || normalized.contains("insufficient_mud_points") + || normalized.contains("insufficient-mud-points") + || normalized.contains("mud points insufficient") +} + /// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。 /// /// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 @@ -1232,9 +1244,24 @@ mod tests { // 没有字段可读的变体只带判别键。 assert_eq!( - serde_json::to_value(TurnFailure::HostDropped).expect("serialize"), + serde_json::to_value(TurnFailure::HostDropped(crate::agent::HostDropped { + detail: None, + })) + .expect("serialize"), serde_json::json!({ "type": "hostDropped" }) ); + let historical: TurnFailure = + serde_json::from_value(serde_json::json!({ "type": "hostDropped" })) + .expect("old failure remains readable"); + assert!( + matches!(historical, TurnFailure::HostDropped(payload) if payload.detail.is_none()) + ); + let current: TurnFailure = serde_json::from_value(serde_json::json!({ + "type": "hostDropped", "detail": "宿主任务提前退出:IPC EPIPE", + })) + .expect("current failure remains readable"); + assert!(matches!(current, TurnFailure::HostDropped(payload) + if payload.detail.as_deref() == Some("宿主任务提前退出:IPC EPIPE"))); } /// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。 @@ -1361,6 +1388,27 @@ mod tests { assert!(!projected.is_model_repairable()); } + #[test] + fn non_payment_upstream_409_keeps_the_upstream_status_and_detail() { + let projected = TurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "Claude Code 返回冲突(HTTP 409):session already active".into(), + }); + match projected { + TurnError::ModelCallFailed(payload) => { + assert_eq!( + payload.kind, + ModelCallKind::UpstreamFailed { + status_code: 409, + native: None, + } + ); + assert!(payload.detail.contains("session already active")); + } + other => panic!("expected upstream 409 failure, got {other:?}"), + } + } + #[test] fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { let rate_limited = TurnError::from_model_call(&LlmError::Upstream { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 707e5e29a..7fb2b7dda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -149,10 +149,10 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result(&value) - .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) + .map_err(|error| format!("内置 Codex CLI 完整性清单无效:{error}")) })?; if manifest.schema_version != codex_bundle::SCHEMA || manifest.platform != layout.platform @@ -167,9 +167,9 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result 120 { return Err("返回了无法识别的版本信息".to_string()); @@ -283,6 +283,7 @@ struct CodexCliStderrSummary { byte_len: usize, sha256: String, classification: &'static str, + detail: Option, } pub(crate) fn game_creator_codex_cli_version_identity() -> Result { @@ -487,18 +488,32 @@ where { let mut byte_len = 0_usize; let mut sha256 = Sha256::new(); + let mut preview = Vec::new(); let mut buffer = [0_u8; 8 * 1024]; loop { let count = reader .read(&mut buffer) .await - .map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?; + .map_err(|error| format!("读取 Codex CLI Agent stderr 失败:{error}"))?; if count == 0 { break; } byte_len = byte_len.saturating_add(count); sha256.update(&buffer[..count]); + let remaining = max_bytes.saturating_sub(preview.len()); + preview.extend_from_slice(&buffer[..count.min(remaining)]); } + let detail = if preview.is_empty() { + None + } else { + let text = String::from_utf8_lossy(&preview); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + text.trim(), + 480, + ); + (!detail.trim().is_empty()).then_some(detail) + }; Ok(CodexCliStderrSummary { byte_len, sha256: format!("{:x}", sha256.finalize()), @@ -509,6 +524,7 @@ where } else { "nonempty" }, + detail, }) } @@ -516,23 +532,34 @@ fn parse_game_creator_codex_cli_response( stdout: &[u8], request: &LlmRunRequest, ) -> Result { - let stdout = std::str::from_utf8(stdout).map_err(|_| { - platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string()) + let stdout = std::str::from_utf8(stdout).map_err(|error| { + platform_llm::LlmError::Deserialize(format!("Codex CLI Agent JSONL 不是 UTF-8:{error}")) })?; let mut response_id = None; let mut final_message = None; let mut usage = None; let mut completed = false; + let mut last_event_detail = None; for (index, line) in stdout.lines().enumerate() { if line.trim().is_empty() { continue; } - let event = serde_json::from_str::(line).map_err(|_| { + let event = serde_json::from_str::(line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + ); platform_llm::LlmError::Deserialize(format!( - "Codex CLI Agent JSONL 第 {} 行无效", + "Codex CLI Agent JSONL 第 {} 行无效:{error};原文={detail}", index.saturating_add(1) )) })?; + last_event_detail = Some(crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + )); match event.get("type").and_then(serde_json::Value::as_str) { Some("thread.started") => { response_id = event @@ -571,17 +598,35 @@ fn parse_game_creator_codex_cli_response( } } Some("turn.failed") => { - return Err(platform_llm::LlmError::Transport( - "Codex CLI Agent 返回失败终态".to_string(), - )); + let mut detail = event + .get("error") + .or_else(|| event.get("message")) + .map(serde_json::Value::to_string) + .unwrap_or_default(); + if detail.trim().is_empty() { + detail = serde_json::to_string(&event).unwrap_or_default(); + } + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + detail.trim_matches('"'), + 480, + ); + return Err(platform_llm::LlmError::Transport(if detail.is_empty() { + "Codex CLI Agent 返回失败终态".to_string() + } else { + format!("Codex CLI Agent 返回失败终态:{detail}") + })); } _ => {} } } if !completed { - return Err(platform_llm::LlmError::Deserialize( - "Codex CLI Agent 缺少 turn.completed 终态".to_string(), - )); + return Err(platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent 缺少 turn.completed 终态;最后事件={}", + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + ))); } let text = final_message .filter(|text| !text.trim().is_empty()) @@ -589,18 +634,30 @@ fn parse_game_creator_codex_cli_response( let tool_calls = if request.function_tools.is_empty() { Vec::new() } else { - let envelope = serde_json::from_str::(&text).map_err(|_| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent structured output 不是严格 JSON".to_string(), - ) + let envelope = serde_json::from_str::(&text).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &text, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output JSON 无效:{error};原文={detail}" + )) })?; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent structured output 缺少 toolCalls".to_string(), - ) + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output 缺少 toolCalls;原文={detail}" + )) })?; calls .iter() @@ -610,17 +667,26 @@ fn parse_game_creator_codex_cli_response( .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent tool call 缺少 name".to_string(), - ) + let detail = serde_json::to_string(call) + .unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 name;原文={detail}", + index.saturating_add(1) + )) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent tool call 缺少 arguments".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( @@ -780,14 +846,18 @@ async fn request_game_creator_agent_codex_cli_with_executable( }; let stdout = stdout_task .await - .map_err(|_| { - platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string()) + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stdout 读取任务失败:{error}" + )) })? .map_err(platform_llm::LlmError::Transport)?; let stderr = stderr_task .await - .map_err(|_| { - platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string()) + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stderr 读取任务失败:{error}" + )) })? .map_err(platform_llm::LlmError::Transport)?; if timed_out { @@ -796,23 +866,33 @@ async fn request_game_creator_agent_codex_cli_with_executable( let status = status.expect("non-timeout Codex CLI wait has exit status"); if stderr.classification == "oversized" { return Err(platform_llm::LlmError::Transport(format!( - "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}", + "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}{}", GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, stderr.classification, stderr.byte_len, - stderr.sha256 + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() ))); } if !status.success() { return Err(platform_llm::LlmError::Transport(format!( - "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置", + "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={}{};请检查 Codex CLI 登录状态、网络和本机配置", status .code() .map(|code| code.to_string()) .unwrap_or_else(|| "none".to_string()), stderr.classification, stderr.byte_len, - stderr.sha256 + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() ))); } if let Some(error) = stdin_error { @@ -1126,21 +1206,24 @@ mod tests { } #[tokio::test] - async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() { + async fn codex_cli_mode_bounds_unterminated_output_and_keeps_sanitized_stderr() { let output = vec![b'x'; 33]; let error = read_game_creator_codex_cli_output(output.as_slice(), 32) .await .expect_err("unterminated output over the cap must fail"); assert!(error.contains("超过 32 字节上限")); - let stderr = b"private-auth-detail-without-newline"; + let stderr = b"provider connection refused"; let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256) .await .expect("summarize stderr"); assert_eq!(summary.classification, "nonempty"); assert_eq!(summary.byte_len, stderr.len()); assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr))); - assert!(!summary.sha256.contains("private-auth-detail")); + assert_eq!( + summary.detail.as_deref(), + Some("provider connection refused") + ); } #[test] @@ -1190,6 +1273,67 @@ mod tests { ); } + #[test] + fn codex_cli_mode_preserves_failed_terminal_and_invalid_json_details() { + let request = LlmRunRequest::single_turn("系统", "任务"); + let failed = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"error\":{\"message\":\"HTTP 502 upstream overloaded\",\"api_key\":\"fixture-secret\"}}\n", + &request, + ) + .expect_err("failed terminal must be returned as an error"); + let failed_text = failed.to_string(); + assert!(failed_text.contains("HTTP 502 upstream overloaded")); + assert!(!failed_text.contains("fixture-secret")); + + let failed_without_message = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"code\":\"EPIPE\",\"status\":\"closed\"}\n", + &request, + ) + .expect_err("failed terminal without message must retain event detail"); + let failed_without_message = failed_without_message.to_string(); + assert!(failed_without_message.contains("EPIPE")); + assert!(failed_without_message.contains("closed")); + + let invalid = parse_game_creator_codex_cli_response( + b"{not-json Authorization: Bearer fixture-secret}\n", + &request, + ) + .expect_err("invalid JSONL must preserve a bounded safe line detail"); + let invalid_text = invalid.to_string(); + assert!(invalid_text.contains("JSONL 第 1 行无效")); + assert!(invalid_text.contains("原文=")); + assert!(!invalid_text.contains("fixture-secret")); + } + + #[test] + fn codex_cli_mode_preserves_missing_terminal_and_structured_output_details() { + let request = tool_request(); + let missing_terminal = parse_game_creator_codex_cli_response( + b"{\"type\":\"item.completed\",\"item\":{\"type\":\"error\",\"message\":\"IPC EPIPE\"}}\n", + &request, + ) + .expect_err("missing terminal must retain the last protocol event"); + let missing_terminal = missing_terminal.to_string(); + assert!(missing_terminal.contains("缺少 turn.completed 终态")); + assert!(missing_terminal.contains("IPC EPIPE")); + + let malformed_event = serde_json::json!({ + "type": "item.completed", + "item": { "type": "agent_message", "text": r#"{"toolCalls":["# } + }); + let malformed_stdout = format!( + "{}\n{{\"type\":\"turn.completed\"}}\n", + serde_json::to_string(&malformed_event).expect("serialize malformed event") + ); + let malformed_structured = + parse_game_creator_codex_cli_response(malformed_stdout.as_bytes(), &request) + .expect_err("malformed structured output must retain parse detail"); + let malformed_structured = malformed_structured.to_string(); + assert!(malformed_structured.contains("structured output JSON 无效")); + assert!(malformed_structured.contains("line")); + assert!(malformed_structured.contains("toolCalls")); + } + #[cfg(unix)] #[tokio::test] async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() { @@ -1246,14 +1390,14 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens #[cfg(unix)] #[tokio::test] - async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() { + async fn codex_cli_mode_preserves_sanitized_process_stderr_on_failure() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); let executable = temp.path().join("fake-codex-failure"); std::fs::write( &executable, - "#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n", + "#!/bin/sh\nprintf '%s\\n' 'Authorization: Bearer secret-auth-detail; out of memory (ENOMEM)' >&2\nexit 43\n", ) .expect("write fake codex"); let mut permissions = std::fs::metadata(&executable) @@ -1271,10 +1415,12 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens .to_string(); assert!(error.contains("code=43")); assert!(error.contains("stderrClass=nonempty")); - assert!(error.contains("stderrBytes=19")); + assert!(error.contains("stderrBytes=")); assert!(error.contains("stderrSha256=")); + assert!(error.contains("out of memory (ENOMEM)")); + assert!(error.contains("[redacted-secret]")); assert!(error.contains("登录状态")); - assert!(!error.contains("secret-auth-detail")); + assert!(!error.contains("Bearer secret-auth-detail")); } #[cfg(unix)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index bdc90be02..4d45af75d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -147,11 +147,11 @@ fn normalize_codex_provider_upstream(value: &str) -> Result { Ok(value.to_string()) } -fn proxy_error(status: StatusCode, message: &'static str) -> Response { +fn proxy_error(status: StatusCode, message: impl Into) -> Response { Response::builder() .status(status) .header("content-type", "text/plain; charset=utf-8") - .body(Body::from(message)) + .body(message.into()) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -175,18 +175,19 @@ fn is_codex_account_limit_header(name: &HeaderName) -> bool { name.as_str().to_ascii_lowercase().starts_with("x-codex-") } -fn parallel_direct_request(body: &[u8]) -> Result, &'static str> { - let mut value: serde_json::Value = - serde_json::from_slice(body).map_err(|_| "provider request JSON invalid")?; +fn parallel_direct_request(body: &[u8]) -> Result, String> { + let mut value: serde_json::Value = serde_json::from_slice(body) + .map_err(|error| format!("provider request JSON invalid:{error}"))?; let object = value .as_object_mut() - .ok_or("provider request must be an object")?; + .ok_or_else(|| "provider request must be an object".to_string())?; // SDK 的未知模型回退目录默认关闭并行。Direct 的宿主已负责许可、依赖和冲突, // 因此明确请求标准 Responses 并行能力;不伪造模型名称或工具的只读标记。 object.insert("parallel_tool_calls".into(), serde_json::Value::Bool(true)); - let bytes = serde_json::to_vec(&value).map_err(|_| "provider request JSON invalid")?; + let bytes = serde_json::to_vec(&value) + .map_err(|error| format!("provider request JSON serialization failed:{error}"))?; if bytes.len() > CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES { - return Err("provider request too large"); + return Err("provider request too large".to_string()); } Ok(bytes) } @@ -233,15 +234,21 @@ async fn proxy_codex_provider_request( let (parts, body) = request.into_parts(); let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await { Ok(body) => body, - Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"), + Err(error) => { + return proxy_error( + StatusCode::PAYLOAD_TOO_LARGE, + format!("provider request body read failed:{error}"), + ) + } }; let body = if state.parallel_tool_calls { match tokio::task::spawn_blocking(move || parallel_direct_request(&body)).await { Ok(Ok(bytes)) => axum::body::Bytes::from(bytes), - _ => { + Ok(Err(error)) => return proxy_error(StatusCode::BAD_REQUEST, error), + Err(error) => { return proxy_error( - StatusCode::BAD_REQUEST, - "provider request JSON invalid or oversized", + StatusCode::INTERNAL_SERVER_ERROR, + format!("provider request rewrite task failed:{error}"), ) } } @@ -264,10 +271,10 @@ async fn proxy_codex_provider_request( } let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() { Ok(value) => value, - Err(_) => { + Err(error) => { return proxy_error( StatusCode::INTERNAL_SERVER_ERROR, - "provider proxy credential invalid", + format!("provider proxy credential invalid:{error}"), ) } }; @@ -281,7 +288,12 @@ async fn proxy_codex_provider_request( .await { Ok(response) => response, - Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"), + Err(error) => { + return proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider upstream unavailable:{error}"), + ) + } }; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); @@ -310,7 +322,12 @@ async fn proxy_codex_provider_request( } response .body(Body::from_stream(stream)) - .unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid")) + .unwrap_or_else(|error| { + proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider response invalid:{error}"), + ) + }) } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3f8c99995..40afd5dad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1247,8 +1247,17 @@ async fn finish_design_command( // panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 // design_debug(task-local 提供项目根),不进入用户可见消息。 -fn design_panic_error(_payload: Box) -> String { - DESIGN_PANIC_PUBLIC_ERROR.to_string() +fn design_panic_error(payload: Box) -> String { + let raw = payload + .downcast_ref::<&str>() + .map(|value| (*value).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_default(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 600); + if detail.trim().is_empty() { + return DESIGN_PANIC_PUBLIC_ERROR.to_string(); + } + format!("策划运行发生内部错误:{detail};本轮已中断,可直接重试;若反复出现请反馈。") } #[cfg(test)] @@ -1680,7 +1689,9 @@ pub(crate) async fn continue_design_agent_session( input, capture, |event| { - let _ = app.emit("design-agent-update", event); + if let Err(error) = app.emit("design-agent-update", event) { + app_log!("design_agent.update.emit_failed detail={error}"); + } }, ) .await @@ -1707,7 +1718,9 @@ pub(crate) async fn decide_design_phase( approved, capture, |event| { - let _ = app.emit("design-agent-update", event); + if let Err(error) = app.emit("design-agent-update", event) { + app_log!("design_agent.update.emit_failed detail={error}"); + } }, ) .await @@ -2808,10 +2821,9 @@ mod tests { .expect("panic 必须转成可恢复视图而不是向上传播"); assert!(!view.running); assert!(view.can_retry); - assert_eq!( - view.session.last_error.as_deref(), - Some(DESIGN_PANIC_PUBLIC_ERROR) - ); + let last_error = view.session.last_error.as_deref().expect("panic detail"); + assert!(last_error.contains("注入的策划工具 panic"), "{last_error}"); + assert!(last_error.contains("本轮已中断"), "{last_error}"); let session = read_design_session(&root) .expect("read session") .expect("session exists"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5dc6c824c..796dbdd21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -499,7 +499,7 @@ pub(crate) fn list_design_workspace_files( Ok(entries) => entries .collect::, _>>() .map_err(|error| format!("读取工作区失败:{error}"))?, - Err(_) => continue, + Err(error) => return Err(format!("读取工作区目录失败:{error}")), }; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { @@ -551,7 +551,7 @@ pub(crate) fn import_design_workspace_file( ) -> Result { let root = PathBuf::from(project_path.trim()); let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?; - let _ = app.emit( + if let Err(error) = app.emit( "design-agent-update", serde_json::json!({ "projectPath": root.to_string_lossy(), @@ -561,7 +561,9 @@ pub(crate) fn import_design_workspace_file( "text": null, "view": null, }), - ); + ) { + app_log!("design_agent.workspace.emit_failed detail={error}"); + } Ok(relative_path) } @@ -881,9 +883,12 @@ fn search_design_text( return Ok(()); } if path.is_file() { - let Ok(text) = fs::read_to_string(path) else { - return Ok(()); - }; + let text = fs::read_to_string(path).map_err(|error| { + format!( + "搜索工作区文件失败:{}:{error}", + design_tool_location(root, path) + ) + })?; let display = design_tool_location(root, path); for (index, line) in text.lines().enumerate() { if line.contains(query) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index 5cd95eab2..c8e365157 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -39,7 +39,7 @@ pub(super) async fn update_plan( input: &Value, ) -> Result { let mut plan: DirectPlanInput = serde_json::from_value(input.clone()) - .map_err(|_| "direct-plan-invalid: 计划只接受explanation与plan(step,status)")?; + .map_err(|error| format!("direct-plan-invalid: 计划参数解析失败:{error}"))?; if plan.plan.is_empty() || plan.plan.len() > 32 { return Err("direct-plan-invalid: 计划需包含1到32步".into()); } @@ -58,11 +58,12 @@ pub(super) async fn update_plan( } *explanation = truncate_agent_runtime_text(explanation, 1200); } - let value = serde_json::to_value(plan).map_err(|_| "direct-plan-invalid")?; + let value = + serde_json::to_value(plan).map_err(|error| format!("direct-plan-invalid:{error}"))?; let session = Arc::clone(session); tokio::task::spawn_blocking(move || session.update_plan(value)) .await - .map_err(|_| "direct-plan-worker-exited")? + .map_err(|error| format!("direct-plan-worker-exited:{error}"))? } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -199,13 +200,13 @@ struct InitialDelivery { fn initial_delivery_path(root: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "delivery-project-unavailable")?; + .map_err(|error| format!("delivery-project-unavailable:{error}"))?; let host = crate::game_creator_runtime_config_dir() .ok_or("delivery-host-unavailable: 缺少客户端私有目录")?; crate::ensure_game_creator_private_directory_tree(&host, "客户端交付目录")?; let host = host .canonicalize() - .map_err(|_| "delivery-host-unavailable")?; + .map_err(|error| format!("delivery-host-unavailable:{error}"))?; if host.starts_with(&root) { return Err("delivery-host-invalid: 权威交付状态不能位于项目内".into()); } @@ -220,14 +221,14 @@ fn initial_delivery_path(root: &Path) -> Result { fn read_initial_delivery(path: &Path, project_id: &str) -> Result, String> { if !path .try_exists() - .map_err(|_| "delivery-initial-state-unavailable")? + .map_err(|error| format!("delivery-initial-state-unavailable:{error}"))? { return Ok(None); } let value: InitialDelivery = serde_json::from_str( &crate::read_game_creator_private_file_to_string(path, "首次交付状态", 16 * 1024)?, ) - .map_err(|_| "delivery-initial-state-invalid")?; + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?; if value.schema_version != "agc-initial-web-delivery.v1" || value.project_id != project_id { return Err("delivery-initial-state-identity".into()); } @@ -249,7 +250,7 @@ fn is_new_web_delivery(root: &Path) -> Result { let receipt = resolve_local_project_path(root, ".agent/runtime/web-scaffold-preparation.json")?; if !receipt .try_exists() - .map_err(|_| "delivery-scaffold-receipt-unavailable")? + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))? { return Ok(false); } @@ -257,12 +258,12 @@ fn is_new_web_delivery(root: &Path) -> Result { let mut bytes = Vec::new(); file.take(16 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "delivery-scaffold-receipt-unavailable")?; + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?; if bytes.len() > 16 * 1024 { return Err("delivery-scaffold-receipt-invalid".into()); } - let receipt: Value = - serde_json::from_slice(&bytes).map_err(|_| "delivery-scaffold-receipt-invalid")?; + let receipt: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("delivery-scaffold-receipt-invalid:{error}"))?; let expected: BTreeMap<_, _> = crate::project::trusted_web_scaffold_files() .into_iter() .map(|(path, content)| (path, format!("{:x}", Sha256::digest(content.as_bytes())))) @@ -270,7 +271,8 @@ fn is_new_web_delivery(root: &Path) -> Result { if receipt["schemaVersion"] != "agc-web-scaffold-preparation.v1" || receipt["projectId"] != project.project_id || receipt["templateHashes"] - != serde_json::to_value(expected).map_err(|_| "delivery-scaffold-template-invalid")? + != serde_json::to_value(expected) + .map_err(|error| format!("delivery-scaffold-template-invalid:{error}"))? { return Err("delivery-scaffold-receipt-invalid".into()); } @@ -281,7 +283,7 @@ fn is_new_web_delivery(root: &Path) -> Result { project_id: project.project_id, completed: false, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", )?; Ok(true) @@ -302,7 +304,7 @@ fn mark_initial_delivered(root: &Path, ledger: &ExecutionLedger) -> Result<(), S project_id: ledger.project_id.clone(), completed: true, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", ) } @@ -343,7 +345,7 @@ fn evidence_file_hash(root: &Path, relative: &str) -> Result { loop { let count = file .read(&mut buffer) - .map_err(|_| "delivery-artifact-read")?; + .map_err(|error| format!("delivery-artifact-read:{error}"))?; if count == 0 { break; } @@ -409,8 +411,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result { checks: vec![json!({"id":"contract","passed":false,"detail":"尚未登记本轮验收合同"})], }); }; - let contract: FrozenContract = - serde_json::from_value(value.clone()).map_err(|_| "delivery-frozen-contract-invalid")?; + let contract: FrozenContract = serde_json::from_value(value.clone()) + .map_err(|error| format!("delivery-frozen-contract-invalid:{error}"))?; if contract.schema_version != CONTRACT_SCHEMA { return Err("delivery-frozen-contract-invalid".into()); } @@ -449,8 +451,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result { }) } -pub(super) fn terminal_report(session: &Arc) -> Option { - session.snapshot().ok()?.terminal_report +pub(super) fn terminal_report(session: &Arc) -> Result, String> { + session.snapshot().map(|snapshot| snapshot.terminal_report) } pub(super) async fn status(root: &Path, session: &Arc) -> Result { @@ -477,7 +479,7 @@ pub(super) async fn try_seal(root: &Path, session: &Arc) -> Re session.begin_sealing(ledger.revision) }) .await - .map_err(|_| "delivery-seal-worker-exited")? + .map_err(|error| format!("delivery-seal-worker-exited:{error}"))? } pub(super) async fn finish_sealing( @@ -503,7 +505,7 @@ pub(super) async fn finish_sealing( session.complete(report.clone())?; if mark_initial_delivered(&root,&ledger).is_err() { app_log!("首次交付标记未写入,后续保持更严格的新项目验收要求"); } Ok(Some(report)) - }).await.map_err(|_| "delivery-finalize-worker-exited")? + }).await.map_err(|error| format!("delivery-finalize-worker-exited:{error}"))? } /// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 @@ -514,15 +516,28 @@ pub(super) async fn review_reply( root: &Path, session: &Arc, ) -> Result, TurnError> { - if let Some(report) = terminal_report(session) { + let existing_report = terminal_report(session).map_err(|error| { + TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + ) + })?; + if let Some(report) = existing_report { return Ok(Some(report)); } let ledger = session.snapshot()?; if ledger.contract.is_none() { let finished = session.finish_without_contract(); // 关闭失败时优先呈现已持久化的终态报告,保留真实失败原因。 - if let Some(report) = terminal_report(session) { - return Ok(Some(report)); + match terminal_report(session) { + Ok(Some(report)) => return Ok(Some(report)), + Ok(None) => {} + Err(error) => { + return Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + )) + } } finished?; return Ok(None); @@ -531,16 +546,23 @@ pub(super) async fn review_reply( if let Some(report) = finish_sealing(root, session).await? { return Ok(Some(report)); } - if let Some(report) = terminal_report(session) { + let terminal_report = terminal_report(session).map_err(|error| { + TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + ) + })?; + if let Some(report) = terminal_report { return Ok(Some(report)); } } let review_session = Arc::clone(session); let review = tokio::task::spawn_blocking(move || review_session.record_delivery_review()) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; let assessment = status(root, session).await?; - let detail = serde_json::to_string(&assessment).map_err(|_| "delivery-review-invalid")?; + let detail = serde_json::to_string(&assessment) + .map_err(|error| format!("delivery-review-invalid:{error}"))?; if review >= ledger.max_runs { let missing = assessment .pointer("/assessment/checks") @@ -567,7 +589,7 @@ pub(super) async fn review_reply( let saved = report.clone(); tokio::task::spawn_blocking(move || session.interrupt(saved)) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; return Ok(Some(report)); } Err(TurnError::ReviewRequired { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs index 429c97240..580346e73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs @@ -73,10 +73,16 @@ pub(crate) fn emit_direct_active_turns_changed() { let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; - let _ = app.emit( + if let Err(error) = app.emit( DIRECT_ACTIVE_TURNS_CHANGED_EVENT, serde_json::json!({ "revision": revision }), - ); + ) { + app_log!( + "agent.direct_events.emit_failed event={} revision={} detail={error}", + DIRECT_ACTIVE_TURNS_CHANGED_EVENT, + revision + ); + } } #[cfg(test)] @@ -88,14 +94,19 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; - let _ = app.emit( + if let Err(error) = app.emit( "game-creator-agent-progress", GameCreatorAgentProgressEvent { project_path: root.to_string_lossy().into_owned(), stage: stage.to_string(), message: message.to_string(), }, - ); + ) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-agent-progress stage={} detail={error}", + stage + ); + } } #[derive(Clone)] @@ -204,7 +215,11 @@ pub(crate) fn start_game_creator_manifest_invalidation_event_sink( if envelope.token != expected_token { continue; } - let _ = app.emit("game-creator-manifest-invalidated", envelope.event); + if let Err(error) = app.emit("game-creator-manifest-invalidated", envelope.event) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}" + ); + } } }) .map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?; @@ -360,7 +375,11 @@ pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str agent_id: agent_id.to_string(), }; if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() { - let _ = app.emit("game-creator-manifest-invalidated", event); + if let Err(error) = app.emit("game-creator-manifest-invalidated", event) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}" + ); + } return; } let _ = relay_game_creator_manifest_invalidation(root, agent_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 8ea601335..64a699fa5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -171,11 +171,11 @@ struct CodexExecutorIdentity { fn executor_digest(path: &Path) -> Result { use std::io::Read; - let mut file = - File::open(path).map_err(|_| "direct-execution-executor: 无法读取已选择的执行器")?; + let mut file = File::open(path) + .map_err(|error| format!("direct-execution-executor: 无法读取已选择的执行器:{error}"))?; let before = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if !before.is_file() || before.len() > 512 * 1024 * 1024 { return Err("direct-execution-executor: 执行器类型或大小无效".into()); } @@ -185,7 +185,7 @@ fn executor_digest(path: &Path) -> Result { loop { let read = file .read(&mut buffer) - .map_err(|_| "direct-execution-executor: 执行器读取失败")?; + .map_err(|error| format!("direct-execution-executor: 执行器读取失败:{error}"))?; if read == 0 { break; } @@ -197,7 +197,7 @@ fn executor_digest(path: &Path) -> Result { } let after = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if bytes != before.len() || after.len() != before.len() || before.modified().ok() != after.modified().ok() @@ -421,7 +421,7 @@ fn closed_error(phase: ExecutionPhase) -> String { pub(super) fn current(root: &Path) -> Result, String> { let root = root .canonicalize() - .map_err(|_| "direct-execution-project: 项目不可用")?; + .map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?; sessions() .lock() .map_err(|_| "direct-execution-state: 会话状态不可用")? @@ -468,7 +468,7 @@ pub(super) async fn begin( ) }) .await - .map_err(|_| "direct-execution-start: 宿主状态初始化退出")??; + .map_err(|error| format!("direct-execution-start: 宿主状态初始化退出:{error}"))??; let root = session.root.clone(); { let mut registry = sessions() @@ -504,7 +504,7 @@ pub(super) fn open_with_analytics_at( config.validate()?; let root = root .canonicalize() - .map_err(|_| "direct-execution-project: 项目不可用")?; + .map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?; if turn.is_empty() || turn.len() > 512 || request_hash.len() != 64 { return Err("direct-execution-identity: 回合身份无效".into()); } @@ -512,17 +512,17 @@ pub(super) fn open_with_analytics_at( return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } crate::ensure_game_creator_private_directory_tree(host, "宿主执行状态目录") - .map_err(|_| "direct-execution-host: 无法准备私有执行状态目录")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有执行状态目录:{error}"))?; let host = host .canonicalize() - .map_err(|_| "direct-execution-host: 状态目录不可用")?; + .map_err(|error| format!("direct-execution-host: 状态目录不可用:{error}"))?; if host.starts_with(&root) { return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } let project_key = hash(root.to_string_lossy().as_bytes()); let directory = host.join(&project_key); crate::ensure_game_creator_private_directory_tree(&directory, "宿主项目执行状态") - .map_err(|_| "direct-execution-host: 无法准备私有项目执行状态")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有项目执行状态:{error}"))?; let key = hash(turn.as_bytes()); let lock_path = directory.join(format!("{key}.lock")); if lock_path.exists() { @@ -535,7 +535,7 @@ pub(super) fn open_with_analytics_at( .create(true) .truncate(false) .open(&lock_path) - .map_err(|_| "direct-execution-owner: 无法打开执行归属锁")?; + .map_err(|error| format!("direct-execution-owner: 无法打开执行归属锁:{error}"))?; owner .try_lock() .map_err(|_| "direct-execution-active: 同一回合仍由其他执行器持有")?; @@ -728,7 +728,7 @@ impl ExecutionSession { } next.revision = data.ledger.revision.saturating_add(1); let bytes = serde_json::to_vec(&next) - .map_err(|_| "direct-execution-persistence: 状态序列化失败")?; + .map_err(|error| format!("direct-execution-persistence: 状态序列化失败:{error}"))?; if bytes.len() > MAX_STATE_BYTES { return Err("direct-execution-capacity: 宿主状态超过保留上限".into()); } @@ -786,7 +786,10 @@ impl ExecutionSession { } pub(super) fn update_plan(&self, plan: Value) -> Result { if !plan.is_object() - || serde_json::to_vec(&plan).map_err(|_| "计划格式无效")?.len() > 32 * 1024 + || serde_json::to_vec(&plan) + .map_err(|error| format!("计划格式无效:{error}"))? + .len() + > 32 * 1024 { return Err("direct-plan-invalid: 计划必须为有界对象".into()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs index a4d2ba17c..85f841ffd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs @@ -191,42 +191,60 @@ fn validate_argv(executable: &Path, patch: &str) -> Result<(), String> { Ok(()) } -fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap> { - targets - .iter() - .map(|target| { - let digest = (|| { - match std::fs::symlink_metadata(&target.absolute) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Some("missing".into()) - } - Ok(_) => {} - Err(_) => return None, - } +fn target_fingerprints( + targets: &[PatchTarget], +) -> Result>, String> { + let mut fingerprints = BTreeMap::new(); + for target in targets { + let digest = match std::fs::symlink_metadata(&target.absolute) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some("missing".into()), + Ok(_) => { let (mut file, metadata) = - open_project_snapshot_regular_file(&target.absolute, "补丁指纹").ok()?; + open_project_snapshot_regular_file(&target.absolute, "补丁指纹").map_err( + |error| format!("补丁指纹读取失败:{}:{error}", target.relative), + )?; if metadata.len() > TARGET_HASH_MAX_BYTES { - return None; + return Err(format!( + "补丁指纹读取失败:目标文件 {} 大小 {} bytes,超过 {} bytes 上限", + target.relative, + metadata.len(), + TARGET_HASH_MAX_BYTES + )); } let mut digest = Sha256::new(); let mut buffer = [0u8; 16 * 1024]; let mut total = 0u64; loop { - let count = file.read(&mut buffer).ok()?; + let count = file.read(&mut buffer).map_err(|error| { + format!( + "补丁指纹读取失败:{}:读取文件正文失败:{error}", + target.relative + ) + })?; if count == 0 { break; } total += count as u64; if total > TARGET_HASH_MAX_BYTES { - return None; + return Err(format!( + "补丁指纹读取失败:目标文件 {} 在读取期间超过 {} bytes 上限", + target.relative, TARGET_HASH_MAX_BYTES + )); } digest.update(&buffer[..count]); } Some(format!("file:{:x}", digest.finalize())) - })(); - (target.relative.clone(), digest) - }) - .collect() + } + Err(error) => { + return Err(format!( + "补丁指纹检查失败:{}:读取文件元数据失败:{error}", + target.relative + )); + } + }; + fingerprints.insert(target.relative.clone(), digest); + } + Ok(fingerprints) } fn analytics_patch_changes( @@ -278,7 +296,7 @@ fn run_transaction( let targets = collect_targets(root, &parsed.hunks)?; let executable = session.codex_executor()?; validate_argv(&executable, &parsed.patch)?; - let before = target_fingerprints(&targets); + let before = target_fingerprints(&targets)?; let operation = session.admit(EffectKind::Write, None)?; let process = runtime.block_on(crate::command_exec::run_owned_codex_patch_at( root, @@ -323,7 +341,7 @@ fn run_transaction( ) } }; - let after = target_fingerprints(&targets); + let after = target_fingerprints(&targets)?; let changed_paths = targets .iter() .filter(|target| { @@ -436,6 +454,44 @@ mod tests { (temp, root.canonicalize().unwrap()) } + #[test] + fn target_fingerprints_keep_missing_distinct_and_report_read_failures() { + let (_temp, root) = project(); + let missing = PatchTarget { + relative: "game/missing.txt".into(), + absolute: root.join("game/missing.txt"), + }; + let fingerprints = target_fingerprints(std::slice::from_ref(&missing)).unwrap(); + assert_eq!( + fingerprints.get("game/missing.txt"), + Some(&Some("missing".into())) + ); + + let directory = root.join("game/fingerprint-directory"); + std::fs::create_dir_all(&directory).unwrap(); + let directory_target = PatchTarget { + relative: "game/fingerprint-directory".into(), + absolute: directory, + }; + let error = target_fingerprints(std::slice::from_ref(&directory_target)).unwrap_err(); + assert!(error.contains("补丁指纹读取失败"), "{error}"); + assert!( + error.contains("打开补丁指纹失败") || error.contains("必须是普通文件"), + "{error}" + ); + + let oversized = root.join("game/fingerprint-oversized.bin"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(TARGET_HASH_MAX_BYTES + 1).unwrap(); + let oversized_target = PatchTarget { + relative: "game/fingerprint-oversized.bin".into(), + absolute: oversized, + }; + let error = target_fingerprints(std::slice::from_ref(&oversized_target)).unwrap_err(); + assert!(error.contains("超过"), "{error}"); + assert!(error.contains("补丁指纹读取失败"), "{error}"); + } + #[test] fn parser_paths_include_every_move_source_and_destination_before_any_write() { let (_temp, root) = project(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index cf06f63b0..b4c30424b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -52,7 +52,9 @@ struct ContextIdentity { fn context_identity(root: &Path) -> Result { let manifest = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; - let canonical = root.canonicalize().map_err(|_| "项目上下文目录无法解析")?; + let canonical = root + .canonicalize() + .map_err(|error| format!("项目上下文目录无法解析:{error}"))?; let active = list_active_turns()?.into_iter().find(|turn| { Path::new(&turn.project_path).canonicalize().ok().as_ref() == Some(&canonical) }); @@ -194,7 +196,7 @@ pub(super) fn external_read_is_protected(display: &str) -> bool { } /// O_NOFOLLOW 只保护末段;项目外路径还必须逐段拒绝目录链接与 Windows 重解析点。 -pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str> { +pub(super) fn reject_external_read_links(path: &Path) -> Result<(), String> { let mut current = PathBuf::new(); for component in path.components() { current.push(component.as_os_str()); @@ -202,9 +204,15 @@ pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str if matches!(component, std::path::Component::Prefix(_)) { continue; } - let metadata = std::fs::symlink_metadata(¤t).map_err(|_| "file-not-found")?; + let metadata = std::fs::symlink_metadata(¤t).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "file-not-found".to_string() + } else { + format!("读取路径元数据失败:{error}") + } + })?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); + return Err("linked-path".to_string()); } } Ok(()) @@ -237,50 +245,53 @@ fn normalize_requests(root: &Path, arguments: &Value) -> Result Ok(files) } -fn bounded_bytes(root: &Path, raw: &str) -> Result, &'static str> { - let target = resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path")?; +fn bounded_bytes(root: &Path, raw: &str) -> Result, String> { + let target = + resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path".to_string())?; let path = if let Some(relative) = target.project_relative.as_deref() { if relative.is_empty() { - return Err("not-safe-regular-file"); + return Err("not-safe-regular-file".to_string()); } - reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?; - reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?; + reject_agent_runtime_private_control_path(relative) + .map_err(|_| "private-control-path".to_string())?; + reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path".to_string())?; if should_skip_project_snapshot_path(relative) { - return Err("excluded-project-path"); + return Err("excluded-project-path".to_string()); } // 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。 let mut component = root.to_path_buf(); for segment in relative.split('/') { component.push(segment); - let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?; + let metadata = std::fs::symlink_metadata(&component) + .map_err(|error| format!("file-not-found:{error}"))?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); + return Err("linked-path".to_string()); } } component } else { if external_read_is_protected(&target.display) { - return Err("sensitive-path"); + return Err("sensitive-path".to_string()); } reject_external_read_links(&target.absolute)?; target.absolute }; let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取") - .map_err(|_| "not-safe-regular-file")?; + .map_err(|error| format!("not-safe-regular-file:{error}"))?; if metadata.len() > MAX_FILE_BYTES as u64 { - return Err("file-too-large"); + return Err("file-too-large".to_string()); } read_bounded(file) } -fn read_bounded(reader: impl Read) -> Result, &'static str> { +fn read_bounded(reader: impl Read) -> Result, String> { let mut bytes = Vec::new(); reader .take(MAX_FILE_BYTES as u64 + 1) .read_to_end(&mut bytes) - .map_err(|_| "file-read-failed")?; + .map_err(|error| format!("file-read-failed:{error}"))?; if bytes.len() > MAX_FILE_BYTES { - return Err("file-grew-over-limit"); + return Err("file-grew-over-limit".to_string()); } Ok(bytes) } @@ -320,7 +331,7 @@ struct ReadResult { } fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult { - let error = |code| ReadResult { + let error = |code: String| ReadResult { value: json!({"path":input.path,"status":"error","code":code}), source_hash: None, }; @@ -331,16 +342,16 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult let source_hash = sha256(&bytes); let text = match std::str::from_utf8(&bytes) { Ok(text) if !text.contains('\0') => text, - _ => return error("not-utf8-text"), + _ => return error("not-utf8-text".to_string()), }; let lines: Vec<_> = text.split_inclusive('\n').collect(); let safe_text = safe_source_text(root, text); let safe_lines: Vec<_> = safe_text.split_inclusive('\n').collect(); if safe_lines.len() != lines.len() { - return error("redaction-line-boundary-error"); + return error("redaction-line-boundary-error".to_string()); } if input.start_line > lines.len().saturating_add(1) { - return error("line-out-of-range"); + return error("line-out-of-range".to_string()); } let start = input.start_line - 1; let requested_count = input.max_lines.min(lines.len().saturating_sub(start)); @@ -361,7 +372,7 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult value, source_hash: Some(source_hash), }, - None => error("line-exceeds-response-budget"), + None => error("line-exceeds-response-budget".to_string()), } } @@ -414,7 +425,7 @@ where context_identity(&identity_root) }) .await - .map_err(|_| "项目上下文读取任务中断")??; + .map_err(|error| format!("项目上下文读取任务中断:{error}"))??; let item_budget = MAX_ITEM_BYTES.min(response_budget.saturating_sub(4096) / files.len()); let reader = Arc::new(reader); let root = root.to_path_buf(); @@ -425,7 +436,7 @@ where let path = request.path.clone(); let item = tokio::task::spawn_blocking(move || reader(&root, &request, item_budget)) .await - .map_err(|_| "文件批量读取任务中断".to_string())?; + .map_err(|error| format!("文件批量读取任务中断:{error}"))?; Ok::<_, String>((index, path, item)) } })) @@ -460,10 +471,10 @@ where .collect::>() }) .await - .map_err(|_| "批量读取复核任务中断")?; + .map_err(|error| format!("批量读取复核任务中断:{error}"))?; let after = tokio::task::spawn_blocking(move || context_identity(&root)) .await - .map_err(|_| "项目上下文复核任务中断")??; + .map_err(|error| format!("项目上下文复核任务中断:{error}"))??; let stale = before.project_id != after.project_id || before.revision != after.revision || before.turn_id != after.turn_id @@ -480,7 +491,7 @@ where "status":if stale {"stale"} else {"ready"},"activity":before.activity,"turnStatus":before.status, "capturedAt":unix_millis().min(u128::from(u64::MAX)) as u64,"atomicSnapshot":false,"files":files}); if serde_json::to_vec(&result) - .map_err(|_| "上下文序列化失败")? + .map_err(|error| format!("上下文序列化失败:{error}"))? .len() > response_budget { @@ -530,7 +541,7 @@ pub(super) async fn prefetch_turn_input( .collect::>() }) .await - .map_err(|_| "项目预取扫描任务中断")?; + .map_err(|error| format!("项目预取扫描任务中断:{error}"))?; if files.is_empty() { return Ok(None); } @@ -571,7 +582,7 @@ mod tests { let mut reader = std::io::Cursor::new(vec![b'a'; MAX_FILE_BYTES * 2]); assert_eq!( read_bounded(&mut reader).unwrap_err(), - "file-grew-over-limit" + "file-grew-over-limit".to_string() ); assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1); } @@ -595,7 +606,7 @@ mod tests { ] { assert_eq!( bounded_bytes(&root, &path.to_string_lossy()), - Err("linked-path") + Err("linked-path".to_string()) ); } std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs index c41b19de0..d0d7c0e76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -3,7 +3,7 @@ use serde_json::Value; use std::collections::BTreeMap; use std::path::Path; -#[derive(Default)] +#[derive(Default, Debug)] pub(crate) struct DirectProjectHistoryAccumulator { text_by_item_id: BTreeMap, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 8869139e8..90ac6b484 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2165,7 +2165,7 @@ fn record_direct_codex_failure_facts( "未能保存项目诊断" }; // sidecar 照写,但引用不进用户可见文案。 - let _ = persist_agent_runtime_error( + if let Err(error) = persist_agent_runtime_error( root, client_turn_id, "direct-codex", @@ -2177,10 +2177,27 @@ fn record_direct_codex_failure_facts( serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, }), - ) - .ok(); + ) { + // 失败载荷仍然会带着原始 detail 返回前端;这里额外保留统一错误事件落盘失败的 + // OS/JSON/时钟正文,不能让诊断写入失败也被 `.ok()` 吞掉。 + app_log!( + "agent.runtime.error_persist_failed source=direct-codex stage={} code={} detail={}", + stage, + error_code, + error + ); + } + let safe_detail = redact_agent_runtime_error(root, &detail, 420) + .split_whitespace() + .collect::>() + .join(" "); + let detail_suffix = if safe_detail.trim().is_empty() { + "详情:未提供具体错误正文".to_string() + } else { + format!("详情:{safe_detail}") + }; let public_text = format!( - "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", + "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{};{}", stage, error_code, retryable, @@ -2189,6 +2206,7 @@ fn record_direct_codex_failure_facts( .unwrap_or("未提供可安全展示的详细原因"), recovery_hint, diagnostics_suffix, + detail_suffix, ); // 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。 // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 @@ -2631,6 +2649,53 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .collect() } +/// 完成判定前的输入完整性检查。 +/// +/// 运行态的“是否引用平台素材”是业务判定;源码读取、清单解析和已登记图片读取则是 +/// 宿主 I/O。后者失败时不能把权限、损坏 JSON 或磁盘错误伪装成“没有引用素材”,否则 +/// 回合会继续走同一条通用返修提示,用户看不到真正原因。 +fn validate_direct_completion_inputs(root: &Path) -> Result<(), String> { + let entry = agent_runtime_game_entry_relative_path(root); + let source_paths = direct_codex_game_outputs(root) + .into_iter() + .map(|(relative_path, _, _)| relative_path) + .chain(direct_npm_source_paths(root)) + .collect::>(); + for relative_path in source_paths { + let path = root.join(&relative_path); + if !path.exists() { + continue; + } + if !path.is_file() { + return Err(format!("读取游戏源码失败:{} 不是文件", relative_path)); + } + std::fs::read_to_string(&path) + .map_err(|error| format!("读取游戏源码 {} 失败:{error}", relative_path))?; + } + if !root.join(&entry).is_file() { + return Ok(()); + } + let manifest = + read_manifest_for_project(root).map_err(|error| format!("读取项目清单失败:{error}"))?; + for asset in manifest.assets.iter().filter(|asset| { + asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + // 完整图集可以合法地没有切片文件;切片是可选的运行时投影,不能让它的 + // 缺失覆盖真正的 spritesheet 完整性判断。 + && !asset.local_path.starts_with("assets/art-spritesheet-slices/") + }) { + let Some(relative_path) = direct_normalized_project_asset_path(&asset.local_path) else { + continue; + }; + let path = root.join(&relative_path); + let bytes = std::fs::read(&path) + .map_err(|error| format!("读取已登记平台素材 {} 失败:{error}", relative_path))?; + validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {relative_path}")) + .map_err(|error| format!("校验已登记平台素材 {} 失败:{error}", relative_path))?; + } + Ok(()) +} + fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { let Ok(manifest) = read_manifest_for_project(root) else { return Vec::new(); @@ -2749,6 +2814,9 @@ fn direct_game_output_completion_error(root: &Path) -> Option { if !root.join(entry).is_file() { return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } + if let Err(error) = validate_direct_completion_inputs(root) { + return Some(format!("完成判定读取项目文件失败:{error}")); + } if !direct_game_sources_reference_taonier_art_package(root) { return Some( "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成" @@ -2989,7 +3057,15 @@ async fn recover_direct_taonier_spritesheet_read_only_at( access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { - return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "读取陶泥儿画布资源", + status, + &body, + )); } let payload = response .json::() @@ -3575,7 +3651,7 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource { } } -fn direct_codex_output_fingerprint(root: &Path) -> String { +fn direct_codex_output_fingerprint_checked(root: &Path) -> Result { let mut hasher = Sha256::new(); let mut paths: Vec = direct_codex_game_outputs(root) .into_iter() @@ -3588,16 +3664,27 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { hasher.update(local_path.as_bytes()); hasher.update([0]); let path = root.join(local_path); - match std::fs::read(path) { + match std::fs::read(&path) { Ok(bytes) => { hasher.update([1]); hasher.update((bytes.len() as u64).to_le_bytes()); hasher.update(bytes); } - Err(_) => hasher.update([0]), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update([0]), + Err(error) => { + return Err(format!( + "读取直连 Codex 输出文件失败:{}:{error}", + path.display() + )); + } } } - format!("{:x}", hasher.finalize()) + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +fn direct_codex_output_fingerprint(root: &Path) -> String { + direct_codex_output_fingerprint_checked(root).expect("读取直连 Codex 输出指纹") } fn direct_npm_source_paths(root: &Path) -> Vec { @@ -3631,7 +3718,6 @@ fn direct_npm_source_paths(root: &Path) -> Vec { | "dist" | "target" | "memory" - | "exports" | "auth.json" | "credentials.json" | "game-creator.config.json" @@ -4136,7 +4222,7 @@ fn sync_direct_codex_project_file_projection_at( root: &Path, previous_output_fingerprint: Option<&str>, ) -> Result<(), String> { - let current_output_fingerprint = direct_codex_output_fingerprint(root); + let current_output_fingerprint = direct_codex_output_fingerprint_checked(root)?; let outputs_changed = previous_output_fingerprint .map(|previous| previous != current_output_fingerprint) .unwrap_or(true); @@ -4519,7 +4605,9 @@ async fn run_direct_game_creator_turn_inner( direct_turn_trace("run-analytics-accepted"); // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 let result: Result = async { - if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { + let terminal_report = super::direct_delivery::terminal_report(&execution_session) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, format!("读取交付终态失败:{error}")))?; + if let Some(report) = terminal_report { return Ok(report); } // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID @@ -4556,7 +4644,8 @@ async fn run_direct_game_creator_turn_inner( let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; - let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let base_system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; @@ -4659,20 +4748,28 @@ async fn run_direct_game_creator_turn_inner( } // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, // 第二次拿到 `None` 时还会把空串当成回复返回。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) - { - Some(report) => break Ok(report), - None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = - truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); - emitter.emit("running", Some("error-feedback")); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + Err(error) => { + let original_detail = error.diagnostic_detail(); + match super::direct_delivery::terminal_report(&execution_session) { + Ok(Some(report)) => break Ok(report), + Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = + truncate_agent_runtime_text(&original_detail, 1800); + emitter.emit("running", Some("error-feedback")); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + Ok(None) => break Err(error), + Err(report_error) => break Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( + "读取交付终态失败:{report_error};原始回合失败:{original_detail}" + ), + )), } - None => break Err(error), - }, + } } }; drop(observer); @@ -4721,19 +4818,29 @@ async fn run_direct_game_creator_turn_inner( } // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` // 时还会绕过下面那条"未返回结果"的兜底错误)。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) - { - Some(report) => break Some(report), - None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = - truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + Err(error) => { + let original_detail = error.diagnostic_detail(); + match super::direct_delivery::terminal_report(&execution_session) { + Ok(Some(report)) => break Some(report), + Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = + truncate_agent_runtime_text(&original_detail, 1800); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + Ok(None) => return Err(error), + Err(report_error) => { + return Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( + "读取交付终态失败:{report_error};原始回合失败:{original_detail}" + ), + )) + } } - None => return Err(error), - }, + } } }; response.ok_or_else(|| { @@ -4749,7 +4856,9 @@ async fn run_direct_game_creator_turn_inner( if let Some(emitter) = turn_emitter { emitter.emit("finalizing", Some("response-finalization")); } - if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + let current_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?; + if current_output_fingerprint != previous_output_fingerprint { emit_direct_game_creator_progress( root, "project.sync", @@ -4891,7 +5000,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( .await .map_err(|error| TurnError::turn_failed(FailureStage::ArtPreparation, error))?; } - let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let mut system_prompt = build_direct_codex_system_prompt(root) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; if prepare_art { @@ -4903,7 +5013,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( } let _initial_reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()).await?; - let initial_output_fingerprint = direct_codex_output_fingerprint(root); + let initial_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let mut completion_error = direct_game_output_completion_error(root); let mut evidence_attempts = 0_usize; let mut evidence: Option = None; @@ -4934,7 +5045,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt).await?; - let repaired_fingerprint = direct_codex_output_fingerprint(root); + let repaired_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; completion_error = direct_game_output_completion_error(root); if completion_error.is_none() && (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str()) @@ -4965,7 +5077,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "试玩未通过,正在进行最后一次自主修复", ); reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt).await?; - let final_fingerprint = direct_codex_output_fingerprint(root); + let final_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; completion_error = direct_game_output_completion_error(root); if completion_error.is_none() && browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str()) @@ -7416,6 +7529,7 @@ mod tests { .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary=")); assert!(error.contains(""), "{error}"); assert!(error.contains(""), "{error}"); + assert!(error.contains("详情:读取陶泥儿画布资源失败"), "{error}"); assert!(!error.contains("authorization=Bearer secret"), "{error}"); assert!(!error.contains("?token=secret"), "{error}"); assert!(!error.contains("provider.example"), "{error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 2fbc2d2f6..39167f3a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -160,7 +160,7 @@ mod tests { TurnFailure::TurnInterrupted(payload) => payload.detail.clone(), TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(), TurnFailure::Unclassified(payload) => payload.detail.clone(), - TurnFailure::HostDropped => String::new(), + TurnFailure::HostDropped(payload) => payload.detail.clone().unwrap_or_default(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 32d491f9c..2c56f6f03 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1053,15 +1053,15 @@ fn bridge_resource_request_fingerprint(input: &DirectResourceGenerationInput) -> fn bridge_png_content(root: &Path, path: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "工具桥项目根无法安全解析".to_string())?; + .map_err(|error| format!("工具桥项目根无法安全解析:{error}"))?; let path = path .canonicalize() - .map_err(|_| "工具桥图片不存在".to_string())?; + .map_err(|error| format!("工具桥图片不存在或无法解析:{error}"))?; if !path.starts_with(&root) { return Err("工具桥图片越出当前项目边界".to_string()); } let metadata = - std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?; + std::fs::symlink_metadata(&path).map_err(|error| format!("读取工具桥图片失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES @@ -1071,7 +1071,7 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result { let (mut file, _) = open_project_snapshot_regular_file(&path, "工具桥图片")?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes) - .map_err(|_| "读取工具桥图片失败".to_string())?; + .map_err(|error| format!("读取工具桥图片失败:{error}"))?; if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Err("工具桥图片不是有效 PNG".to_string()); } @@ -3429,9 +3429,11 @@ async fn bridge_update_plan( DirectExecutionGateRejection::SessionUnavailable { cause }, )) } - Err(_) => { + Err(error) => { return Err(UpdatePlanError::Gate( - DirectExecutionGateRejection::SessionTaskLost, + DirectExecutionGateRejection::SessionTaskLost { + cause: error.to_string(), + }, )) } }; @@ -3548,8 +3550,20 @@ async fn bridge_web_search_at( cause: error.to_string(), })?; if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); return Err(WebSearchError::HttpStatusNotSuccess { - status: response.status().as_u16(), + status: status.as_u16(), + detail: (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail), }); } if let Some(length) = response @@ -3679,7 +3693,10 @@ async fn bridge_editor_execute( )) } Ok(Err(error)) => Err(error), - Err(_) => Err(EditorExecuteError::ExecutionUnconfirmed { editor }), + Err(error) => Err(EditorExecuteError::ExecutionUnconfirmed { + editor, + cause: error.to_string(), + }), } } @@ -3913,9 +3930,9 @@ async fn bridge_cocos_call( ), error.to_string(), ), - Err(_) => ( + Err(error) => ( true, - "Cocos execute worker 退出,执行结果需要核对".to_string(), + format!("Cocos execute worker 退出:{error};执行结果需要核对"), ), Ok(Ok(_)) => unreachable!(), }; @@ -3978,14 +3995,16 @@ async fn handle_direct_tool_bridge( )), )); } - Err(_) => { + Err(error) => { return Json(compose_direct_tool_outcome( &state, request.tool.as_str(), &diagnostics_arguments, false, Err(ToolCallError::from( - &DirectExecutionGateRejection::PermitTaskLost, + &DirectExecutionGateRejection::PermitTaskLost { + cause: error.to_string(), + }, )), )); } @@ -4178,13 +4197,20 @@ async fn handle_direct_tool_bridge( let finished = tokio::task::spawn_blocking(move || operation.finish(passed, false, None)).await; if !matches!(finished, Ok(Ok(()))) { + // 合并口径:结算调用沿用 master 的 `operation.finish` 形状,但失败时必须把 + // 真实原因带回调用方,不能只剩一句无正文的通用失败(错误收敛分支的意图)。 + let cause = match finished { + Ok(Err(error)) => format!("执行回执结算失败:{error}"), + Err(error) => format!("执行回执结算任务未返回:{error}"), + Ok(Ok(())) => "执行回执结算未确认".to_string(), + }; return Json(compose_direct_tool_outcome( &state, request.tool.as_str(), &diagnostics_arguments, dispatch_denied, Err(ToolCallError::from( - &DirectExecutionGateRejection::ReceiptNotPersisted, + &DirectExecutionGateRejection::ReceiptNotPersisted { cause }, )), )); } @@ -4223,7 +4249,7 @@ async fn start_tool_bridge_for_source( } let root = root .canonicalize() - .map_err(|_| "AGC 工具桥项目目录无法安全解析".to_string())?; + .map_err(|error| format!("AGC 工具桥项目目录无法安全解析:{error}"))?; let route = format!("/tool-{}", uuid::Uuid::new_v4().simple()); let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await 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 6a39a2773..7626ae617 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 @@ -80,11 +80,17 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option .build() { Ok(runtime) => runtime, - Err(_) => return Some(1), + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行时初始化失败:{error}"); + return Some(1); + } }; Some(match runtime.block_on(run_direct_tools_mcp_stdio()) { Ok(()) => 0, - Err(_) => 1, + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行失败:{error}"); + 1 + } }) } @@ -104,18 +110,27 @@ async fn direct_tools_mcp_specs() -> Value { feature = "godot-editor-execute" )) { - // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 - if let Ok(result) = tokio::time::timeout( + // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具,但必须把桥失败的 + // HTTP/IPC/解析正文留在应用日志,不能静默变成“工具不存在”。 + match tokio::time::timeout( std::time::Duration::from_secs(5), call_client_tool_bridge("builtin.plugins.tools", &json!({})), ) .await { - if result["isError"] == false { + Ok(result) if result["isError"] == false => { let availability = result .pointer("/content/0/text") .and_then(Value::as_str) - .and_then(|text| serde_json::from_str::(text).ok()); + .and_then(|text| match serde_json::from_str::(text) { + Ok(value) => Some(value), + Err(error) => { + app_log!( + "agent.direct_tools_mcp.plugin_capability_parse_failed detail={error}" + ); + None + } + }); cocos_editor_available = availability .as_ref() .and_then(|v| v["tools"].as_array()) @@ -141,6 +156,13 @@ async fn direct_tools_mcp_specs() -> Value { .any(|tool| tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME) }); } + Ok(result) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_failed detail={}", + result + ), + Err(error) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_timeout timeoutSeconds=5 detail={error}" + ), } } direct_tools_mcp_specs_for_plugins( @@ -245,6 +267,25 @@ fn direct_tools_mcp_specs_for_plugins( "additionalProperties": false } }), + json!({ + "name": "agc_install_skill_resource", + "description": prompt_text!("directTools.agc_install_skill_resource.description"), + "inputSchema": { + "type": "object", + "properties": { + "skillName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "relativePath": { "type": "string", "minLength": 1, "maxLength": 240 }, + "destinationPath": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": prompt_text!("directTools.agc_install_skill_resource.parameters.destinationPath") + } + }, + "required": ["skillName", "relativePath", "destinationPath"], + "additionalProperties": false + } + }), json!({ "name": "agc_write_file", "description": prompt_text!("directTools.agc_write_file.description"), @@ -902,7 +943,7 @@ fn build_direct_tool_bridge_client() -> Result { .timeout(std::time::Duration::from_secs(1_800)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建客户端工具桥连接失败".to_string()) + .map_err(|error| format!("创建客户端工具桥连接失败:{error}")) } async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { @@ -914,11 +955,17 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { .json(&json!({ "tool": tool, "arguments": arguments })) .send() .await - .map_err(|_| "连接客户端受控工具桥失败".to_string())?; - if !response.status().is_success() { - return Err(format!( - "客户端受控工具桥返回 HTTP {}", - response.status().as_u16() + .map_err(|error| format!("连接客户端受控工具桥失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "客户端受控工具桥请求", + status, + &body, )); } if response @@ -930,12 +977,12 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { let bytes = response .bytes() .await - .map_err(|_| "读取客户端受控工具桥响应失败".to_string())?; + .map_err(|error| format!("读取客户端受控工具桥响应失败:{error}"))?; if bytes.len() > DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES { return Err("客户端受控工具桥响应超过大小上限".to_string()); } let value = serde_json::from_slice::(&bytes) - .map_err(|_| "客户端受控工具桥响应格式无效".to_string())?; + .map_err(|error| format!("客户端受控工具桥响应格式无效:{error}"))?; if !value.is_object() || !value.get("content").is_some_and(Value::is_array) || !value.get("isError").is_some_and(Value::is_boolean) @@ -1031,11 +1078,7 @@ fn call_agc_read_skill_resource(arguments: &Value) -> Value { Ok(value) => value, Err(error) => return mcp_tool_result(error, Vec::new(), true), }; - if Path::new(&relative).is_absolute() - || relative.contains("..") - || relative.contains(':') - || relative.contains('\\') - { + if skill_resource_relative_path_is_unsafe(&relative) { return mcp_tool_result("Skill 资源路径不安全".to_string(), Vec::new(), true); } match read_agc_skill_resource(&format!("{skill}/{relative}")) { @@ -1044,6 +1087,40 @@ fn call_agc_read_skill_resource(arguments: &Value) -> Value { } } +/// Skill 资源相对路径的安全边界:`agc_read_skill_resource` 与 +/// `agc_install_skill_resource` 共用同一份判定。 +fn skill_resource_relative_path_is_unsafe(relative: &str) -> bool { + Path::new(relative).is_absolute() + || relative.contains("..") + || relative.contains(':') + || relative.contains('\\') +} + +/// 组装 `agc_install_skill_resource` 的落盘请求:宿主侧直接读已审核的内置字节, +/// 再复用 `agc_write_file` 的入参规则(项目相对路径 + 内容边界),写入仍走同一条合同 lease 门。 +/// +/// 正文不经过模型上下文,因此既不会被大文件截断,也不依赖原生 `cp` 及其审批。 +fn install_skill_resource_write_arguments(arguments: &Value) -> Result { + validate_tool_object_fields(arguments, &["skillName", "relativePath", "destinationPath"])?; + let skill = bounded_tool_string(arguments, "skillName", 120)?; + let relative = bounded_tool_string(arguments, "relativePath", 240)?; + if skill_resource_relative_path_is_unsafe(&relative) { + return Err("Skill 资源路径不安全".to_string()); + } + let destination = bounded_tool_string(arguments, "destinationPath", 512)?; + let content = read_agc_skill_resource(&format!("{skill}/{relative}"))?; + let write_arguments = json!({ "path": destination, "content": content }); + validate_write_file_arguments(&write_arguments)?; + Ok(write_arguments) +} + +async fn call_agc_install_skill_resource(arguments: &Value) -> Value { + match install_skill_resource_write_arguments(arguments) { + Ok(write_arguments) => call_client_tool_bridge("agc_write_file", &write_arguments).await, + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> Value { if !enabled { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -1112,22 +1189,18 @@ fn external_mcp_session_id(root: &Path) -> String { format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } -fn external_mcp_project_id(root: &Path) -> String { - std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| { - value - .get("projectId") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| { - format!( - "project-{:x}", - Sha256::digest(root.to_string_lossy().as_bytes()) - ) - }) +fn external_mcp_project_id(root: &Path) -> Result { + let path = root.join(".agent/manifest.json"); + let bytes = std::fs::read(&path).map_err(|error| format!("读取 MCP 项目清单失败:{error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析 MCP 项目清单失败:{error}"))?; + value + .get("projectId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "MCP 项目清单缺少有效 projectId".to_string()) } fn external_mcp_account_id() -> String { @@ -1166,17 +1239,22 @@ fn validate_external_mcp_record_arguments( fn read_external_mcp_journal(root: &Path) -> Result, String> { let path = external_mcp_journal_path(root); - let Ok(bytes) = std::fs::read(&path) else { - return Ok(Vec::new()); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取 Codex 返回记录失败:{error}")), }; if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { return Err("Codex 返回记录超过客户端保留上限".to_string()); } bytes .split(|byte| *byte == b'\n') - .filter(|line| !line.is_empty()) - .map(|line| { - serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + .enumerate() + .filter(|(_, line)| !line.is_empty()) + .map(|(line_number, line)| { + serde_json::from_slice::(line).map_err(|error| { + format!("Codex 返回记录格式损坏:第 {} 行:{error}", line_number + 1) + }) }) .collect() } @@ -1238,18 +1316,28 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ); } } + let received_at = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => duration.as_millis() as u64, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录时间失败:{error}"), + Vec::new(), + true, + ) + } + }; let record = json!({ "recordId": uuid::Uuid::new_v4().to_string(), "recordType": "codex.response", "accountId": external_mcp_account_id(), - "projectId": external_mcp_project_id(root), + "projectId": match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }, "sessionId": external_mcp_session_id(root), "requestId": request_id, "sequence": sequence, - "receivedAt": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(), + "receivedAt": received_at, "content": redacted, "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), "summary": external_mcp_response_summary(&redacted), @@ -1266,9 +1354,17 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ) } }; - let current_size = std::fs::metadata(&path) - .map(|metadata| metadata.len()) - .unwrap_or(0); + let current_size = match std::fs::metadata(&path) { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录大小失败:{error}"), + Vec::new(), + true, + ) + } + }; if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { return mcp_tool_result( "Codex 返回记录达到客户端保留上限".to_string(), @@ -1306,15 +1402,10 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { } fn external_mcp_session_info(root: &Path) -> Value { - let manifest = std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); - let project_id = manifest - .as_ref() - .and_then(|value| value.get("projectId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(); + let project_id = match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; mcp_tool_result( json!({ "status": "bound", @@ -1383,10 +1474,25 @@ async fn run_direct_tools_mcp_blocking_read( ) -> Value { match tokio::task::spawn_blocking(read).await { Ok(result) => result, - Err(_) => mcp_tool_result("MCP 本地资源读取任务未完成".to_string(), Vec::new(), true), + Err(error) => mcp_tool_result( + format!("MCP 本地资源读取任务未完成:{error}"), + Vec::new(), + true, + ), } } +fn direct_tools_mcp_panic_detail(payload: &(dyn std::any::Any + Send)) -> String { + let raw = if let Some(text) = payload.downcast_ref::<&str>() { + (*text).to_string() + } else if let Some(text) = payload.downcast_ref::() { + text.clone() + } else { + "未知 panic 负载".to_string() + }; + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 480) +} + async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; @@ -1438,13 +1544,17 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< .and_then(Value::as_str) .unwrap_or_default(); if uri == "agc://skills/index" { - let text = render_agc_skill_pack_index() - .map_err(|_| ()) - .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); - return Some(mcp_success( - id, - json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), - )); + return match render_agc_skill_pack_index() { + Ok(text) => Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 索引读取失败:{error}"), + )), + }; } if let Some(resource) = uri.strip_prefix("agc://skills/") { return match read_agc_skill_resource(resource) { @@ -1455,21 +1565,30 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< )) } Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), - Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 资源读取失败:{error}"), + )), }; } if uri != "agc://conversation/codex-responses" { Some(mcp_error(id, -32602, "未知资源")) } else { - let text = read_external_mcp_journal(root) - .map(|records| { - records - .iter() - .map(Value::to_string) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); + let text = match read_external_mcp_journal(root) { + Ok(records) => records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + Err(error) => { + return Some(mcp_error( + id, + -32603, + &format!("Codex 返回记录资源读取失败:{error}"), + )); + } + }; if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); } @@ -1528,6 +1647,7 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< .await } "agc_write_file" => call_agc_write_file(&arguments).await, + "agc_install_skill_resource" => call_agc_install_skill_resource(&arguments).await, #[cfg(all(windows, feature = "cocos-editor-execute"))] "agc_cocos_execute" => call_agc_cocos_execute(&arguments).await, #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] @@ -1571,7 +1691,7 @@ fn read_bounded_line(reader: &mut R) -> Result>, Stri loop { let available = reader .fill_buf() - .map_err(|_| "读取 MCP 请求失败".to_string())?; + .map_err(|error| format!("读取 MCP 请求失败:{error}"))?; if available.is_empty() { return Ok((!bytes.is_empty()).then_some(bytes)); } @@ -1599,11 +1719,12 @@ fn write_direct_tools_mcp_response( writer: &mut impl Write, response: &Value, ) -> Result<(), String> { - serde_json::to_writer(&mut *writer, response).map_err(|_| "写入 MCP 响应失败".to_string())?; + serde_json::to_writer(&mut *writer, response) + .map_err(|error| format!("写入 MCP 响应失败:序列化失败:{error}"))?; writer .write_all(b"\n") .and_then(|_| writer.flush()) - .map_err(|_| "写入 MCP 响应失败".to_string()) + .map_err(|error| format!("写入 MCP 响应失败:{error}")) } #[cfg(not(test))] @@ -1652,12 +1773,25 @@ where pending.push(async move { match std::panic::AssertUnwindSafe(future).catch_unwind().await { Ok(response) => response, - Err(_) => id.map(|id| mcp_error(id, -32603, "Tool execution interrupted")), + Err(payload) => id.map(|id| { + mcp_error( + id, + -32603, + &format!( + "Tool execution interrupted:{}", + direct_tools_mcp_panic_detail(payload.as_ref()) + ), + ) + }), } }); None } - Err(_) => Some(mcp_error(Value::Null, -32700, "Parse error")), + Err(error) => Some(mcp_error( + Value::Null, + -32700, + &format!("Parse error: {error}"), + )), }, Some(Err(error)) => { terminal_error = Some(error); @@ -1688,7 +1822,7 @@ where #[cfg(not(test))] async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let root = validate_direct_tools_project_root( - &std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?, + &std::env::current_dir().map_err(|error| format!("读取 MCP 工作目录失败:{error}"))?, )?; let (sender, receiver) = tokio::sync::mpsc::channel(DIRECT_TOOLS_MCP_MAX_IN_FLIGHT); std::thread::Builder::new() @@ -1697,7 +1831,7 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let stdin = std::io::stdin(); read_direct_tools_mcp_requests(BufReader::new(stdin.lock()), sender); }) - .map_err(|_| "启动 MCP 请求读取失败".to_string())?; + .map_err(|error| format!("启动 MCP 请求读取失败:{error}"))?; let stdout = std::io::stdout(); dispatch_direct_tools_mcp_requests(receiver, &mut stdout.lock(), move |request| { let root = root.clone(); @@ -1714,27 +1848,60 @@ fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { .is_some_and(|value| value == token) } +fn external_mcp_http_error( + status: StatusCode, + code: &'static str, + message: &'static str, +) -> axum::response::Response { + ( + status, + Json(json!({ + "error": { + "code": code, + "message": message, + "status": status.as_u16(), + } + })), + ) + .into_response() +} + async fn handle_external_mcp_http_request( AxumState(state): AxumState, headers: HeaderMap, Json(request): Json, -) -> Result { +) -> Result { if !external_mcp_authorized(&headers, &state.token) { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-unauthorized", + "客户端 MCP 鉴权失败:Bearer token 缺失、无效或已过期", + )); } let Some(session) = current_platform_session() else { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-unavailable", + "客户端 MCP 鉴权失败:当前账号会话不存在或已失效", + )); }; if session.user_id != state.session_user_id || session.identity_generation != state.session_identity_generation { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-changed", + "客户端 MCP 鉴权失败:账号会话已变化,请重新建立 MCP 连接", + )); } // HTTP 有独立入口容量;饱和时在任何工具副作用前拒绝,不与 STDIO 重复取许可。 - let _permit = state - .in_flight - .try_acquire() - .map_err(|_| StatusCode::TOO_MANY_REQUESTS)?; + let _permit = state.in_flight.try_acquire().map_err(|_| { + external_mcp_http_error( + StatusCode::TOO_MANY_REQUESTS, + "mcp-capacity-exhausted", + "客户端受控工具桥并发已满(上限 8),请稍后重试", + ) + })?; // JSON-RPC 通知(没有 `id`)没有响应体:MCP 客户端在 `initialize` 之后一定会发 // `notifications/initialized`,按错误回 400 会被判成握手失败,整个服务器就被标记 // 成 `failed`、一个工具都不广告(真实案例:2026-10-02 的 cc DirectProject 因此 @@ -1748,7 +1915,13 @@ async fn handle_external_mcp_http_request( handle_direct_tools_mcp_request(&state.root, request), ) .await - .ok_or(StatusCode::BAD_REQUEST)?; + .ok_or_else(|| { + external_mcp_http_error( + StatusCode::BAD_REQUEST, + "mcp-bridge-context-missing", + "客户端受控工具桥未绑定:当前请求不在受控工具执行上下文中", + ) + })?; Ok(Json(response).into_response()) } @@ -1806,7 +1979,9 @@ async fn start_external_mcp_loopback_with_mode( .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) .with_state(state); let task = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; + if let Err(error) = axum::serve(listener, app).await { + app_log!("agent.direct_tools_mcp.server_failed detail={error}"); + } }); let url = format!("http://127.0.0.1:{}{route}", address.port()); let registry = @@ -1828,21 +2003,33 @@ async fn start_external_mcp_loopback_with_mode( pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - guard.clear(); + match registry.lock() { + Ok(mut guard) => guard.clear(), + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_all detail=mutex-poisoned" + ), } } } pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { - let Ok(root) = root.canonicalize() else { - return; + let root = match root.canonicalize() { + Ok(root) => root, + Err(error) => { + app_log!("agent.direct_tools_mcp.stop_root_canonicalize_failed detail={error}"); + return; + } }; if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - if guard.get(&root).is_some_and(|server| server.token == token) { - guard.remove(&root); + match registry.lock() { + Ok(mut guard) => { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_root detail=mutex-poisoned" + ), } } } @@ -1903,7 +2090,17 @@ mod tests { Json(json!({"id":1,"method":"ping"})), ) .await; - assert_eq!(rejected.unwrap_err(), StatusCode::TOO_MANY_REQUESTS); + let rejected = rejected.unwrap_err(); + assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS); + let rejected_body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let rejected_body: Value = serde_json::from_slice(&rejected_body).unwrap(); + assert_eq!(rejected_body["error"]["code"], "mcp-capacity-exhausted"); + assert_eq!(rejected_body["error"]["status"], 429); + assert!(rejected_body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("并发已满"))); drop(occupied); let response = handle_external_mcp_http_request( AxumState(state.clone()), @@ -1923,6 +2120,44 @@ mod tests { ); } + #[tokio::test] + async fn external_http_mcp_auth_failure_keeps_status_and_actionable_body() { + let _session = crate::platform_session::install_test_platform_session( + "mcp-auth-user", + "mcp-auth-key", + "http://127.0.0.1:9", + ); + let session = current_platform_session().unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let state = ExternalMcpHttpState { + bridge_url: "http://127.0.0.1:9".to_string(), + root: temporary.path().to_path_buf(), + token: "expected-token".to_string(), + session_user_id: session.user_id, + session_identity_generation: session.identity_generation, + in_flight: std::sync::Arc::new(tokio::sync::Semaphore::new( + DIRECT_TOOLS_MCP_MAX_IN_FLIGHT, + )), + }; + let rejected = handle_external_mcp_http_request( + AxumState(state), + HeaderMap::new(), + Json(json!({"id":1,"method":"ping"})), + ) + .await + .unwrap_err(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["error"]["code"], "mcp-unauthorized"); + assert_eq!(body["error"]["status"], 401); + assert!(body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Bearer token"))); + } + #[tokio::test] async fn mcp_dispatch_runs_mixed_tools_concurrently_with_bounded_out_of_order_responses() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -2147,7 +2382,12 @@ mod tests { .map(|line| serde_json::from_str::(line).unwrap()) .collect::>(); assert_eq!(replies.len(), 2); - assert!(replies.iter().any(|reply| reply["error"]["code"] == -32700)); + assert!(replies.iter().any(|reply| { + reply["error"]["code"] == -32700 + && reply["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Parse error")) + })); assert!(replies .iter() .any(|reply| reply["id"] == "failed-call" && reply["error"]["code"] == -32603)); @@ -2744,6 +2984,7 @@ mod tests { "conversation.list", "conversation.read", "agc_read_skill_resource", + "agc_install_skill_resource", "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", @@ -3259,6 +3500,66 @@ mod tests { assert_eq!(denied_windows_absolute["isError"], true); } + #[test] + fn install_skill_resource_rewrites_reviewed_bytes_into_one_write_arguments() { + let write = install_skill_resource_write_arguments(&json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md", + "destinationPath": "scripts/structure-contract.md" + })) + .expect("reviewed skill resource composes a write"); + assert_eq!(write["path"], "scripts/structure-contract.md"); + assert_eq!( + write["content"], + read_agc_skill_resource("agc-project-structure/references/structure-contract.md") + .expect("reviewed bytes") + ); + // 只暴露 agc_write_file 的两个入参,模型自带的正文没有旁路通道。 + assert_eq!(write.as_object().expect("object").len(), 2); + } + + #[test] + fn install_skill_resource_rejects_unreviewed_or_unsafe_targets() { + for arguments in [ + // 未登记的资源 + json!({ + "skillName": "agc-project-structure", + "relativePath": "references/not-in-manifest.md", + "destinationPath": "scripts/x.md" + }), + // 资源路径穿越 + json!({ + "skillName": "agc-project-structure", + "relativePath": "../../auth.json", + "destinationPath": "scripts/x.md" + }), + // Windows 绝对路径 + json!({ + "skillName": "agc-project-structure", + "relativePath": r"C:\temp\SKILL.md", + "destinationPath": "scripts/x.md" + }), + // 目标路径越出项目根 + json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md", + "destinationPath": "../outside.md" + }), + // 模型自带正文的旁路字段 + json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md", + "destinationPath": "scripts/x.md", + "content": "model-supplied" + }), + ] { + assert!( + install_skill_resource_write_arguments(&arguments).is_err(), + "{arguments} must be rejected before any bridge call" + ); + } + } + #[test] fn editor_guides_are_available_through_the_existing_skill_resource_tool() { for (skill, engine) in [("agc-unity-editor", "Unity"), ("agc-godot-editor", "Godot")] { @@ -3302,6 +3603,57 @@ mod tests { .is_err()); } + #[test] + fn external_codex_response_journal_distinguishes_missing_from_read_and_parse_errors() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-read-response-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-read-response", "Codex 返回读取测试") + .expect("init project"); + assert!(read_external_mcp_journal(root).unwrap().is_empty()); + + let journal = external_mcp_journal_path(root); + std::fs::create_dir_all(&journal).unwrap(); + let error = read_external_mcp_journal(root).unwrap_err(); + assert!(error.contains("读取 Codex 返回记录失败"), "{error}"); + + std::fs::remove_dir_all(&journal).unwrap(); + std::fs::write(&journal, b"{broken-json\n").unwrap(); + let error = read_external_mcp_journal(root).unwrap_err(); + assert!(error.contains("第 1 行"), "{error}"); + assert!(error.contains("Codex 返回记录格式损坏"), "{error}"); + } + + #[test] + fn external_codex_response_does_not_hide_corrupt_project_manifest() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-manifest-error-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-manifest-error", "MCP 清单错误测试") + .expect("init project"); + std::fs::write(root.join(".agent/manifest.json"), b"{broken-json\n") + .expect("corrupt manifest"); + + let record = external_mcp_record_response( + root, + &json!({ + "requestId": "req-corrupt-manifest", + "sequence": 0, + "content": "这条记录不应伪造 projectId" + }), + ); + assert_eq!(record["isError"], true, "{record}"); + assert!( + record.to_string().contains("解析 MCP 项目清单失败"), + "{record}" + ); + + let session = external_mcp_session_info(root); + assert_eq!(session["isError"], true, "{session}"); + assert!( + session.to_string().contains("解析 MCP 项目清单失败"), + "{session}" + ); + } + #[test] fn recorded_codex_response_only_writes_its_own_journal() { let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index b03817add..fd32e98a1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -79,17 +79,17 @@ fn evidence_hashes(root: &Path, result: &Value) -> Result { return Err("validation-evidence: 证据不属于受控验证目录".into()); } let path = resolve_local_project_path(root, relative)?; - let meta = - std::fs::symlink_metadata(&path).map_err(|_| "validation-evidence: 证据文件丢失")?; + let meta = std::fs::symlink_metadata(&path) + .map_err(|error| format!("validation-evidence: 证据文件丢失:{error}"))?; if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据类型或大小无效".into()); } let mut bytes = Vec::new(); std::fs::File::open(path) - .map_err(|_| "validation-evidence: 无法读取证据")? + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))? .take(16 * 1024 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "validation-evidence: 无法读取证据")?; + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?; if bytes.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据文件在读取时超限".into()); } @@ -212,14 +212,15 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { let mut bytes = 0u64; let mut visited = 0usize; while let Some(directory) = directories.pop() { - for entry in - std::fs::read_dir(&directory).map_err(|_| "validation-fingerprint: 读取项目失败")? + for entry in std::fs::read_dir(&directory) + .map_err(|error| format!("validation-fingerprint: 读取项目失败:{error}"))? { visited += 1; if visited > 20_000 { return Err("validation-fingerprint: 项目文件数超限".into()); } - let entry = entry.map_err(|_| "validation-fingerprint: 读取目录项失败")?; + let entry = entry + .map_err(|error| format!("validation-fingerprint: 读取目录项失败:{error}"))?; let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); if matches!( name.as_str(), @@ -233,7 +234,6 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { | ".cache" | "node_modules" | "target" - | "exports" | "logs" | "coverage" ) || name.starts_with(".edge-profile") @@ -242,7 +242,7 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { } let ty = entry .file_type() - .map_err(|_| "validation-fingerprint: 读取类型失败")?; + .map_err(|error| format!("validation-fingerprint: 读取类型失败:{error}"))?; let path = entry.path(); if !include_outputs && name == "dist" @@ -278,14 +278,14 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { if name.starts_with("game-creator.config") { continue; } - let mut file = - std::fs::File::open(&path).map_err(|_| "validation-fingerprint: 打开输入失败")?; + let mut file = std::fs::File::open(&path) + .map_err(|error| format!("validation-fingerprint: 打开输入失败:{error}"))?; let mut digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let read = file .read(&mut buffer) - .map_err(|_| "validation-fingerprint: 读取输入失败")?; + .map_err(|error| format!("validation-fingerprint: 读取输入失败:{error}"))?; if read == 0 { break; } @@ -358,7 +358,7 @@ async fn start_for_current_turn( reserve(&root, session, &key, &fingerprint, &source, build) }) .await - .map_err(|_| "验证预约任务退出".to_string())? + .map_err(|error| format!("验证预约任务退出:{error}"))? } async fn finish_async( @@ -370,7 +370,7 @@ async fn finish_async( let root = root.to_path_buf(); tokio::task::spawn_blocking(move || finish(&root, &reservation, result, passed)) .await - .map_err(|_| "验证回执任务退出".to_string())? + .map_err(|error| format!("验证回执任务退出:{error}"))? } #[derive(Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 643db58cc..8bdd5f07e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -928,8 +928,8 @@ pub(crate) fn classify_external_generation_initial_response( )) } _ => Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {}", + format_external_http_error("平台图片生成", status, &payload.to_string()) )), } } @@ -970,7 +970,7 @@ async fn accepted_generation_is_authoritatively_failed_once( ), ) .await - .map_err(|_| "查询平台图片生成任务超时".to_string())??; + .map_err(|_| format!("查询平台图片生成任务超时(3000 ms);operationId={operation_id}"))??; access.validate_frozen_session()?; let generation = platform_generation_status_data(&payload); match json_string_field(generation, "status").as_deref() { @@ -1000,7 +1000,10 @@ pub(crate) async fn external_editor_json_request( })?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error(action, status, &body)); } @@ -1139,6 +1142,27 @@ fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Ve } } +fn summarize_external_generation_failure(generation: &serde_json::Value) -> String { + let source = generation.get("error").unwrap_or(generation); + let mut fields = Vec::new(); + collect_external_http_error_fields(source, &mut fields); + let detail = if fields.is_empty() { + match source { + serde_json::Value::String(value) => value.trim().to_string(), + _ => serde_json::to_string(source).unwrap_or_default(), + } + } else { + fields.join(";") + }; + let detail = redact_secret_tokens(&redact_absolute_path_tokens(&detail)); + let detail = detail.trim().chars().take(600).collect::(); + if detail.is_empty() { + "平台图片生成任务返回 failed,但响应缺少 error 详情".to_string() + } else { + detail + } +} + pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") @@ -1268,8 +1292,7 @@ async fn wait_for_external_generation_result_inner( return Ok(result); } Some("failed") => { - let error = json_string_field(generation, "error") - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + let error = summarize_external_generation_failure(generation); return Err(format!( "平台图片生成任务失败:{error};operationId={operation_id}" )); @@ -1336,9 +1359,14 @@ pub(crate) async fn submit_external_generation_request( ) })?; if response.status().is_server_error() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知", - response.status().as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知", + format_external_http_error("平台图片生成", status, &body) )); } Ok(response) @@ -1366,11 +1394,14 @@ async fn resume_prepared_external_generation_at( let status = response.status(); if !status.is_success() { post_submit_session?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留", + format_external_http_error("External Editor prepared 恢复提交", status, &body) )); } let submission_payload_result = response @@ -1589,7 +1620,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( return Err("绑定素材目录不存在,无法继续生成".to_string()); } if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error( "读取绑定素材目录", @@ -1943,7 +1977,7 @@ fn read_validated_platform_art_reference_at( } let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + .map_err(|error| format!("参考素材不是可解析图片:{}:{error}", source.local_path))?; Ok((bytes, decoded)) } @@ -2087,11 +2121,11 @@ async fn upload_manifest_asset_remote_reference_at( }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; + .map_err(|error| format!("当前账号参考图上传地址无效:{error}"))?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; + .map_err(|error| format!("无法创建当前账号参考图上传客户端:{error}"))?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -2104,17 +2138,23 @@ async fn upload_manifest_asset_remote_reference_at( // 请求发出前就结束生命周期。 .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("参考图媒体类型不能用于上传:{error}"))?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号参考图失败".to_string())?; + .map_err(|error| format!("上传当前账号参考图失败:{error}"))?; if upload_response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传当前账号参考图失败:HTTP {}", - upload_response.status().as_u16() + let status = upload_response.status(); + let body = upload_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "上传当前账号参考图", + status, + &body, )); } access.validate_frozen_session()?; @@ -3648,7 +3688,10 @@ async fn request_platform_art_asset_for_purpose_at( if !status.is_success() { post_submit_session?; binding_access.validate_frozen_session()?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); binding_access.validate_frozen_session()?; if external_generation_submit_rejection_is_definitive(status) { @@ -8748,6 +8791,21 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn failed_generation_without_error_field_keeps_response_envelope_detail() { + let detail = summarize_external_generation_failure(&serde_json::json!({ + "status": "failed", + "code": "provider-rejected", + "message": "上游拒绝请求", + "operationId": "private-operation-id", + "api_key": "private-key" + })); + assert!(detail.contains("provider-rejected"), "{detail}"); + assert!(detail.contains("上游拒绝请求"), "{detail}"); + assert!(!detail.contains("private-operation-id"), "{detail}"); + assert!(!detail.contains("private-key"), "{detail}"); + } + #[test] fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( @@ -12981,6 +13039,7 @@ mod canvas_generation_tests { .await; server.join().expect("join accepted failure fixture"); assert!(error.contains("平台图片生成任务失败"), "{error}"); + assert!(error.contains("provider rejected request"), "{error}"); assert!(request_receiver .recv_timeout(Duration::from_secs(1)) .expect("failed operation request") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs index ac67a164a..ba5224c66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs @@ -19,7 +19,6 @@ pub(crate) fn write_local_game_draft_at( let balance_path = root.join("game/balance.json"); let art_manifest_path = root.join("assets/manifest.art.json"); let audio_manifest_path = root.join("assets/manifest.audio.json"); - let publish_readme_path = root.join("exports/README.md"); let agent_log_path = root.join(".agent/logs/agent.log"); let short_memory_path = root.join("memory/session.md"); let long_memory_path = root.join("memory/project.md"); @@ -68,20 +67,6 @@ pub(crate) fn write_local_game_draft_at( audio_manifest_path.display() ) })?; - fs::write( - &publish_readme_path, - format!( - "# 发布包装草案\n\n## 标题\n\n{title}\n\n## 简介\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n{}\n", - draft.publish_readme.trim() - ), - ) - .map_err(|error| { - format!( - "写入发布包装草案失败:{}: {error}", - publish_readme_path.display() - ) - })?; - fs::write(&game_index_path, draft.game_html.trim()) .map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?; fs::OpenOptions::new() @@ -107,7 +92,6 @@ pub(crate) fn write_local_game_draft_at( "game/balance.json", "assets/manifest.art.json", "assets/manifest.audio.json", - "exports/README.md", "game/index.html", ], }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index de0390d4f..828d206b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -1,4 +1,5 @@ use super::*; +use std::path::Path; pub(crate) struct AgentProgressEmitter<'a> { app: &'a tauri::AppHandle, @@ -15,14 +16,19 @@ impl<'a> AgentProgressEmitter<'a> { } fn emit(&self, stage: &str, message: &str) { - let _ = self.app.emit( + if let Err(error) = self.app.emit( "game-creator-agent-progress", GameCreatorAgentProgressEvent { project_path: self.project_path.clone(), stage: stage.to_string(), message: message.to_string(), }, - ); + ) { + app_log!( + "agent.generation.progress.emit_failed stage={} detail={error}", + stage + ); + } } } @@ -104,12 +110,30 @@ pub(crate) fn game_creator_agent_llm_error_public_summary( platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), }; let raw = error.to_string(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 1200); let http_status = http_status .map(|status| format!(" httpStatus={status}")) .unwrap_or_default(); format!( - "kind={kind}{http_status} fingerprint={:x} chars={}", + "kind={kind}{http_status} detail={detail} fingerprint={:x} chars={}", Sha256::digest(raw.as_bytes()), raw.chars().count() ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_summary_keeps_safe_error_detail() { + let summary = + game_creator_agent_llm_error_public_summary(&platform_llm::LlmError::Upstream { + status_code: 502, + message: "upstream overloaded; operationId=private-op; api_key=private-key".into(), + }); + assert!(summary.contains("kind=upstream-502"), "{summary}"); + assert!(summary.contains("upstream overloaded"), "{summary}"); + assert!(!summary.contains("private-key"), "{summary}"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs index 6710b1bba..1a8d9b552 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs @@ -354,7 +354,7 @@ pub(crate) fn append_agent_success_memories( let timestamp = unix_timestamp(); let title = draft.title.trim(); let mut blackboard_entry = format!( - "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n\n## 角色共享摘要\n\n" + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n\n## 角色共享摘要\n\n" ); for brief in briefs { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9e7c8518e..131017a1c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -621,7 +621,6 @@ const ERROR_SENSITIVE_ASSIGNMENT_KEYS: &[&str] = &[ ]; const ERROR_REDACTED_VALUE: &str = "[redacted-secret]"; -const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// Redact an error for display in Runtime state, diagnostics, and tool /// results. This intentionally does not call `sanitize_prompt_context`: the @@ -952,14 +951,11 @@ fn redact_error_sensitive_assignments(line: &str) -> String { if value_start < cursor { break; } - // Do not retain the sensitive field name itself. A warning may be - // serialized to the Agent/UI, and names such as `api_key` or - // `Authorization` are sensitive context even after their values have - // been replaced. Preserve surrounding quotes, separators, and - // whitespace so JSON-ish diagnostics remain readable. + // 保留敏感字段名,只替换值:Authorization/api_key/token 本身是排障分类,值才是秘密。 + // 同时保留引号、分隔符和空白,使 JSON-ish 错误仍然可读。 let key_end = key_start + key_len; output.push_str(&line[cursor..key_start]); - output.push_str(ERROR_REDACTED_KEY); + output.push_str(&line[key_start..key_end]); output.push_str(&line[key_end..value_start]); let (value_end, replacement) = error_assignment_value_replacement(line, value_start); if value_end <= value_start { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs index 46c41064a..8637c474b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs @@ -234,7 +234,6 @@ pub(crate) fn append_local_artifact_write_step( "game/balance.json".to_string(), "assets/manifest.art.json".to_string(), "assets/manifest.audio.json".to_string(), - "exports/README.md".to_string(), "game/index.html".to_string(), ".agent/manifest.json".to_string(), ], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 7e7a2e385..ca15b25c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -405,9 +405,10 @@ mod tests { let identity = crate::sanitize_diagnostic_message(&marked[0], None); assert!(identity.contains("eventId=error-3-1"), "{identity}"); assert!(identity.contains("code=tool-error"), "{identity}"); - assert_eq!( - crate::sanitize_diagnostic_message(&marked[1], None), - "" + let marked_detail = crate::sanitize_diagnostic_message(&marked[1], None); + assert!( + marked_detail.contains("credential rotation failed"), + "{marked_detail}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 7354b7ce8..940d7acee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -6,7 +6,7 @@ use std::path::{Component, Path}; const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json"); const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1"; -pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 8] = [ +pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 11] = [ "agc-browser-playtest", "agc-client-projection", "agc-game-production-workflow", @@ -14,10 +14,13 @@ pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 8] = [ "agc-project-structure", "agc-unity-editor", "agc-web-game-development", + "platform-abstract", "taonier-art-assets", + "vite-export-taonier", + "vite-export-xhs-minitool", ]; -const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 23] = [ +const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 39] = [ ( "agc-unity-editor/SKILL.md", include_bytes!("../../resources/agc-skills/agc-unity-editor/SKILL.md"), @@ -122,6 +125,90 @@ const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 23] = [ "../../resources/agc-skills/taonier-art-assets/references/platform-art-contract.md" ), ), + ( + "platform-abstract/SKILL.md", + include_bytes!("../../resources/agc-skills/platform-abstract/SKILL.md"), + ), + ( + "vite-export-taonier/SKILL.md", + include_bytes!("../../resources/agc-skills/vite-export-taonier/SKILL.md"), + ), + ( + "vite-export-taonier/scripts/pack.mjs", + include_bytes!("../../resources/agc-skills/vite-export-taonier/scripts/pack.mjs"), + ), + ( + "vite-export-taonier/scripts/vite.config.taonier.mjs", + include_bytes!( + "../../resources/agc-skills/vite-export-taonier/scripts/vite.config.taonier.mjs" + ), + ), + ( + "vite-export-xhs-minitool/SKILL.md", + include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/SKILL.md"), + ), + ( + "vite-export-xhs-minitool/references/cross-platform-h5.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/cross-platform-h5.md" + ), + ), + ( + "vite-export-xhs-minitool/references/css-compatibility.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/css-compatibility.md" + ), + ), + ( + "vite-export-xhs-minitool/references/device-capabilities.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/device-capabilities.md" + ), + ), + ( + "vite-export-xhs-minitool/references/js-api.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/js-api.md" + ), + ), + ( + "vite-export-xhs-minitool/references/js-compatibility.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/js-compatibility.md" + ), + ), + ( + "vite-export-xhs-minitool/references/manual-checks.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/manual-checks.md" + ), + ), + ( + "vite-export-xhs-minitool/references/performance-budget.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/performance-budget.md" + ), + ), + ( + "vite-export-xhs-minitool/references/zip-artifact-spec.md", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/references/zip-artifact-spec.md" + ), + ), + ( + "vite-export-xhs-minitool/scripts/pack.mjs", + include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs"), + ), + ( + "vite-export-xhs-minitool/scripts/validate.mjs", + include_bytes!("../../resources/agc-skills/vite-export-xhs-minitool/scripts/validate.mjs"), + ), + ( + "vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs", + include_bytes!( + "../../resources/agc-skills/vite-export-xhs-minitool/scripts/vite.config.xhs-minitool.mjs" + ), + ), ]; #[derive(Clone, Debug, Deserialize)] @@ -150,15 +237,27 @@ pub(crate) struct AgcSkillCatalogEntry { pub(crate) description: String, } +/// 隐藏文件/目录名(以 `.` 开头)永远不进入审核 Skill Pack。 +/// +/// 与 `scripts/skill-pack-manifest.mjs` 的 `isHiddenSkillEntryName` 保持同一口径: +/// Skill 目录里以 `.` 开头的文件和目录是本地开发辅助(例如 `.selective_rule.txt`、 +/// `scripts/.pack.test.mjs`),既不写进 manifest,也不做指纹、不安装、不可读取。 +fn is_hidden_skill_entry_name(name: &str) -> bool { + name.starts_with('.') +} + fn is_safe_skill_relative_path(value: &str) -> bool { let path = Path::new(value); !value.is_empty() && !value.contains('\\') && !value.contains(':') && !path.is_absolute() - && path - .components() - .all(|component| matches!(component, Component::Normal(part) if !part.is_empty())) + && path.components().all(|component| match component { + Component::Normal(part) => { + !part.is_empty() && !is_hidden_skill_entry_name(part.to_string_lossy().as_ref()) + } + _ => false, + }) } fn bundled_skill_file(path: &str) -> Option<&'static [u8]> { @@ -449,6 +548,10 @@ mod tests { assert!(!is_safe_skill_relative_path("C:/temp/SKILL.md")); assert!(!is_safe_skill_relative_path(r"\\server\share\SKILL.md")); assert!(!is_safe_skill_relative_path(r"references\contract.md")); + // 隐藏文件/目录不进入审核 Skill Pack,声明了也必须被拒绝。 + assert!(!is_safe_skill_relative_path(".selective_rule.txt")); + assert!(!is_safe_skill_relative_path("scripts/.pack.test.mjs")); + assert!(!is_safe_skill_relative_path(".hidden/SKILL.md")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index ec68232d3..16b064817 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -33,6 +33,8 @@ pub(crate) struct TurnReservation { thread_id: String, token: String, user_item_id: Option, + /// panic hook 在析构之前记录可用负载;随占用保存,future 取消时不依赖 task-local 仍可访问。 + panic_detail: std::sync::Arc>>, /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,随这一轮的占用同生共死。 /// /// 渲染层不再按固定间隔触发续期(见 `useDirectProjectChatController` 的说明):会话保活的 @@ -47,6 +49,7 @@ impl TurnReservation { thread_id: thread_id.to_string(), token: dispatched.token.clone(), user_item_id: dispatched.pending.user_item_id(), + panic_detail: std::sync::Arc::new(std::sync::Mutex::new(None)), _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), } } @@ -86,8 +89,19 @@ impl TurnReservation { impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let finalized_by_guard = self.finish_if_unfinished(TurnCompletion::host_dropped()); + // 这里至少把 Drop 当下能观测到的收场事实带进失败载荷,不能只给一个空分类。 + let panic_detail = match self.panic_detail.lock() { + Ok(slot) => slot.clone(), + Err(_) => Some("读取 DirectProject 宿主 panic 详情失败:panic 上下文锁已中毒".into()), + }; + let host_drop_detail = panic_detail.as_deref().unwrap_or(if std::thread::panicking() { + "DirectProject 宿主任务 panic,未能写下终态;具体 panic 负载请查看应用日志" + } else { + "DirectProject 宿主任务退出时未写下终态,Drop 未观察到 panic;可能为 future 被取消、丢弃或提前返回" + }); + let finalized_by_guard = self.finish_if_unfinished( + TurnCompletion::host_dropped_with_detail(Path::new(&self.thread_id), host_drop_detail), + ); // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 @@ -96,9 +110,8 @@ impl Drop for TurnReservation { if finalized_by_guard { // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场, // 不必只依赖 AppData 的应用日志。 - // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, - // 整行被替换成 ``,离线就只剩一个 - // 说不出原因的 HostDropped。 + // 保留回合定位字段;错误日志只替换敏感值,不再因字段名命中凭据标记而吞掉整行, + // 这样离线仍能看出 HostDropped 的发生位置。 let failure = TurnError::turn_failed( FailureStage::CodeGeneration, format!( @@ -125,20 +138,27 @@ impl Drop for TurnReservation { // 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。 // task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 tokio::task_local! { - static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>; + static DIRECT_TURN_PANIC_CONTEXT: Option; +} + +#[derive(Clone)] +struct DirectTurnPanicContext { + thread_id: String, + token: String, + detail: std::sync::Arc>>, } /// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。 /// -/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因" -/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` -/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 +/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成没有 panic detail 的 `HostDropped`, +/// 离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` 全空, +/// 无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 fn ensure_direct_turn_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { + if let Ok(Some(context)) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { let location = info .location() .map(|location| { @@ -150,16 +170,27 @@ fn ensure_direct_turn_panic_hook() { ) }) .unwrap_or_else(|| "未知位置".to_string()); + let detail = format!( + "DirectProject 宿主任务 panic:{};位置={location}", + direct_turn_panic_detail(info.payload()) + ); + if let Ok(mut slot) = context.detail.lock() { + *slot = Some(crate::agent::redact_agent_runtime_error( + Path::new(&context.thread_id), + &detail, + 480, + )); + } #[cfg(not(test))] app_log!( "agent.direct_turn.panic threadId={} tt={} location={} payload={}", - thread_id, - token, + context.thread_id, + context.token, location, info.payload_as_str().unwrap_or("未知 panic 负载") ); #[cfg(test)] - let _ = (thread_id, token, location); + let _ = (context.thread_id, context.token, location); } previous(info); })); @@ -185,7 +216,11 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { let reservation = TurnReservation::resume(&thread_id, &dispatched); let root = root.to_path_buf(); ensure_direct_turn_panic_hook(); - let panic_context = Some((thread_id.clone(), dispatched.token.clone())); + let panic_context = Some(DirectTurnPanicContext { + thread_id: thread_id.clone(), + token: dispatched.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }); tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move { run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) .await; @@ -240,7 +275,7 @@ async fn run_dispatched_direct_turn( crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); let capture = crate::analytics::gui::capture_writer_context(); let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id.clone()); - // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的 + // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成只有 Drop 观测事实的 // `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志 // **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败, // 走与正常失败同一条收口:先写脱敏诊断,再写终态。 @@ -312,6 +347,47 @@ mod tests { }; use uuid::Uuid; + #[tokio::test] + async fn panic_before_explicit_terminal_keeps_payload_in_drop_failure() { + let thread = unique_thread("panic-detail"); + let subscription = watch(&thread); + let reservation = TurnReservation::accept_for_test(&thread, "turn-panic-detail"); + ensure_direct_turn_panic_hook(); + let context = DirectTurnPanicContext { + thread_id: thread.clone(), + token: reservation.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }; + let outcome = DIRECT_TURN_PANIC_CONTEXT + .scope( + Some(context), + std::panic::AssertUnwindSafe(async move { + let _reservation = reservation; + panic!( + "IPC EPIPE; Authorization: Bearer fixture-secret; out of memory (ENOMEM)" + ); + }) + .catch_unwind(), + ) + .await; + assert!(outcome.is_err()); + let events = pending(&subscription); + let payload = events + .iter() + .find_map(|event| match event { + ThreadEvent::TurnCompleted { + failure: Some(TurnFailure::HostDropped(payload)), + .. + } => Some(payload), + _ => None, + }) + .expect("drop failure must include panic detail"); + let detail = payload.detail.as_deref().expect("panic detail"); + assert!(detail.contains("IPC EPIPE"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(!detail.contains("fixture-secret"), "{detail}"); + } + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 fn watch(thread_id: &str) -> String { let bootstrap = subscribe_thread(thread_id); @@ -443,7 +519,8 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if *status == TurnCompletedStatus::Failed && matches!(failure, TurnFailure::HostDropped) + if *status == TurnCompletedStatus::Failed + && matches!(failure, TurnFailure::HostDropped(_)) ), "{events:?}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index da0fdff89..14f43e3e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -1006,11 +1006,16 @@ fn notify_subscribers(thread_id: &str) { }; if let Some(app) = THREAD_MANAGER_APP_HANDLE.get() { for subscription_id in subscriber_ids { - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, THREAD_NOTIFY_EVENT, serde_json::json!({ "subscriptionId": subscription_id }), - ); + ) { + app_log!( + "agent.thread_manager.notify.emit_failed subscriptionId={} detail={error}", + subscription_id + ); + } } } } @@ -1259,7 +1264,12 @@ mod tests { manager.append("thread-1", ThreadEvent::turn_started(1_000)); manager.append( "thread-1", - ThreadEvent::turn_completed_failed(crate::agent::TurnFailure::HostDropped, FIXED_AT_MS), + ThreadEvent::turn_completed_failed( + crate::agent::TurnFailure::HostDropped(crate::agent::HostDropped { + detail: Some("宿主任务提前结束".into()), + }), + FIXED_AT_MS, + ), ); let bootstrap = manager.subscribe("thread-1"); @@ -1269,7 +1279,7 @@ mod tests { if *status == TurnCompletedStatus::Failed && failure.as_ref().is_some_and(|failure| matches!( failure, - crate::agent::TurnFailure::HostDropped + crate::agent::TurnFailure::HostDropped(_) )) && *at == Some(FIXED_AT_MS) )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs index e97280e1b..864d28acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -18,7 +18,7 @@ use std::path::Path; use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified}; -use super::wire::TurnFailure; +use super::wire::{HostDropped, TurnFailure}; /// 一轮的收场:正常收场只有前三档,失败必须带载荷。 /// @@ -56,9 +56,17 @@ impl TurnCompletion { /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 /// - /// 这类收场说不出原因;能说清原因的一律走 [`Self::failed`]。 + /// 旧调用方仍可构造没有正文的兼容载荷;生产 Drop 路径使用 + /// [`Self::host_dropped_with_detail`],把能观测到的收场事实带给前端。 pub(crate) fn host_dropped() -> Self { - Self::Failed(TurnFailure::HostDropped) + Self::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) + } + + pub(crate) fn host_dropped_with_detail(history_root: &Path, detail: &str) -> Self { + let detail = crate::agent::redact_agent_runtime_error(history_root, detail, 480); + Self::Failed(TurnFailure::HostDropped(HostDropped { + detail: (!detail.trim().is_empty()).then_some(detail), + })) } } @@ -107,7 +115,7 @@ pub(crate) fn turn_terminal( } } session_completion(session_status).unwrap_or_else(|| { - // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 + // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条原因缺失的失败,也不冒充 // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 let error = TurnError::Unclassified(Unclassified { detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), @@ -292,12 +300,12 @@ stderrClass=nonempty;stderrBytes=1000"; )); } - /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + /// 兼容终态:没有额外 detail 的旧构造仍能收口;生产 Drop 路径会带观测到的原因。 #[test] - fn host_dropped_terminal_only_carries_the_classification() { + fn host_dropped_terminal_keeps_optional_detail_shape() { assert_eq!( TurnCompletion::host_dropped(), - TurnCompletion::Failed(TurnFailure::HostDropped) + TurnCompletion::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs index deb44460a..508932fb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -20,8 +20,8 @@ use crate::agent::{ /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 -/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 -/// 不直接上屏。 +/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,由前端精确脱敏后 +/// 展示,或用于诊断。 /// /// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 /// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 @@ -45,6 +45,19 @@ pub(crate) enum TurnFailure { TurnInterrupted(TurnInterrupted), SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage), Unclassified(Unclassified), - /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 - HostDropped, + /// 宿主任务提前结束(panic / 被取消):带上宿主能观测到的具体收场原因。 + /// + /// `detail` 设为可选是为了兼容旧版本已经落盘的 `{ "type": "hostDropped" }` 事件; + /// 新事件由占用对象 Drop 路径补上 `panic` 或 `future 被取消/丢弃`。 + HostDropped(HostDropped), +} + +/// 宿主任务提前结束时可观测到的收场事实。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HostDropped { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub(crate) detail: Option, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs index 97f70b437..fc8267ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs @@ -72,6 +72,7 @@ pub(crate) enum EditorExecuteError { /// 执行任务异常退出,回执不可用:结果待核对,不要自动重放。 ExecutionUnconfirmed { editor: EditorKind, + cause: String, }, } @@ -108,8 +109,8 @@ impl ToolFailure for EditorExecuteError { Self::ExecutionNotCompleted { editor, report } => { format!("{} 执行未完成:{report}", editor.label()) } - Self::ExecutionUnconfirmed { editor } => format!( - "{} 执行任务异常,回执不可用;执行已发出,结果待核对,不要自动重放:\ + Self::ExecutionUnconfirmed { editor, cause } => format!( + "{} 执行任务异常,回执不可用:{cause};执行已发出,结果待核对,不要自动重放:\ {{\"status\":\"needs-reconciliation\",\"dispatched\":true,\"retryAllowed\":false}}", editor.label() ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index fe86f162c..89311142f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -185,8 +185,8 @@ impl ToolFailure for ResourceCompletionRejection { /// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实, /// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 /// -/// `to_user_msg` 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。`phase_detail` -/// 是结构化字段,只进诊断 sidecar 给开发者/LLM 看,不参与用户文案。 +/// `to_user_msg` 优先给平台 `error` 原文,缺失时展示同一终态的 `phase_detail`;两者都没有 +/// 才说明响应没有提供失败正文。结构化字段也会留在诊断 sidecar。 #[derive(serde::Serialize, Debug)] pub(crate) struct RemoteResourceEditFailure { /// 平台 `error` 字段原文;平台没给时为空。 @@ -197,10 +197,10 @@ pub(crate) struct RemoteResourceEditFailure { impl ToolFailure for RemoteResourceEditFailure { fn to_user_msg(&self) -> String { - match &self.server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - } + self.server_message + .clone() + .or_else(|| self.phase_detail.clone()) + .unwrap_or_else(|| "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string()) } } @@ -253,14 +253,14 @@ impl ToolFailure for UnknownClientToolRejection { pub(crate) enum DirectExecutionGateRejection { /// 操作许可(付费/写入/执行)取不到。 PermitUnavailable { cause: String }, - /// 取执行许可的阻塞任务没有返回。 - PermitTaskLost, + /// 取执行许可的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + PermitTaskLost { cause: String }, /// 回合执行会话 / 计划会话取不到。 SessionUnavailable { cause: String }, - /// 取执行会话的阻塞任务没有返回。 - SessionTaskLost, - /// 工具已返回,但执行回执没有可靠落盘。 - ReceiptNotPersisted, + /// 取执行会话的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + SessionTaskLost { cause: String }, + /// 工具已返回,但执行回执没有可靠落盘;保留落盘或回执任务的具体原因。 + ReceiptNotPersisted { cause: String }, } impl ToolFailure for DirectExecutionGateRejection { @@ -269,13 +269,16 @@ impl ToolFailure for DirectExecutionGateRejection { Self::PermitUnavailable { cause } => { format!("本轮执行许可不可用,未派发工具调用:{cause}") } - Self::PermitTaskLost => "宿主执行许可任务未返回,未派发工具调用。".to_string(), - Self::SessionUnavailable { cause } => format!("本轮执行会话不可用:{cause}"), - Self::SessionTaskLost => "宿主执行会话任务未返回。".to_string(), - Self::ReceiptNotPersisted => { - "工具已返回,但宿主执行回执未可靠落盘;请查看交付状态并核对原操作,不自动重放" - .to_string() + Self::PermitTaskLost { cause } => { + format!("宿主执行许可任务未返回,未派发工具调用:{cause}") } + Self::SessionUnavailable { cause } => format!("本轮执行会话不可用:{cause}"), + Self::SessionTaskLost { cause } => { + format!("宿主执行会话任务未返回:{cause}") + } + Self::ReceiptNotPersisted { cause } => format!( + "工具已返回,但宿主执行回执未可靠落盘:{cause};请查看交付状态并核对原操作,不自动重放" + ), } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs index 8abf08c0e..522ae19e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs @@ -18,7 +18,7 @@ pub(crate) enum WebSearchError { MaxResultsOutOfRange { got: u64, max: u64 }, ClientUnavailable { cause: String }, RequestFailed { cause: String }, - HttpStatusNotSuccess { status: u16 }, + HttpStatusNotSuccess { status: u16, detail: Option }, ResponseTooLarge { got_bytes: usize, max_bytes: usize }, ResponseReadFailed { cause: String }, NoPublicResults, @@ -63,9 +63,10 @@ impl ToolFailure for WebSearchError { Self::RequestFailed { cause } => { format!("联网搜索失败:AGC 受控搜索请求未完成:{cause}") } - Self::HttpStatusNotSuccess { status } => { - format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}。") - } + Self::HttpStatusNotSuccess { status, detail } => detail.as_deref().map_or_else( + || format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}。"), + |detail| format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}:{detail}"), + ), Self::ResponseTooLarge { got_bytes, max_bytes, @@ -83,3 +84,19 @@ impl ToolFailure for WebSearchError { } } } + +#[cfg(test)] +mod tests { + use super::{ToolFailure, WebSearchError}; + + #[test] + fn http_failure_keeps_status_and_upstream_detail() { + let message = WebSearchError::HttpStatusNotSuccess { + status: 429, + detail: Some("rate limit exceeded".to_string()), + } + .to_user_msg(); + assert!(message.contains("HTTP 429")); + assert!(message.contains("rate limit exceeded")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 3a1ae2797..ba6bd729e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -75,14 +75,19 @@ fn emit_asset_generation_task_changed(root: &Path, task_id: &str) { let Some(app) = ASSET_GENERATION_TASK_APP_HANDLE.get() else { return; }; - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, ASSET_GENERATION_TASK_CHANGED_EVENT, serde_json::json!({ "projectPath": root.to_string_lossy(), "taskId": task_id, }), - ); + ) { + app_log!( + "asset_generation_task.emit_failed taskId={} detail={error}", + task_id + ); + } } const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; @@ -247,15 +252,21 @@ fn trim_ledger(tasks: &mut Vec) { /// /// 只用来做**精确落点**的交叉核对,所以按同一个口径归一化两侧的路径串(去空白、反斜杠折成 /// 正斜杠);不做模糊匹配、不按素材名猜,避免把另一次生成的产物算到这条任务上。 -fn registered_asset_ids_by_local_path(root: &Path) -> Option> { - let manifest = read_existing_manifest_for_project(root).ok()?; - Some( +fn registered_asset_ids_by_local_path( + root: &Path, +) -> Result>, String> { + let manifest = match read_existing_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) if error == "本地项目尚未初始化" => return Ok(None), + Err(error) => return Err(format!("读取生成任务核对用项目清单失败:{error}")), + }; + Ok(Some( manifest .assets .iter() .map(|asset| (normalize_local_path(&asset.local_path), asset.id.clone())) .collect(), - ) + )) } fn normalize_local_path(path: &str) -> String { @@ -340,7 +351,7 @@ pub(crate) fn list_local_project_asset_generation_tasks( ) -> Result, String> { let _guard = lock_ledger()?; let mut tasks = read_ledger(root)?; - let registered = registered_asset_ids_by_local_path(root); + let registered = registered_asset_ids_by_local_path(root)?; if repair_interrupted_tasks(&mut tasks, ®istered) { write_ledger(root, &tasks)?; drop(_guard); diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index c45f849b8..dae7e74d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -11,6 +11,60 @@ const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str = "private-external-editor-credential-persistence-failed"; +/// Extract a bounded, redacted upstream error without treating a status code as the whole reason. +fn external_asset_http_detail(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok(); + let error = parsed + .as_ref() + .and_then(|value| value.get("error")) + .or_else(|| parsed.as_ref()); + let detail = ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .and_then(|value| value.get(field)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"); + let detail = (!detail.is_empty()).then_some(detail).or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.trim().is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) + })?; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + +pub(crate) fn external_asset_http_failure( + action: &str, + status: reqwest::StatusCode, + body: &str, +) -> String { + let detail = external_asset_http_detail(body) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + match status { + reqwest::StatusCode::UNAUTHORIZED => { + format!("authentication-required: {action}失败(HTTP 401),请重新登录后重试{detail}") + } + reqwest::StatusCode::FORBIDDEN => { + format!("permission-denied: {action}被服务器拒绝(HTTP 403){detail}") + } + _ => format!("{action}失败:HTTP {}{detail}", status.as_u16()), + } +} + /// A task-scoped External Editor credential used only by the direct Codex /// runtime. It never changes the GUI account session and deliberately keeps /// the key private: callers may use it to make authenticated requests but may @@ -163,8 +217,10 @@ fn private_external_editor_api_credentials_from_file_at( "本机陶泥儿开发者 Key 配置", PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES, )?; - let parsed = serde_json::from_str::(&content) - .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; + let parsed = + serde_json::from_str::(&content).map_err(|error| { + format!("本机陶泥儿开发者 Key 配置格式无效:{error};请重新登录客户端后重试") + })?; let api_key = normalize_external_editor_api_key(&parsed.api_key)?; let api_base_url = normalize_external_editor_api_base_url( parsed @@ -404,22 +460,21 @@ async fn create_private_external_editor_api_credentials_from_platform_session( .map_err(|error| format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(match status { - reqwest::StatusCode::UNAUTHORIZED => { - "authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string() - } - reqwest::StatusCode::FORBIDDEN => { - "permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string() - } - _ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()), - }); + return Err(external_asset_http_failure( + "创建本机陶泥儿开发者 Key", + status, + &body, + )); } let payload = response .json::() .await - .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + .map_err(|error| format!("创建本机陶泥儿开发者 Key 响应格式无效:{error}"))?; let api_key = payload .get("apiKey") .or_else(|| payload.pointer("/data/apiKey")) @@ -453,9 +508,9 @@ pub(crate) async fn ensure_private_external_editor_api_credentials( if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { return Ok(credentials); } - prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|_| { + prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|error| { format!( - "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化;未创建远端凭据" + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化:{error};未创建远端凭据" ) })?; // Another local client may have completed the atomic write while this call @@ -465,9 +520,9 @@ pub(crate) async fn ensure_private_external_editor_api_credentials( } let credentials = create_private_external_editor_api_credentials_from_platform_session().await?; - if write_private_external_editor_api_credentials_at(&path, &credentials).is_err() { + if let Err(error) = write_private_external_editor_api_credentials_at(&path, &credentials) { return Err(format!( - "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存;请在账户开发者凭据页面撤销后重试" + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存:{error};请在账户开发者凭据页面撤销后重试" )); } Ok(credentials) @@ -905,11 +960,15 @@ pub(crate) async fn sync_canvas_project_assets_at( .map_err(|error| format!("读取画板项目失败:{error}"))?; let project_status = project_response.status(); if !project_status.is_success() { - let body = project_response.text().await.unwrap_or_default(); + let body = project_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(project_status.as_u16(), &body); - return Err(format!( - "读取画板项目失败:HTTP {}", - project_status.as_u16() + return Err(external_asset_http_failure( + "读取画板项目", + project_status, + &body, )); } let project_payload = project_response @@ -1149,6 +1208,9 @@ fn external_asset_host_is_private(host: &str) -> bool { || address.is_unique_local() || address.is_unicast_link_local() || address.is_multicast() + // 与 IPv4 的 198.18/15 对齐:RFC 5180 的 2001:2::/48 同样只有“经已鉴权 + // 稳定引用换签”这一条窄例外能放行,用户或上游直接给的地址仍然失败关闭。 + || external_asset_ip_is_proxy_benchmark(std::net::IpAddr::V6(address)) || address .to_ipv4_mapped() .is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string())) @@ -1162,7 +1224,14 @@ fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool { let [first, second, ..] = address.octets(); first == 198 && (18..=19).contains(&second) } - std::net::IpAddr::V6(_) => false, + // Clash / Clash.Meta 的 IPv6 fake-IP 默认段是 RFC 5180 的 2001:2::/48,与 IPv4 的 + // RFC 2544 段是同一类“透明代理占位地址”。双栈解析会同时返回两个 fake-IP(现场: + // OSS 域名解析出 198.18.0.162 + 2001:2::91),只承认 IPv4 会让“全部地址都是 + // fake-IP”永远不成立,把正常的平台资产下载误判成私网请求并拒绝。 + std::net::IpAddr::V6(address) => { + let segments = address.segments(); + segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0x0000 + } } } @@ -1176,9 +1245,9 @@ fn external_asset_resolved_addresses_are_safe( { return true; } - // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。 - // 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外; - // 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 + // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15(IPv4)和 RFC 5180 的 + // 2001:2::/48(IPv6)fake-IP。只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使 + // 用这项窄例外;用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 came_from_stable_reference && !addresses.is_empty() && addresses @@ -1238,7 +1307,7 @@ pub(crate) async fn build_external_asset_download_client( .map(|addresses| addresses.collect::>()) }) .await - .map_err(|_| "解析画板资产下载域名的任务异常".to_string())? + .map_err(|error| format!("解析画板资产下载域名的任务异常:{error}"))? .map_err(|error| format!("解析画板资产下载域名失败:{error}"))?; if addresses.is_empty() { return Err("画板资产下载域名没有可用地址".to_string()); @@ -1364,7 +1433,11 @@ where return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string()); } if !status.is_success() { - return Err(format!("下载画板资产失败:HTTP {}", status.as_u16())); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(external_asset_http_failure("下载画板资产", status, &body)); } if response .content_length() @@ -1434,9 +1507,12 @@ pub(crate) async fn resolve_external_asset_signed_url( .map_err(|error| format!("换签画板资产失败:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(format!("换签画板资产失败:HTTP {}", status.as_u16())); + return Err(external_asset_http_failure("换签画板资产", status, &body)); } let payload = response .json::() @@ -2257,6 +2333,19 @@ mod tests { use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::{Read, Write}; + #[test] + fn external_asset_http_failure_keeps_status_and_redacted_upstream_detail() { + let detail = external_asset_http_failure( + "读取画板项目", + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":"TOKEN_EXPIRED","message":"access token expired;authorization=Bearer secret"}}"#, + ); + assert!(detail.contains("HTTP 401"), "{detail}"); + assert!(detail.contains("TOKEN_EXPIRED"), "{detail}"); + assert!(detail.contains("access token expired"), "{detail}"); + assert!(!detail.contains("Bearer secret"), "{detail}"); + } + #[test] fn design_artifact_registration_reports_only_real_manifest_changes() { let temporary = tempfile::tempdir().expect("tempdir"); @@ -2982,11 +3071,28 @@ mod tests { assert!(external_asset_resolved_addresses_are_safe(&fake_ip, true)); assert!(!external_asset_resolved_addresses_are_safe(&fake_ip, false)); + // Clash 双栈 fake-IP:IPv6 侧是 RFC 5180 的 2001:2::/48,必须与 IPv4 一起放行, + // 否则双栈解析下“全部地址都是 fake-IP”永不成立,正常资产下载会被误拒。 + let fake_ipv6 = ["[2001:2::91]:443".parse().expect("parse proxy fake IPv6")]; + assert!(external_asset_resolved_addresses_are_safe(&fake_ipv6, true)); + assert!(!external_asset_resolved_addresses_are_safe( + &fake_ipv6, false + )); + let dual_stack_fake_ips = [ + "198.18.0.162:443".parse().expect("parse proxy fake IPv4"), + "[2001:2::91]:443".parse().expect("parse proxy fake IPv6"), + ]; + assert!(external_asset_resolved_addresses_are_safe( + &dual_stack_fake_ips, + true + )); + for address in [ "127.0.0.1:443", "10.0.0.1:443", "169.254.169.254:80", "[::1]:443", + "[fd00::1]:443", ] { let addresses = [address.parse().expect("parse private address")]; assert!(!external_asset_resolved_addresses_are_safe( diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 8c62e8deb..6a34d07c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -161,12 +161,14 @@ pub(crate) struct AuthNetworkFailure { pub(crate) reason: AuthNetworkReason, } -/// 登录服务 5xx 的状态码。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +/// 登录服务 5xx 的状态码与服务端原文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthServiceUnavailable { pub(crate) status: u16, + /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 + pub(crate) server_message: Option, } /// 其它未识别拒绝的状态码与服务端原文。 @@ -268,7 +270,10 @@ mod tests { "authNetworkFailure", ), ( - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }), "authServiceUnavailable", ), ( @@ -342,7 +347,10 @@ mod tests { #[test] fn payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( - AuthServiceUnavailable { status: 503 }, + AuthServiceUnavailable { + status: 503, + server_message: None, + }, )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 25ea5b770..680d0ef6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -565,6 +565,7 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA if status.is_server_error() { return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, + server_message, }); } ClientAuthError::UnexpectedRejection(UnexpectedRejection { @@ -794,7 +795,12 @@ fn auth_state_view( } fn emit_auth_state(app: &tauri::AppHandle, view: &ClientAuthStateView) { - let _ = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()); + if let Err(error) = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()) { + app_log!( + "auth_session.state.emit_failed status={} detail={error}", + view.status + ); + } } fn phone_is_valid(phone: &str) -> bool { @@ -1317,7 +1323,10 @@ mod tests { assert!(!transient.is_authority_failure()); assert_eq!( transient, - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 500, + server_message: None, + }) ); } @@ -1424,7 +1433,10 @@ mod tests { fn unreadable_body_keeps_the_known_status() { assert_eq!( classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin), - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }) ); assert_eq!( classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session), diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index 1df7ccf9a..8d18975df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -643,10 +643,10 @@ pub(super) async fn read_playable_web_game_state( let evaluated = tokio::time::timeout(remaining, page.evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT)) .await .map_err(|_| "读取固定试玩状态超时".to_string())? - .map_err(|_| "读取固定试玩状态失败".to_string())?; + .map_err(|error| format!("读取固定试玩状态失败:{error}"))?; let surface = evaluated .into_value::() - .map_err(|_| "解析固定试玩状态面读取结果失败".to_string())?; + .map_err(|error| format!("解析固定试玩状态面读取结果失败:{error}"))?; if surface.content_length > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { return Err("固定试玩状态 JSON 超过大小上限".to_string()); } @@ -675,7 +675,7 @@ pub(super) async fn click_playtest_control( let mut elements = tokio::time::timeout(remaining, page.find_elements(selector)) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不存在或查询失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 不存在或查询失败:{error}"))?; if elements.len() != 1 { return Err(format!( "固定试玩控件 {action} 必须唯一,实际数量为 {}", @@ -690,7 +690,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.scroll_into_view()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 无法滚动到可见区域"))?; + .map_err(|error| format!("固定试玩控件 {action} 无法滚动到可见区域:{error}"))?; let remaining = playtest_remaining(deadline)?; let evaluated = tokio::time::timeout( @@ -699,7 +699,7 @@ pub(super) async fn click_playtest_control( ) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态读取失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态读取失败:{error}"))?; let probe = evaluated .result .value @@ -708,7 +708,7 @@ pub(super) async fn click_playtest_control( .as_str() .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; let probe = serde_json::from_str::(probe) - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态结果无效:{error}"))?; if !probe.is_html_element { return Err(format!("固定试玩控件 {action} 必须是 HTMLElement")); } @@ -723,7 +723,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.click()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不可点击"))?; + .map_err(|error| format!("固定试玩控件 {action} 不可点击:{error}"))?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs index 2119e7de5..b3eb67080 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs @@ -135,15 +135,16 @@ async fn snapshot(page: &Page) -> Result<(PlayableWebGameState, RunnerState), St let raw: Value = page .evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT) .await - .map_err(|_| "runner-v1 读取状态失败")? + .map_err(|error| format!("runner-v1 读取状态失败:{error}"))? .into_value() - .map_err(|_| "runner-v1 状态读取结果无效")?; + .map_err(|error| format!("runner-v1 状态读取结果无效:{error}"))?; let text = raw .get("content") .and_then(Value::as_str) .ok_or("runner-v1 状态接口缺失或超限")?; let base = parse_playable_web_game_state(text, BrowserPlaytestScenario::RunnerV1)?; - let value: Value = serde_json::from_str(text).map_err(|_| "runner-v1 JSON 无效")?; + let value: Value = + serde_json::from_str(text).map_err(|error| format!("runner-v1 JSON 无效:{error}"))?; Ok((base, parse_runner_state(&value)?)) } @@ -151,7 +152,7 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 let mut elements = page .find_elements(selector) .await - .map_err(|_| "runner-v1 查询控件失败")?; + .map_err(|error| format!("runner-v1 查询控件失败:{error}"))?; if elements.len() != 1 { return Err("runner-v1 真实控件必须唯一".into()); } @@ -159,11 +160,11 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 element .scroll_into_view() .await - .map_err(|_| "runner-v1 控件无法进入视口")?; + .map_err(|error| format!("runner-v1 控件无法进入视口:{error}"))?; let probe = element .call_js_fn(PROBE_PLAYTEST_CONTROL_SCRIPT, false) .await - .map_err(|_| "runner-v1 控件检查失败")?; + .map_err(|error| format!("runner-v1 控件检查失败:{error}"))?; let probe: PlaytestControlProbe = serde_json::from_str( probe .result @@ -172,14 +173,18 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 .and_then(Value::as_str) .ok_or("runner-v1 控件信息缺失")?, ) - .map_err(|_| "runner-v1 控件信息无效")?; + .map_err(|error| format!("runner-v1 控件信息无效:{error}"))?; if !probe.is_html_element || !probe.visible || probe.disabled { return Err("runner-v1 控件必须可见且可用".into()); } let point = element.call_js_fn("function(){const r=this.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});}", false) - .await.map_err(|_| "runner-v1 控件坐标读取失败")?.result.value.ok_or("runner-v1 控件坐标缺失")?; + .await + .map_err(|error| format!("runner-v1 控件坐标读取失败:{error}"))? + .result + .value + .ok_or("runner-v1 控件坐标缺失")?; let point: Value = serde_json::from_str(point.as_str().ok_or("runner-v1 控件坐标结果无效")?) - .map_err(|_| "runner-v1 控件坐标 JSON 无效")?; + .map_err(|error| format!("runner-v1 控件坐标 JSON 无效:{error}"))?; Ok(( point["x"].as_f64().ok_or("runner-v1 x 坐标无效")?, point["y"].as_f64().ok_or("runner-v1 y 坐标无效")?, @@ -196,7 +201,7 @@ async fn touch(page: &Page, point: Option<(f64, f64)>) -> Result<(), String> { }; page.execute(RunnerTouchInput { kind, points }) .await - .map_err(|_| "runner-v1 真实触摸输入失败")?; + .map_err(|error| format!("runner-v1 真实触摸输入失败:{error}"))?; Ok(()) } @@ -242,7 +247,7 @@ async fn held_input( input.windows_virtual_key_code = Some(if jump { 32 } else { 40 }); page.execute(input) .await - .map_err(|_| "runner-v1 键盘输入失败")?; + .map_err(|error| format!("runner-v1 键盘输入失败:{error}"))?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 712c41fbd..81682e790 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -111,38 +111,48 @@ fn parse_devtools_ws_url(line: &str) -> Option { Some(ws.to_string()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum OwnedBrowserLaunchError { - SpawnFailed, - WsTimeout, - WsFailed, + SpawnFailed(String), + WsTimeout(String), + WsFailed(String), ConnectTimeout, - ConnectFailed, + ConnectFailed(String), } impl OwnedBrowserLaunchError { - fn stage(self) -> BrowserLaunchStage { + fn stage(&self) -> BrowserLaunchStage { match self { - Self::SpawnFailed => BrowserLaunchStage::Spawn, - Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, - Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + Self::SpawnFailed(_) => BrowserLaunchStage::Spawn, + Self::WsTimeout(_) | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed(_) => BrowserLaunchStage::CdpConnect, } } - fn is_recoverable(self) -> bool { + fn is_recoverable(&self) -> bool { matches!( self, - Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + Self::WsTimeout(_) | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_) ) } - fn code(self) -> &'static str { + fn code(&self) -> &'static str { match self { - Self::SpawnFailed => "browser-spawn-failed", - Self::WsTimeout => "browser-ws-timeout", - Self::WsFailed => "browser-ws-failed", + Self::SpawnFailed(_) => "browser-spawn-failed", + Self::WsTimeout(_) => "browser-ws-timeout", + Self::WsFailed(_) => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", - Self::ConnectFailed => "browser-cdp-connect-failed", + Self::ConnectFailed(_) => "browser-cdp-connect-failed", + } + } + + fn detail(&self) -> Option<&str> { + match self { + Self::SpawnFailed(detail) | Self::WsFailed(detail) | Self::ConnectFailed(detail) => { + Some(detail) + } + Self::WsTimeout(detail) => Some(detail), + Self::ConnectTimeout => None, } } } @@ -166,13 +176,14 @@ impl BrowserLaunchStage { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserLaunchFailure { code: &'static str, stage: BrowserLaunchStage, recoverable: bool, subprocess_exited: bool, cleanup_confirmed: bool, + detail: Option, } impl BrowserLaunchFailure { @@ -183,6 +194,24 @@ impl BrowserLaunchFailure { recoverable: false, subprocess_exited: true, cleanup_confirmed: true, + detail: None, + } + } + + fn setup_with_detail(code: &'static str, detail: impl Into) -> Self { + let detail = detail.into(); + Self { + code, + stage: BrowserLaunchStage::Setup, + recoverable: false, + subprocess_exited: true, + cleanup_confirmed: true, + detail: Some( + crate::sanitize_diagnostic_message(&detail, None) + .chars() + .take(1200) + .collect(), + ), } } @@ -197,11 +226,17 @@ impl BrowserLaunchFailure { recoverable: error.is_recoverable(), subprocess_exited, cleanup_confirmed, + detail: error.detail().map(|detail| { + crate::sanitize_diagnostic_message(detail, None) + .chars() + .take(1200) + .collect() + }), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserRecoveryFailure { first: BrowserLaunchFailure, final_attempt: Option, @@ -211,16 +246,26 @@ impl BrowserRecoveryFailure { fn diagnostic(self) -> String { match self.final_attempt { Some(final_attempt) => format!( - "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}{}{}", self.first.stage.as_str(), final_attempt.stage.as_str(), final_attempt.subprocess_exited, final_attempt.cleanup_confirmed, self.first.code, final_attempt.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" initial-detail={detail}")) + .unwrap_or_default(), + final_attempt + .detail + .as_deref() + .map(|detail| format!(" final-detail={detail}")) + .unwrap_or_default(), ), None => format!( - "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}{}", if self.first.recoverable { "browser-recovery-blocked" } else { @@ -230,6 +275,11 @@ impl BrowserRecoveryFailure { self.first.subprocess_exited, self.first.cleanup_confirmed, self.first.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default(), ), } } @@ -271,7 +321,7 @@ async fn devtools_ws_url_from_stderr( let stderr = child .stderr .take() - .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; + .ok_or_else(|| OwnedBrowserLaunchError::SpawnFailed("browser stderr 未建立".into()))?; let mut reader = futures::io::BufReader::new(stderr); let mut captured: Vec = Vec::new(); let mut exited = Box::pin(child.wait()).fuse(); @@ -279,18 +329,18 @@ async fn devtools_ws_url_from_stderr( loop { let mut line = Vec::new(); futures::select! { - _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), + _status = exited => return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))), result = reader.read_until(b'\n', &mut line).fuse() => { - let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; + let count = result.map_err(|error| OwnedBrowserLaunchError::WsFailed(format!("读取 browser stderr 失败:{error};{}", safe_browser_detail(&captured))))?; if count == 0 { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))); } captured.extend_from_slice(&line); if captured.len() > MAX_LAUNCH_STDERR_BYTES { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 超过大小上限;{}", safe_browser_detail(&captured)))); } let Ok(text) = std::str::from_utf8(&line) else { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 不是 UTF-8;{}", safe_browser_detail(&captured)))); }; if let Some(url) = parse_devtools_ws_url(text) { return Ok(url); @@ -302,10 +352,23 @@ async fn devtools_ws_url_from_stderr( match tokio::time::timeout(timeout, read).await { Ok(Ok(url)) => Ok((url, reader)), Ok(Err(error)) => Err(error), - Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), + Err(_) => Err(OwnedBrowserLaunchError::WsTimeout(safe_browser_detail( + &captured, + ))), } } +fn safe_browser_detail(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + if text.is_empty() { + return "未收到 browser stderr 具体正文".to_string(); + } + crate::sanitize_diagnostic_message(&text, None) + .chars() + .take(1200) + .collect() +} + fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 tokio::spawn(async move { @@ -329,20 +392,46 @@ struct BrowserProcessGuard { job: Option, } +struct BrowserReapOutcome { + exited: bool, + detail: Option, +} + impl BrowserProcessGuard { /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 /// 返回是否已确认 root 进程退出。 - async fn reap(&mut self) -> bool { + async fn reap(&mut self) -> BrowserReapOutcome { + let mut details = Vec::new(); #[cfg(windows)] if let Some(job) = &self.job { - let _ = job.terminate(); + if let Err(error) = job.terminate() { + details.push(format!("browser Job 终止失败:{error}")); + } + } + match tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await { + Ok(Ok(())) => {} + Ok(Err(error)) if error.kind() != std::io::ErrorKind::NotFound => { + details.push(format!("browser root 终止失败:{error}")); + } + Ok(Err(_)) => {} + Err(_) => details.push("browser root 终止超时".to_string()), + } + let exited = match tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await { + Ok(Ok(_)) => true, + Ok(Err(error)) => { + details.push(format!("等待 browser root 退出失败:{error}")); + false + } + Err(_) => { + details.push("等待 browser root 退出超时".to_string()); + false + } + }; + BrowserReapOutcome { + exited, + detail: (!details.is_empty()).then(|| details.join(";")), } - let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; - matches!( - tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, - Ok(Ok(_)) - ) } async fn confirm_reaped(&mut self) -> Result<(), String> { @@ -355,7 +444,7 @@ impl BrowserProcessGuard { loop { if job .is_empty() - .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + .map_err(|error| format!("browser-tree-reap-unconfirmed:{error}"))? { return Ok(()); } @@ -371,7 +460,8 @@ impl BrowserProcessGuard { // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 match self.child.inner.try_wait() { Ok(Some(_)) => Ok(()), - _ => Err("browser-tree-reap-unconfirmed".into()), + Ok(None) => Err("browser-tree-reap-unconfirmed:root 进程仍在运行".into()), + Err(error) => Err(format!("browser-tree-reap-unconfirmed:{error}")), } } } @@ -411,26 +501,28 @@ impl OwnedBrowser { { return Ok(()); } - let subprocess_exited = self.process.reap().await; - if !subprocess_exited { + let reap = self.process.reap().await; + if !reap.exited { // 进程退出未确认:保留目录与 owner.json,留待下次启动或 // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err( - "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" - .into(), - ); + return Err(format!( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false{}", + reap.detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default() + )); } - if self.process.confirm_reaped().await.is_err() { + if let Err(error) = self.process.confirm_reaped().await { if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err( - "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false" - .into(), - ); + return Err(format!( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false detail={error}" + )); } Ok(()) } @@ -450,8 +542,8 @@ async fn cleanup_failed_launch( error: OwnedBrowserLaunchError, tree_control_confirmed: bool, ) -> BrowserLaunchFailure { - let subprocess_exited = process.reap().await; - let tree_reaped = if tree_control_confirmed && subprocess_exited { + let reap = process.reap().await; + let tree_reaped = if tree_control_confirmed && reap.exited { #[cfg(windows)] { process.job.is_none() || process.confirm_reaped().await.is_ok() @@ -471,7 +563,19 @@ async fn cleanup_failed_launch( let _ = temp.keep(); false }; - BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) + let mut failure = + BrowserLaunchFailure::from_launch_error(error, reap.exited, cleanup_confirmed); + if let Some(detail) = reap.detail { + let detail = crate::sanitize_diagnostic_message(&detail, None) + .chars() + .take(1200) + .collect::(); + failure.detail = Some(match failure.detail.take() { + Some(existing) => format!("{existing};清理:{detail}"), + None => format!("清理:{detail}"), + }); + } + failure } /// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE @@ -484,9 +588,9 @@ async fn launch_owned_browser( ) -> Result { let child = match config.launch() { Ok(child) => child, - Err(_) => { + Err(error) => { return Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(error.to_string()), true, true, )); @@ -511,7 +615,9 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser root identity unavailable".into(), + ), false, ) .await); @@ -528,13 +634,15 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser process tree not covered by Job".into(), + ), tree_reaped, ) .await); } } - Err(_) => { + Err(error) => { let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { (Some(root_pid), Some(root_identity)) => { super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() @@ -545,7 +653,7 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(format!("创建 browser Job 失败:{error}")), tree_reaped, ) .await); @@ -575,12 +683,12 @@ async fn launch_owned_browser( .await; let (browser, mut handler) = match connected { Ok(Ok(pair)) => pair, - Ok(Err(_)) => { + Ok(Err(error)) => { drain_task.abort(); return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::ConnectFailed, + OwnedBrowserLaunchError::ConnectFailed(error.to_string()), true, ) .await); @@ -616,10 +724,12 @@ async fn launch_browser_attempt( executable: &std::path::Path, proxy_bypass_list: &str, ) -> Result { - let temporary = create_browser_process_temp_dir() - .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; - let config = browser_config(executable, &temporary, proxy_bypass_list) - .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; + let temporary = create_browser_process_temp_dir().map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-temp-unavailable", error) + })?; + let config = browser_config(executable, &temporary, proxy_bypass_list).map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-config-invalid", error) + })?; launch_owned_browser(config, executable, temporary).await } @@ -635,15 +745,16 @@ async fn launch_browser_with_recovery( /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { - let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let discovered = + discover_chrome_or_edge().map_err(|error| format!("browser-not-found:{error}"))?; let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; - if owned.shutdown().await.is_err() { - return Err("browser-cleanup-failed".into()); + if let Err(error) = owned.shutdown().await { + return Err(format!("browser-cleanup-failed:{error}")); } let version = version - .map_err(|_| "browser-cdp-timeout")? - .map_err(|_| "browser-cdp-failed")?; + .map_err(|_| "browser-cdp-timeout:等待 DevTools 版本响应超过 5000 ms".to_string())? + .map_err(|error| format!("browser-cdp-failed:{error}"))?; if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) { return Err("browser-version-invalid".into()); } @@ -828,7 +939,7 @@ mod health_tests { if attempt == 0 { drop(temporary); Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -876,7 +987,7 @@ mod health_tests { async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { let failure = launch_with_one_recovery(|| async { Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -889,6 +1000,7 @@ mod health_tests { assert!(diagnostic.contains("subprocess-exited=true")); assert!(diagnostic.contains("cleanup-confirmed=true")); assert!(diagnostic.contains("recovery-retried=true")); + assert!(diagnostic.contains("fixture ws failure")); assert!(!diagnostic.contains("/tmp")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 14a4e6096..a4a09d8d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -103,6 +103,16 @@ pub(crate) struct ProjectCommandSpec { pub(crate) cwd: PathBuf, pub(crate) timeout_seconds: u64, pub(crate) verification_eligible: bool, + /// Linux 上 npm 的真实启动锚点:`(node, npm-cli.js)`。保留 `executable` 为 npm shim 以 + /// 维持既有 program / verification 口径,实际进程改由 node 直接运行 npm-cli.js,避免 + /// `#!/usr/bin/env node` 在沙箱里落到系统 node。 + pub(crate) node_launcher: Option<(PathBuf, PathBuf)>, +} + +struct ResolvedProjectCommandExecutable { + executable: PathBuf, + safe_path: OsString, + node_launcher: Option<(PathBuf, PathBuf)>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -155,9 +165,11 @@ impl ProjectCommandTree { let pid = child.id().ok_or("受控命令缺少进程身份")?; let start_identity = crate::process_identity::external_agent_runner_process_start_identity(pid) - .ok() - .flatten() + .map_err(|error| format!("受控命令 leader 启动身份读取失败:{error}"))? .filter(|identity| !identity.is_empty()); + if start_identity.is_none() { + return Err("受控命令 leader 启动身份为空,拒绝接管进程组".into()); + } Ok(Self::Group { pid, start_identity, @@ -186,7 +198,7 @@ impl ProjectCommandTree { } let observed = crate::process_identity::external_agent_runner_process_start_identity(*pid) - .map_err(|_| "受控进程组 leader 身份未确认,拒绝发送终止信号")?; + .map_err(|error| format!("受控进程组 leader 身份未确认:{error}"))?; if !owned_project_command_group_identity_matches( start_identity.as_deref(), observed.as_deref(), @@ -207,15 +219,23 @@ impl ProjectCommandTree { { let Self::Job(job) = self; let requested = job.terminate(); - if requested.is_err() { - let _ = child.start_kill(); - } + let fallback_error = requested + .as_ref() + .err() + .and_then(|_| child.start_kill().err()); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let waited = tokio::time::timeout_at(deadline, child.wait()).await; - requested?; + if let Err(error) = requested { + return Err(match fallback_error { + Some(fallback_error) => { + format!("{error};主进程兜底终止失败:{fallback_error}") + } + None => error, + }); + } waited - .map_err(|_| "等待受控命令主进程退出超时")? - .map_err(|_| "受控命令主进程退出未确认")?; + .map_err(|_| "等待受控命令主进程退出超时:超过 5 秒".to_string())? + .map_err(|error| format!("受控命令主进程退出未确认:{error}"))?; while !job.is_empty()? { if tokio::time::Instant::now() >= deadline { return Err("受控命令 Windows Job 子树退出未确认".into()); @@ -228,11 +248,14 @@ impl ProjectCommandTree { { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let requested = self.request_owned_group_termination(); - let _ = child.start_kill(); + let child_kill_error = child.start_kill().err(); let waited = tokio::time::timeout_at(deadline, child.wait()).await; + let child_kill_detail = child_kill_error + .map(|error| format!(";主进程兜底终止失败:{error}")) + .unwrap_or_default(); waited - .map_err(|_| "等待受控命令主进程退出超时")? - .map_err(|_| "受控命令主进程退出未确认")?; + .map_err(|_| "等待受控命令主进程退出超时:超过 5 秒".to_string())? + .map_err(|error| format!("受控命令主进程退出未确认:{error}{child_kill_detail}"))?; #[cfg(target_os = "linux")] { let Self::Group { pid, .. } = self; @@ -525,6 +548,7 @@ where cwd: root.to_path_buf(), timeout_seconds: 15, verification_eligible: false, + node_launcher: None, }; let launch = prepare_project_command_launch_spec(root, &spec)?; let mut staged = stage_project_command_launch_spec(&spec, launch)?; @@ -632,17 +656,18 @@ pub(crate) fn resolve_project_bootstrap_spec_at( )); } let program = "npm".to_string(); - let (executable, safe_path) = resolve_project_command_executable(root, &program) + let resolved = resolve_project_command_executable(root, &program) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; Ok(ProjectCommandSpec { program, - executable, - safe_path, + executable: resolved.executable, + safe_path: resolved.safe_path, arguments: vec!["install".to_string()], cwd_relative, cwd, timeout_seconds, verification_eligible: false, + node_launcher: resolved.node_launcher, }) } @@ -676,17 +701,18 @@ fn resolve_project_command_spec_inner( if program == "node" { validate_project_command_node_test_files(&cwd, arguments)?; } - let (executable, safe_path) = resolve_project_command_executable(root, &program)?; + let resolved = resolve_project_command_executable(root, &program)?; let verification_eligible = project_command_verification_eligible(&program, arguments); Ok(ProjectCommandSpec { program, - executable, - safe_path, + executable: resolved.executable, + safe_path: resolved.safe_path, arguments: arguments.to_vec(), cwd_relative, cwd, timeout_seconds, verification_eligible, + node_launcher: resolved.node_launcher, }) } @@ -874,36 +900,81 @@ fn project_command_npm_verification_script_suffix_allowed(suffix: &str) -> bool fn resolve_project_command_executable( root: &Path, program: &str, -) -> Result<(PathBuf, OsString), String> { +) -> Result { if matches!(program, "node" | "npm") { let runtime = crate::environment_check::resolve_node_runtime(root)?; - let executable = if program == "node" { - runtime.node.clone() - } else { - runtime - .node - .parent() - .ok_or("node-runtime-invalid")? - .join(if cfg!(windows) { "npm.cmd" } else { "npm" }) - }; + if program == "node" { + return Ok(ResolvedProjectCommandExecutable { + executable: runtime.node, + safe_path: runtime.safe_path, + node_launcher: None, + }); + } + let executable = runtime + .node + .parent() + .ok_or("node-runtime-invalid")? + .join(if cfg!(windows) { "npm.cmd" } else { "npm" }); if !executable.is_file() { return Err("npm-runtime-missing-launcher".into()); } - return Ok((executable, runtime.safe_path)); + #[cfg(target_os = "linux")] + let node_launcher = Some((runtime.node.clone(), runtime.npm_cli.clone())); + #[cfg(not(target_os = "linux"))] + let node_launcher = None; + return Ok(ResolvedProjectCommandExecutable { + executable, + safe_path: runtime.safe_path, + node_launcher, + }); } #[cfg(target_os = "linux")] - let path = std::env::join_paths([ - PathBuf::from("/usr/local/sbin"), - PathBuf::from("/usr/local/bin"), - PathBuf::from("/usr/sbin"), - PathBuf::from("/usr/bin"), - PathBuf::from("/sbin"), - PathBuf::from("/bin"), - ]) - .map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?; + let path = { + // 先信任客户端解析出的 Node 工具链 bin(fnm / nvm / 系统),再落到系统目录; + // npx / corepack / pnpm / yarn 等随之在沙箱里与 node 同版本。 + let mut directories = Vec::new(); + if let Ok(runtime) = crate::environment_check::resolve_node_runtime(root) { + if let Some(bin) = runtime.node.parent() { + directories.push(bin.to_path_buf()); + } + } + directories.extend([ + PathBuf::from("/usr/local/sbin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/sbin"), + PathBuf::from("/bin"), + ]); + std::env::join_paths(directories) + .map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))? + }; #[cfg(not(target_os = "linux"))] let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?; - resolve_project_command_executable_from_path(root, program, &path) + let (executable, safe_path) = + resolve_project_command_executable_from_path(root, program, &path)?; + Ok(ResolvedProjectCommandExecutable { + executable, + safe_path, + node_launcher: None, + }) +} + +/// Linux 上 npm 以 `node ` 启动;其余情况保持 `executable + args`。 +pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec) { + #[cfg(target_os = "linux")] + { + if let Some((node, npm_cli)) = spec.node_launcher.as_ref() { + let mut arguments = Vec::with_capacity(spec.arguments.len() + 1); + arguments.push(npm_cli.to_string_lossy().into_owned()); + arguments.extend(spec.arguments.iter().cloned()); + return (node.clone(), arguments); + } + } + ( + spec.executable.clone(), + project_command_actual_arguments(spec), + ) } fn resolve_project_command_executable_from_path( @@ -1591,13 +1662,16 @@ pub(crate) fn prepare_project_command_launch_spec( } } + #[cfg(target_os = "linux")] + let (target_executable, target_arguments) = project_command_actual_target(spec); + #[cfg(not(target_os = "linux"))] let arguments = project_command_actual_arguments(spec); #[cfg(target_os = "linux")] { let sandbox = prepare_command_sandbox_launch( root, - &spec.executable, - &arguments, + &target_executable, + &target_arguments, &spec.cwd, &environment, ) @@ -1700,7 +1774,8 @@ pub(crate) fn stage_project_command_launch_spec( ) -> Result { #[cfg(target_os = "linux")] { - let target_arguments = project_command_actual_arguments(spec) + let (target_executable, target_arguments) = project_command_actual_target(spec); + let target_arguments = target_arguments .into_iter() .map(OsString::from) .collect::>(); @@ -1712,7 +1787,7 @@ pub(crate) fn stage_project_command_launch_spec( environment: launch.environment, metadata: command_sandbox_platform_metadata(), }, - &spec.executable, + &target_executable, &target_arguments, ) .map_err(|error| { @@ -2580,6 +2655,34 @@ where mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn npm_command_targets_node_plus_npm_cli_on_linux() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("game")).unwrap(); + let spec = resolve_project_command_spec_at( + root.path(), + "npm", + &["run".to_string(), "build".to_string()], + ".", + 30, + ) + .unwrap(); + let (executable, arguments) = project_command_actual_target(&spec); + assert_eq!( + executable.file_name().and_then(|name| name.to_str()), + Some("node"), + "{executable:?}" + ); + assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); + assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]); + + let bootstrap = resolve_project_bootstrap_spec_at(root.path(), 30).unwrap(); + let (_, arguments) = project_command_actual_target(&bootstrap); + assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); + assert_eq!(arguments[1], "install"); + } + #[cfg(target_os = "linux")] #[tokio::test] async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() { @@ -2779,6 +2882,7 @@ mod tests { cwd: root.to_path_buf(), timeout_seconds: 20, verification_eligible: false, + node_launcher: None, }; let staged = StagedProjectCommandLaunchSpec { launch: ProjectCommandLaunchSpec { diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index c944151cb..109304b59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -295,16 +295,7 @@ mod linux { environment: &[(OsString, OsString)], ) -> Result { let mut metadata = CommandSandboxMetadata::enforced_linux(); - let network_enabled = executable - .file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| { - name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") - }) - && arguments - .first() - .is_some_and(|argument| argument == "install"); - if network_enabled { + if command_sandbox_requests_npm_install(executable, arguments) { metadata.network = "enabled"; } let bwrap = find_trusted_bwrap().map_err(|error| { @@ -365,6 +356,14 @@ mod linux { ) }, )?; + let node_read_only = collect_node_toolchain_mounts(&root, &executable, &target_environment) + .map_err(|error| { + CommandSandboxError::new( + format!("command sandbox node toolchain mount 无效:{error}"), + metadata.clone(), + ) + })?; + let external_read_only = merge_read_only_mounts(external_read_only, node_read_only); let fixed_system_read_only = collect_fixed_system_mounts(); let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan { @@ -465,6 +464,11 @@ mod linux { let mut values = BTreeMap::::new(); for (name, value) in environment { validate_environment_name(name)?; + if name == OsStr::new("FNM_MULTISHELL_PATH") { + // fnm 的 multishell 路径位于 /run 下,而 sandbox 用 tmpfs 覆盖 /run; + // 保留会让子进程按一个不存在的目录查找 node。 + continue; + } let replacement = match name.to_str().unwrap_or_default() { "HOME" | "USERPROFILE" => Some(COMMAND_SANDBOX_PRIVATE_HOME), "TMPDIR" | "TEMP" | "TMP" => Some("/tmp"), @@ -542,6 +546,108 @@ mod linux { .collect()) } + /// `npm install` 是唯一允许联网的受控入口。Linux 以 `node install` 启动时 + /// executable 是 node,网络判定不能只看 executable 文件名。 + fn command_sandbox_requests_npm_install(executable: &Path, arguments: &[String]) -> bool { + let npm_name = executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }); + if npm_name { + return arguments + .first() + .is_some_and(|argument| argument == "install"); + } + arguments + .first() + .map(Path::new) + .and_then(Path::file_name) + .and_then(OsStr::to_str) + .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")) + && arguments + .get(1) + .is_some_and(|argument| argument == "install") + } + + /// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm` + /// shim 会让 `#!/usr/bin/env node` 落到系统 node,并让 npm 的 `../lib/node_modules/npm` + /// 相对 require 失效。前缀本身仍走与主机发现共用的窄叶校验。 + fn collect_node_toolchain_mounts( + root: &Path, + executable: &Path, + environment: &[(OsString, OsString)], + ) -> Result, String> { + let mut mounts = BTreeMap::::new(); + for candidate in node_installation_candidates(executable, environment) { + if candidate.starts_with(root) { + continue; + } + let Ok(prefix) = + crate::environment_check::validate_node_installation_prefix(&candidate) + else { + continue; + }; + add_external_mount(root, &prefix, &prefix, &mut mounts)?; + } + Ok(mounts + .into_iter() + .map(|(destination, source)| ReadOnlyMount { + source, + destination, + }) + .collect()) + } + + fn node_installation_candidates( + executable: &Path, + environment: &[(OsString, OsString)], + ) -> Vec { + let mut candidates = Vec::new(); + if let Ok(canonical) = fs::canonicalize(executable) { + push_node_prefix_candidates(&canonical, &mut candidates); + } + if let Some(path) = environment + .iter() + .find(|(name, _)| name == OsStr::new("PATH")) + .map(|(_, value)| value) + { + for directory in std::env::split_paths(path) { + if !directory.is_absolute() { + continue; + } + let Ok(directory) = fs::canonicalize(&directory) else { + continue; + }; + if directory.is_dir() { + push_node_prefix_candidates(&directory.join("node"), &mut candidates); + } + } + } + candidates + } + + fn push_node_prefix_candidates(path: &Path, candidates: &mut Vec) { + let Some(parent) = path.parent() else { + return; + }; + for ancestor in parent.ancestors().take(5) { + candidates.push(ancestor.to_path_buf()); + } + } + + fn merge_read_only_mounts( + first: Vec, + second: Vec, + ) -> Vec { + let mut mounts = first; + mounts.extend(second); + mounts.sort_by(|left, right| left.destination.cmp(&right.destination)); + mounts.dedup_by(|left, right| left.destination == right.destination); + mounts + } + fn validate_external_toolchain_root(name: &str, root: &Path) -> Result<(), String> { if root.parent() == Some(Path::new("/home")) { return Err(format!( @@ -609,16 +715,7 @@ mod linux { let mut args = Vec::::new(); push_namespace_arguments( &mut args, - plan.executable - .file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| { - name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") - }) - && plan - .arguments - .first() - .is_some_and(|argument| argument == "install"), + command_sandbox_requests_npm_install(&plan.executable, &plan.arguments), ); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { @@ -1150,6 +1247,113 @@ mod linux { assert_eq!(mounts[0].source, rustup_home); } + fn install_node_prefix_fixture(prefix: &Path) { + use std::os::unix::fs::PermissionsExt; + + let bin = prefix.join("bin"); + let npm = prefix.join("lib/node_modules/npm/bin"); + std::fs::create_dir_all(&bin).expect("create node bin"); + std::fs::create_dir_all(&npm).expect("create npm bin"); + let node = bin.join("node"); + std::fs::write(&node, b"node").expect("write node"); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)) + .expect("chmod node"); + std::fs::write(npm.join("npm-cli.js"), b"npm").expect("write npm-cli"); + } + + #[test] + fn node_installation_prefix_is_mounted_read_only_from_executable_and_path() { + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-node-prefix"); + let installation = tree + .0 + .join("fnm") + .join("node-versions") + .join("v22.23.3") + .join("installation"); + std::fs::create_dir_all(&root).expect("create workspace"); + install_node_prefix_fixture(&installation); + let node = installation.join("bin/node"); + + let mounts = collect_node_toolchain_mounts( + &root, + &node, + &[( + OsString::from("PATH"), + installation.join("bin").into_os_string(), + )], + ) + .expect("node installation prefix should mount"); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].source, installation.canonicalize().unwrap()); + assert_eq!(mounts[0].destination, installation.canonicalize().unwrap()); + } + + #[test] + fn node_toolchain_mount_skips_wide_and_incomplete_candidates() { + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-node-skip"); + std::fs::create_dir_all(&root).expect("create workspace"); + let incomplete = tree.0.join("incomplete-node"); + std::fs::create_dir_all(incomplete.join("bin")).expect("create bin"); + std::fs::write(incomplete.join("bin/node"), b"node").expect("write node"); + + let mounts = collect_node_toolchain_mounts( + &root, + &incomplete.join("bin/node"), + &[( + OsString::from("PATH"), + OsString::from("/usr/bin:/root:/tmp:/"), + )], + ) + .expect("wide or incomplete candidates must be skipped, not fatal"); + assert!(mounts.is_empty(), "unexpected mounts: {mounts:?}"); + } + + #[test] + fn normalize_target_environment_drops_fnm_multishell_path() { + let values = normalize_target_environment(&[ + ( + OsString::from("FNM_MULTISHELL_PATH"), + OsString::from("/run/user/0/fnm_multishells/1_2"), + ), + (OsString::from("PATH"), OsString::from("/usr/bin")), + ]) + .expect("normalize environment"); + assert!(!values + .iter() + .any(|(name, _)| name == OsStr::new("FNM_MULTISHELL_PATH"))); + assert!( + values + .iter() + .any(|(name, value)| name == OsStr::new("PATH") + && value == OsStr::new("/usr/bin")) + ); + } + + #[test] + fn npm_install_network_detection_supports_node_launcher() { + assert!(command_sandbox_requests_npm_install( + Path::new("/opt/node/bin/node"), + &[ + "/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(), + "install".to_string(), + ], + )); + assert!(!command_sandbox_requests_npm_install( + Path::new("/opt/node/bin/node"), + &[ + "/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(), + "run".to_string(), + "build".to_string(), + ], + )); + assert!(command_sandbox_requests_npm_install( + Path::new("/usr/bin/npm"), + &["install".to_string()], + )); + } + struct TempTree(PathBuf); impl Drop for TempTree { @@ -1283,6 +1487,130 @@ print("SANDBOX_OK") } } + #[test] + fn command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-node-runtime"); + std::fs::create_dir_all(root.join(".agent")).expect("create runtime control"); + let Ok(runtime) = crate::environment_check::resolve_node_runtime(&root) else { + return; + }; + let environment = vec![ + (OsString::from("PATH"), runtime.safe_path.clone()), + (OsString::from("HOME"), OsString::from("/host/home")), + ]; + let run = |executable: &Path, arguments: &[String]| { + let launch = prepare_command_sandbox_launch( + &root, + executable, + arguments, + &root, + &environment, + ) + .expect("prepare node sandbox"); + let output = Command::new(&launch.executable) + .args(&launch.arguments) + .current_dir(&launch.cwd) + .env_clear() + .envs(launch.environment.iter().cloned()) + .output() + .expect("run node sandbox"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + + let version = run( + &runtime.node, + &[ + "-e".to_string(), + "process.stdout.write(process.version)".to_string(), + ], + ); + assert!( + version.starts_with('v'), + "unexpected node version: {version}" + ); + + let npm_cli = runtime.npm_cli.to_string_lossy().into_owned(); + let npm_version = run(&runtime.node, &[npm_cli, "--version".to_string()]); + assert!(!npm_version.is_empty(), "npm --version returned nothing"); + } + + #[test] + fn command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let Some(nvm_dir) = std::env::var_os("NVM_DIR").map(PathBuf::from) else { + return; + }; + let versions_dir = nvm_dir.join("versions").join("node"); + let Ok(entries) = std::fs::read_dir(&versions_dir) else { + return; + }; + let mut versions = entries + .flatten() + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .collect::>(); + versions.sort(); + let Some(version) = versions.pop() else { + return; + }; + let prefix = crate::environment_check::validate_node_installation_prefix( + &versions_dir.join(&version), + ) + .expect("nvm 安装前缀应通过窄叶校验"); + let node = prefix.join("bin").join("node"); + let npm_cli = prefix.join("lib/node_modules/npm/bin/npm-cli.js"); + + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-nvm-runtime"); + std::fs::create_dir_all(root.join(".agent")).expect("create runtime control"); + let environment = vec![ + (OsString::from("PATH"), prefix.join("bin").into_os_string()), + (OsString::from("HOME"), OsString::from("/host/home")), + ]; + let run = |arguments: &[String]| { + let launch = + prepare_command_sandbox_launch(&root, &node, arguments, &root, &environment) + .expect("prepare nvm sandbox"); + let output = Command::new(&launch.executable) + .args(&launch.arguments) + .current_dir(&launch.cwd) + .env_clear() + .envs(launch.environment.iter().cloned()) + .output() + .expect("run nvm sandbox"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + + let version = run(&[ + "-e".to_string(), + "process.stdout.write(process.version)".to_string(), + ]); + assert!( + version.starts_with('v'), + "unexpected node version: {version}" + ); + let npm_version = run(&[ + npm_cli.to_string_lossy().into_owned(), + "--version".to_string(), + ]); + assert!(!npm_version.is_empty(), "npm --version returned nothing"); + } + #[test] fn command_sandbox_staged_gate_real_linux_opt_in_blocks_until_commit() { if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 120a3b2f2..f4b190f62 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -295,7 +295,8 @@ fn validate_local_asset_import_requirements( // leave the early validation path unable to reach the one-shot UAC // repair entry. crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地文件失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通文件".to_string()); } @@ -1322,14 +1323,15 @@ fn read_registered_ui_editor_font( } let target = resolve_local_project_path(root, &asset.local_path)?; prepare_game_creator_private_path_for_read(&target, false, "项目字体")?; - let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; + let metadata = + fs::symlink_metadata(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("项目字体必须是普通文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("项目字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?; + let bytes = fs::read(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; let source_file_name = target .file_name() .and_then(|value| value.to_str()) @@ -1380,14 +1382,15 @@ pub(crate) fn import_ui_editor_local_fonts( for source in source_paths { let path = Path::new(source.trim()); crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通字体文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?; + let bytes = fs::read(path).map_err(|error| format!("读取本地字体失败:{error}"))?; let source_file_name = path .file_name() .and_then(|value| value.to_str()) @@ -3432,12 +3435,18 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( reject_agent_local_resource_source_path(&normalized)?; let source = resolve_local_project_path(root, &normalized)?; prepare_game_creator_private_path_for_read(&source, false, "本地资源")?; - let metadata = - fs::symlink_metadata(&source).map_err(|_| format!("本地资源不存在:{normalized}"))?; + let metadata = fs::symlink_metadata(&source).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + format!("本地资源不存在:{normalized}") + } else { + format!("读取本地资源元数据失败:{normalized}:{error}") + } + })?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!("本地素材只能是普通文件:{normalized}")); } - let bytes = fs::read(&source).map_err(|_| format!("读取本地资源失败:{normalized}"))?; + let bytes = fs::read(&source) + .map_err(|error| format!("读取本地资源失败:{normalized}:{error}"))?; let file_type = agent_local_project_file_type(&normalized, &bytes)?; if metadata.len() > file_type.max_file_size { return Err(format!( @@ -3491,7 +3500,7 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( if target.exists() { prepare_game_creator_private_path_for_read(&target, false, "目标资源")?; let existing_bytes = - fs::read(&target).map_err(|_| "读取目标资源失败".to_string())?; + fs::read(&target).map_err(|error| format!("读取目标资源失败:{error}"))?; if existing_bytes != bytes { return Err(format!("本地资源目标已存在且内容不同:{local_path}")); } @@ -3975,7 +3984,7 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result max_bytes { return Err("平台素材超过当前图片下载限制".to_string()); } @@ -3993,9 +4002,15 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result Result<(), String> { + let path = reveal_local_project_asset_file_at(project_path.trim(), relative_path.trim())?; + app.opener() + .reveal_item_in_dir(path) + .map_err(|error| format!("打开文件管理器失败:{error}")) +} + #[tauri::command] pub(crate) async fn read_local_project_image_preview( preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, @@ -1140,128 +1156,6 @@ pub(crate) fn create_local_project_checkpoint( ) } -/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 -/// -/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 -/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 -#[tauri::command] -pub(crate) async fn export_local_project_package( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let _lock = acquire_project_write_lock(root, "project.export_package")?; - advance_agent_runtime_project_revision_locked(root)?; - export_local_project_package_for_publish_at(root).await -} - -/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 -/// -/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 -/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 -#[tauri::command] -pub(crate) fn prepare_local_project_game_package( - app: tauri::AppHandle, - project_path: String, - package_relative_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; - let staging_dir = game_package_upload_staging_dir(&app)?; - let mut staged = crate::game_package_upload::stage_game_package_bytes( - &staging_dir, - &payload.package_sha256, - &payload.package_bytes, - )?; - staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); - Ok(staged) -} - -/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 -#[tauri::command] -pub(crate) async fn upload_local_project_game_package( - app: tauri::AppHandle, - staging_path: String, - version_id: String, - api_base_url: String, - access_token: String, - idempotency_key: String, -) -> Result { - let staging_dir = game_package_upload_staging_dir(&app)?; - let resolved_path = - crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; - let client = reqwest::Client::builder() - .build() - .map_err(|error| format!("创建上传客户端失败:{error}"))?; - let version_id = version_id.trim().to_string(); - if version_id.is_empty() { - return Err("缺少发行版本标识".to_string()); - } - let emit_handle = app.clone(); - let progress_version_id = version_id.clone(); - let progress_publish_id = idempotency_key.trim().to_string(); - let verify_handle = app.clone(); - let verify_version_id = version_id.clone(); - let verify_publish_id = progress_publish_id.clone(); - crate::game_package_upload::upload_staged_game_package( - &client, - crate::game_package_upload::GamePackageUploadRequest { - staging_path: &resolved_path, - version_id: &version_id, - api_base_url: api_base_url.trim(), - access_token: access_token.trim(), - idempotency_key: idempotency_key.trim(), - session_identity: None, - }, - move |received_bytes, total_bytes| { - let _ = emit_handle.emit( - crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, - crate::game_package_upload::progress_event_payload( - &progress_version_id, - &progress_publish_id, - "upload", - "正在上传发行包…", - received_bytes, - total_bytes, - ), - ); - }, - move || { - let _ = verify_handle.emit( - crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, - crate::game_package_upload::progress_event_payload( - &verify_version_id, - &verify_publish_id, - "verify", - "正在校验发行包…", - 0, - 0, - ), - ); - }, - ) - .await -} - -fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { - app.path() - .app_data_dir() - .map(|app_data_root| { - crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) - }) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - -#[tauri::command] -pub(crate) fn list_local_project_export_packages( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_list")?; - list_local_project_export_packages_at(root) -} - #[tauri::command] pub(crate) fn diff_local_project_checkpoint( project_path: String, @@ -1319,7 +1213,7 @@ pub(crate) fn resolve_game_project_creation_root( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) - .or_else(|| crate::workspace_preferences::stored_project_creation_directory(app)); + .or(crate::workspace_preferences::stored_project_creation_directory(app)?); match stored.as_deref() { Some(requested) => validate_requested_game_project_creation_root(requested), None => automatic_local_game_projects_root(app), diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d9e1ad7fa..629eb9a4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4479,20 +4479,16 @@ pub(crate) async fn fetch_custom_llm_models( .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(15)) .build() - .map_err(|_| "初始化模型列表请求失败".to_string())?; + .map_err(|error| format!("初始化模型列表请求失败:{error}"))?; let mut response = client .get(url) .bearer_auth(llm.api_key.trim()) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型端点,请检查 API 地址和网络".to_string() - } - })?; + .map_err(|error| format!("无法连接模型端点:{error};请检查 API 地址和网络"))?; if !response.status().is_success() { + // 自定义模型端点由用户提供、响应不可信:只回报状态码,绝不回显上游正文, + // 否则会把密钥或私有数据带进错误文案。 return Err(format!( "模型列表读取失败(HTTP {})", response.status().as_u16() @@ -4502,7 +4498,7 @@ pub(crate) async fn fetch_custom_llm_models( while let Some(chunk) = response .chunk() .await - .map_err(|_| "读取模型列表响应失败或超时".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len() + chunk.len() > 1024 * 1024 { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -4518,7 +4514,7 @@ pub(crate) async fn fetch_custom_llm_models( data: Vec, } let models: Models = serde_json::from_slice(&body) - .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + .map_err(|error| format!("模型端点需返回 OpenAI 兼容的 data[].id 列表:{error}"))?; let ids = models .data .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index b19959983..5190cd8a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -7,6 +7,14 @@ use asset_generation_tasks::*; use client_extensions::*; use environment_check::preflight_web_game_creation; use error_report::*; +use export::draft::taonier::commands::{ + read_taonier_export, read_taonier_export_hash, run_taonier_export_build, + save_taonier_export_form, +}; +use export::draft::xhs_minitool::commands::{ + read_xhs_minitool_export, read_xhs_minitool_export_hash, run_xhs_minitool_export_build, + save_xhs_minitool_export_form, +}; use game_distribution_publish::*; use plugin_host::{ call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, @@ -609,9 +617,6 @@ pub(super) fn run() { login_client_with_password, login_client_with_phone_code, logout_client_session, - suggest_game_distribution_publish_metadata, - read_game_cover_generation_price, - generate_game_distribution_cover, read_game_distribution_publication, publish_local_project_game, read_game_creator_app_config, @@ -664,6 +669,7 @@ pub(super) fn run() { read_local_project_file, read_local_project_image_preview, save_local_project_asset_file, + reveal_local_project_asset_file, read_local_project_text_preview, read_local_project_structured_preview, read_local_project_media_preview, @@ -688,10 +694,14 @@ pub(super) fn run() { append_direct_project_conversation_message, build_local_project_index, create_local_project_checkpoint, - export_local_project_package, - prepare_local_project_game_package, - upload_local_project_game_package, - list_local_project_export_packages, + read_xhs_minitool_export, + read_xhs_minitool_export_hash, + save_xhs_minitool_export_form, + run_xhs_minitool_export_build, + read_taonier_export, + read_taonier_export_hash, + save_taonier_export_form, + run_taonier_export_build, diff_local_project_checkpoint, restore_local_project_checkpoint, read_project_permission_policy, diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs index f9b87d922..46a151a0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs @@ -210,15 +210,15 @@ fn update_godot_authorized_project_at( if bytes.len() > 64 * 1024 { return Err("Godot 待清理项目归属超过限制".into()); } - let mut temporary = - tempfile::NamedTempFile::new_in(config).map_err(|_| "无法创建 Godot 项目归属记录")?; + let mut temporary = tempfile::NamedTempFile::new_in(config) + .map_err(|error| format!("无法创建 Godot 项目归属记录:{error}"))?; temporary .write_all(&bytes) .and_then(|_| temporary.as_file().sync_all()) - .map_err(|_| "无法持久保存 Godot 项目归属")?; + .map_err(|error| format!("无法持久保存 Godot 项目归属:{error}"))?; temporary .persist(config.join(GODOT_PROJECTS_FILE)) - .map_err(|_| "无法提交 Godot 项目归属记录")?; + .map_err(|error| format!("无法提交 Godot 项目归属记录:{}", error.error))?; Ok(()) } @@ -254,7 +254,7 @@ pub(crate) fn godot_project_cleanup_required(project: &Path) -> Result Ok(entries.next().is_some()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(_) => Err("无法确认 Godot 桥缓存是否已清理".into()), + Err(error) => Err(format!("无法确认 Godot 桥缓存是否已清理:{error}")), } } @@ -266,7 +266,7 @@ fn remove_fence(path: &Path) -> Result<(), String> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("无法清理编辑器执行确认记录,继续阻断执行".into()), + Err(error) => Err(format!("无法清理编辑器执行确认记录:{error},继续阻断执行")), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 5025dc08c..ec6176520 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -22,6 +22,77 @@ const SCHEMA: &str = "agc-node-runtime.v1"; const PROBE_TIMEOUT: Duration = Duration::from_secs(10); const MAX_PROBE_BYTES: u64 = 4096; +/// 进程输出持续排空但只保留有界尾部;失败收口前精确脱敏,不能把 stderr 整份丢弃。 +#[derive(Default)] +struct CapturedProcessOutput { + bytes: Vec, + total: usize, +} + +async fn capture_process_output( + mut reader: impl tokio::io::AsyncRead + Unpin, + output: &mut CapturedProcessOutput, + limit: usize, +) -> Result<(), std::io::Error> { + let mut buffer = vec![0u8; 4096]; + loop { + let count = reader.read(&mut buffer).await?; + if count == 0 { + return Ok(()); + } + output.total = output.total.saturating_add(count); + output.bytes.extend_from_slice(&buffer[..count]); + let excess = output.bytes.len().saturating_sub(limit); + if excess > 0 { + output.bytes.drain(..excess); + } + } +} + +fn environment_failure(code: &str, detail: impl std::fmt::Display) -> String { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail.to_string(), + 1800, + ); + format!("{code}: {detail}") +} + +fn process_failure_output( + code: &str, + detail: impl std::fmt::Display, + stdout: &CapturedProcessOutput, + stderr: &CapturedProcessOutput, +) -> String { + // 优先保留 stderr(npm/Node 的具体故障通常在这里),stdout 留作构建报错的补充。 + environment_failure( + code, + format!( + "{detail};stdoutBytes={};stderrBytes={};stderr={};stdout={}", + stdout.total, + stderr.total, + String::from_utf8_lossy(&stderr.bytes), + String::from_utf8_lossy(&stdout.bytes), + ), + ) +} + +fn blocked_environment_report(error: &str) -> Value { + let candidate = error.split([':', ';']).next().unwrap_or_default(); + let code = if candidate.contains('-') + && candidate + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + candidate + } else { + "environment-check-unclassified" + }; + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + json!({"status":"blocked", "code":code, "diagnostic":diagnostic}) +} + #[derive(Clone, Debug)] pub(crate) struct NodeRuntime { pub node: PathBuf, @@ -282,11 +353,488 @@ fn validate_bundle(directory: &Path) -> Result<(), String> { Ok(()) } +fn host_home_directory() -> Option { + #[cfg(windows)] + { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + } + #[cfg(not(windows))] + { + std::env::var_os("HOME").map(PathBuf::from) + } +} + +/// 受控 Node 安装前缀:`/bin/node` + `/lib/node_modules/npm`(fnm / nvm / 系统 +/// 发行版)或随包 `/node` + `/node_modules/npm`。宽泛宿主目录、用户 HOME 及其 +/// 祖先、逃逸符号链接都在这里失败关闭;命令沙箱复用同一校验,避免主机发现与 bwrap mount 两套 +/// 信任规则漂移。 +pub(crate) fn validate_node_installation_prefix(prefix: &Path) -> Result { + let canonical = fs::canonicalize(prefix) + .map_err(|error| format!("无法 canonicalize node 安装前缀:{error}"))?; + if !canonical.is_absolute() || canonical.parent().is_none() { + return Err("node 安装前缀必须是非根目录的绝对路径".to_string()); + } + const DENIED_ROOTS: [&str; 11] = [ + "/home", "/root", "/tmp", "/var", "/etc", "/proc", "/dev", "/run", "/sys", "/boot", "/srv", + ]; + if DENIED_ROOTS + .iter() + .any(|denied| canonical == Path::new(denied)) + { + return Err(format!( + "拒绝把宽泛宿主目录作为 node 安装前缀:{}", + canonical.display() + )); + } + if let Some(home) = host_home_directory().and_then(|home| fs::canonicalize(home).ok()) { + if canonical == home || home.starts_with(&canonical) { + return Err("拒绝把用户主目录或其祖先作为 node 安装前缀".to_string()); + } + } + let bin_node = canonical.join("bin").join(executable_name()); + let bundle_node = canonical.join(executable_name()); + let bin_npm_cli = canonical.join("lib/node_modules/npm/bin/npm-cli.js"); + let bundle_npm_cli = canonical.join("node_modules/npm/bin/npm-cli.js"); + let node = if bin_node.is_file() { + Some(bin_node) + } else if bundle_node.is_file() { + Some(bundle_node) + } else { + None + }; + let npm_cli = if bin_npm_cli.is_file() { + Some(bin_npm_cli) + } else if bundle_npm_cli.is_file() { + Some(bundle_npm_cli) + } else { + None + }; + let (node, npm_cli) = match (node, npm_cli) { + (Some(node), Some(npm_cli)) => (node, npm_cli), + _ => { + return Err(format!( + "{} 不是完整的 node 安装前缀(缺少 node 或 npm-cli.js)", + canonical.display() + )) + } + }; + for path in [&node, &npm_cli] { + let resolved = fs::canonicalize(path) + .map_err(|error| format!("node 安装前缀内路径不可解析:{error}"))?; + if !resolved.starts_with(&canonical) { + return Err("node 安装前缀存在逃逸符号链接".to_string()); + } + } + Ok(canonical) +} + +fn parse_numeric_version(value: &str) -> Option<(u64, u64, u64)> { + let value = value.trim().trim_start_matches('v'); + let mut parts = value.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts + .next() + .map_or(Some(0), |part| part.parse::().ok())?; + let patch = parts + .next() + .map_or(Some(0), |part| part.parse::().ok())?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct InstalledNodeVersion { + manager: &'static str, + version: (u64, u64, u64), + prefix: PathBuf, +} + +fn environment_managed_roots() -> (Vec, Vec) { + let mut fnm_roots = Vec::new(); + let mut nvm_roots = Vec::new(); + if let Some(dir) = std::env::var_os("FNM_DIR") { + fnm_roots.push(PathBuf::from(dir)); + } + if let Some(dir) = std::env::var_os("NVM_DIR") { + nvm_roots.push(PathBuf::from(dir)); + } + if let Some(home) = host_home_directory() { + fnm_roots.push(home.join(".local").join("share").join("fnm")); + nvm_roots.push(home.join(".nvm")); + } + dedup_paths(&mut fnm_roots); + dedup_paths(&mut nvm_roots); + (fnm_roots, nvm_roots) +} + +fn dedup_paths(paths: &mut Vec) { + let mut seen = BTreeSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + +fn installed_node_versions( + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Vec { + let mut installed = Vec::new(); + for root in fnm_roots { + installed.extend(collect_installed_node_versions( + &root.join("node-versions"), + "fnm", + true, + )); + } + for root in nvm_roots { + installed.extend(collect_installed_node_versions( + &root.join("versions").join("node"), + "nvm", + false, + )); + } + installed +} + +fn collect_installed_node_versions( + versions_dir: &Path, + manager: &'static str, + nested_installation: bool, +) -> Vec { + let Ok(entries) = fs::read_dir(versions_dir) else { + return Vec::new(); + }; + let mut installed = Vec::new(); + for entry in entries.flatten() { + let Some(version) = parse_numeric_version(&entry.file_name().to_string_lossy()) else { + continue; + }; + let candidate = if nested_installation { + entry.path().join("installation") + } else { + entry.path() + }; + let Ok(prefix) = validate_node_installation_prefix(&candidate) else { + continue; + }; + installed.push(InstalledNodeVersion { + manager, + version, + prefix, + }); + } + installed +} + +fn runtime_from_installed( + installed: &InstalledNodeVersion, + root: &Path, + path: &OsStr, +) -> Result { + let prefix = &installed.prefix; + let bin_node = prefix.join("bin").join(executable_name()); + let node = if bin_node.is_file() { + bin_node + } else { + prefix.join(executable_name()) + }; + let bin_npm_cli = prefix.join("lib/node_modules/npm/bin/npm-cli.js"); + let npm_cli = if bin_npm_cli.is_file() { + bin_npm_cli + } else { + prefix.join("node_modules/npm/bin/npm-cli.js") + }; + runtime_from_paths(root, node, npm_cli, installed.manager, path) +} + +/// 把 `.nvmrc` / `.node-version` / `engines.node` 里的单个比较项解释成对某个已安装版本的判定。 +/// 返回 `None` 表示当前比较项超出受支持子集,调用方必须整体回落到宿主 PATH,不能猜。故意不支持 +/// `||`、连字符区间、prerelease 与部分 `>` / `<=` 语义,避免用错误匹配替换用户选中的版本。 +fn comparator_matches_version(version: (u64, u64, u64), comparator: &str) -> Option { + let (operator, rest) = if let Some(rest) = comparator.strip_prefix(">=") { + (">=", rest) + } else if let Some(rest) = comparator.strip_prefix("<=") { + ("<=", rest) + } else if let Some(rest) = comparator.strip_prefix('>') { + (">", rest) + } else if let Some(rest) = comparator.strip_prefix('<') { + ("<", rest) + } else if let Some(rest) = comparator.strip_prefix('=') { + ("=", rest) + } else if let Some(rest) = comparator.strip_prefix('^') { + ("^", rest) + } else if let Some(rest) = comparator.strip_prefix('~') { + ("~", rest) + } else { + ("=", comparator) + }; + let rest = rest.trim(); + if rest.is_empty() { + return None; + } + if rest.eq_ignore_ascii_case("x") || rest == "*" || rest.eq_ignore_ascii_case("latest") { + return Some(true); + } + let mut parsed = Vec::new(); + for component in rest.split('.') { + if component.eq_ignore_ascii_case("x") || component == "*" { + break; + } + parsed.push(component.parse::().ok()?); + } + if parsed.is_empty() || parsed.len() > 3 { + return None; + } + let parts = parsed.len(); + let major = parsed[0]; + let minor = *parsed.get(1).unwrap_or(&0); + let patch = *parsed.get(2).unwrap_or(&0); + Some(match operator { + "=" => match parts { + 1 => version.0 == major, + 2 => version.0 == major && version.1 == minor, + _ => version == (major, minor, patch), + }, + ">=" => version >= (major, minor, patch), + ">" if parts < 3 => return None, + ">" => version > (major, minor, patch), + "<" => version < (major, minor, patch), + "<=" if parts < 3 => return None, + "<=" => version <= (major, minor, patch), + "^" => { + if major > 0 { + version >= (major, minor, patch) && version.0 == major + } else if parts >= 2 && minor > 0 { + version >= (0, minor, patch) && version.0 == 0 && version.1 == minor + } else if parts >= 3 { + version >= (0, 0, patch) && version.0 == 0 && version.1 == 0 && version.2 == patch + } else { + version.0 == 0 + } + } + "~" => { + if parts >= 2 { + version >= (major, minor, patch) && version.0 == major && version.1 == minor + } else { + version >= (major, 0, 0) && version.0 == major + } + } + _ => return None, + }) +} + +fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { + let pin = pin.trim(); + if pin.is_empty() { + return Some(true); + } + // `.nvmrc` 两种写法都常见:`v22.23.3` 与 `22.23.3`。 + let pin = pin.strip_prefix('v').unwrap_or(pin); + let lower = pin.to_ascii_lowercase(); + if matches!(lower.as_str(), "*" | "x" | "node" | "latest" | "lts/*") + || lower.starts_with("lts/") + { + return Some(true); + } + if lower.starts_with("iojs") || lower.contains("||") { + return None; + } + let mut any = false; + for comparator in pin.split_whitespace() { + any = true; + if !comparator_matches_version(version, comparator)? { + return Some(false); + } + } + if any { + Some(true) + } else { + None + } +} + +enum PinSelection<'a> { + Matched(&'a InstalledNodeVersion), + NoMatch, + Unsupported, +} + +fn select_pinned_installation<'a>( + installed: &'a [InstalledNodeVersion], + pin: &str, +) -> PinSelection<'a> { + let mut best: Option<&InstalledNodeVersion> = None; + let mut understood = false; + for candidate in installed { + match version_matches_pin(candidate.version, pin) { + Some(true) => { + understood = true; + if best.is_none_or(|current| candidate.version > current.version) { + best = Some(candidate); + } + } + Some(false) => understood = true, + None => return PinSelection::Unsupported, + } + } + match best { + Some(best) => PinSelection::Matched(best), + None if understood => PinSelection::NoMatch, + None => PinSelection::Unsupported, + } +} + +fn read_project_version_file_pin(root: &Path) -> Option { + for name in [".nvmrc", ".node-version"] { + let path = root.join(name); + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + if !metadata.is_file() || metadata.len() > 4096 { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + if let Some(pin) = content + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + { + return Some(pin.to_string()); + } + } + None +} + +fn read_project_engines_range(root: &Path) -> Option { + let path = root.join("package.json"); + let metadata = fs::metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() > 4 * 1024 * 1024 { + return None; + } + let bytes = fs::read(&path).ok()?; + let value: Value = serde_json::from_slice(&bytes).ok()?; + let range = value.get("engines")?.get("node")?.as_str()?.trim(); + if range.is_empty() { + None + } else { + Some(range.to_string()) + } +} + +fn active_managed_prefix() -> Option { + if let Some(path) = std::env::var_os("FNM_MULTISHELL_PATH") { + if let Ok(prefix) = validate_node_installation_prefix(Path::new(&path)) { + return Some(prefix); + } + } + if let Some(bin) = std::env::var_os("NVM_BIN") { + if let Some(parent) = Path::new(&bin).parent() { + if let Ok(prefix) = validate_node_installation_prefix(parent) { + return Some(prefix); + } + } + } + None +} + +fn default_managed_installation<'a>( + installed: &'a [InstalledNodeVersion], + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Option<&'a InstalledNodeVersion> { + for root in fnm_roots { + let alias = root.join("aliases").join("default"); + if let Ok(prefix) = validate_node_installation_prefix(&alias) { + if let Some(node) = installed.iter().find(|node| node.prefix == prefix) { + return Some(node); + } + } + } + for root in nvm_roots { + let Ok(content) = fs::read_to_string(root.join("alias").join("default")) else { + continue; + }; + // nvm 的 default 别名常写成 `22`、`v22.23.3` 或 `lts/*`,不是完整三元组;按 pin 的 + // 同一子集在已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。 + if let PinSelection::Matched(node) = select_pinned_installation(installed, content.trim()) { + return Some(node); + } + } + None +} + +fn resolve_pinned_managed_runtime( + root: &Path, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Result, String> { + let installed = installed_node_versions(fnm_roots, nvm_roots); + if installed.is_empty() { + return Ok(None); + } + let Some(pin) = read_project_version_file_pin(root) else { + return Ok(None); + }; + match select_pinned_installation(&installed, &pin) { + PinSelection::Matched(node) => Ok(Some(runtime_from_installed(node, root, path)?)), + PinSelection::NoMatch => Err("node-version-pinned-not-installed".into()), + PinSelection::Unsupported => Ok(None), + } +} + +fn resolve_managed_fallback_runtime( + root: &Path, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Result, String> { + let installed = installed_node_versions(fnm_roots, nvm_roots); + if installed.is_empty() { + return Ok(None); + } + // engines.node 只作为“在已安装版本里优先选谁”的偏好,不阻塞;真正的版本文件 pin 才失败关闭。 + if let Some(range) = read_project_engines_range(root) { + if let PinSelection::Matched(node) = select_pinned_installation(&installed, &range) { + return Ok(Some(runtime_from_installed(node, root, path)?)); + } + } + if let Some(prefix) = active_managed_prefix() { + if let Some(node) = installed.iter().find(|node| node.prefix == prefix) { + return Ok(Some(runtime_from_installed(node, root, path)?)); + } + } + if let Some(node) = default_managed_installation(&installed, fnm_roots, nvm_roots) { + return Ok(Some(runtime_from_installed(node, root, path)?)); + } + let node = installed + .iter() + .max_by_key(|node| node.version) + .expect("non-empty installed versions"); + Ok(Some(runtime_from_installed(node, root, path)?)) +} + fn resolve_at( root: &Path, bundle: Option<&Path>, development: bool, path: &OsStr, +) -> Result { + let (fnm_roots, nvm_roots) = environment_managed_roots(); + resolve_at_with_managed_roots(root, bundle, development, path, &fnm_roots, &nvm_roots) +} + +fn resolve_at_with_managed_roots( + root: &Path, + bundle: Option<&Path>, + development: bool, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], ) -> Result { let root = root .canonicalize() @@ -304,16 +852,31 @@ fn resolve_at( if !development { return Err("node-runtime-bundle-missing".into()); } - let directories = safe_directories(&root, path); + if let Some(runtime) = resolve_pinned_managed_runtime(&root, path, fnm_roots, nvm_roots)? { + return Ok(runtime); + } + if let Some(runtime) = resolve_host_path_runtime(&root, path) { + return Ok(runtime); + } + if let Some(runtime) = resolve_managed_fallback_runtime(&root, path, fnm_roots, nvm_roots)? { + return Ok(runtime); + } + Err("node-npm-runtime-missing".into()) +} + +fn resolve_host_path_runtime(root: &Path, path: &OsStr) -> Option { + let directories = safe_directories(root, path); for directory in &directories { let candidate = directory.join(executable_name()); let Ok(node) = candidate.canonicalize() else { continue; }; - if !node.is_file() || node.starts_with(&root) { + if !node.is_file() || node.starts_with(root) { continue; } - let parent = node.parent().ok_or("node-runtime-invalid")?; + let Some(parent) = node.parent() else { + continue; + }; let mut npm_candidates = vec![ parent.join("node_modules/npm/bin/npm-cli.js"), parent.join("../lib/node_modules/npm/bin/npm-cli.js"), @@ -330,10 +893,12 @@ fn resolve_at( .into_iter() .find(|candidate| candidate.is_file()) { - return runtime_from_paths(&root, node, npm_cli, "development", path); + if let Ok(runtime) = runtime_from_paths(root, node, npm_cli, "development", path) { + return Some(runtime); + } } } - Err("node-npm-runtime-missing".into()) + None } pub(crate) fn resolve_node_runtime(root: &Path) -> Result { @@ -357,11 +922,14 @@ fn valid_version(value: &str) -> bool { async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result { // npm 初始化也会查询用户目录;显式隔离,避免依赖 Windows 后备查询。 - let home = tempfile::tempdir().map_err(|_| "runtime-probe-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "runtime-probe-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "runtime-probe-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let mut command = tokio::process::Command::new(&runtime.node); command .env_clear() @@ -377,7 +945,7 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Result MAX_PROBE_BYTES { - return Err("runtime-version-output-too-large"); - } - let status = child - .wait() - .await - .map_err(|_| "runtime-version-wait-failed")?; + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_PROBE_BYTES as usize), + capture_process_output(stderr, &mut error_output, MAX_PROBE_BYTES as usize), + child.wait(), + ); + out.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + err.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + let status = + status.map_err(|error| environment_failure("runtime-version-wait-failed", error))?; if !status.success() { - return Err("runtime-version-failed"); + return Err(process_failure_output( + "runtime-version-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); + } + if output.total as u64 > MAX_PROBE_BYTES { + return Err(process_failure_output( + "runtime-version-output-too-large", + format!("版本输出超过 {MAX_PROBE_BYTES} 字节上限"), + &output, + &error_output, + )); } Ok(()) }) @@ -421,15 +1002,23 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Value { let result = tokio::task::spawn_blocking(move || resolve_node_runtime(&root)).await; let runtime = match result { Ok(Ok(runtime)) => runtime, - Ok(Err(code)) => { - return json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Ok(Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + return blocked; } - Err(_) => { - return json!({"status":"blocked","code":"runtime-check-failed","elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + return blocked_environment_report(&environment_failure( + "runtime-check-failed", + error, + )); } }; let (node, npm) = tokio::join!( @@ -458,8 +1052,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { (Ok(node), Ok(npm)) => { json!({"status":"ready","source":runtime.source,"nodeVersion":node,"npmVersion":npm,"elapsedMs":started.elapsed().as_millis()}) } - (Err(code), _) | (_, Err(code)) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + (Err(error), _) | (_, Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -469,8 +1065,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { Ok(browser) => { json!({"status":"ready","kind":browser.kind,"product":browser.product,"protocolVersion":browser.protocol_version,"elapsedMs":started.elapsed().as_millis()}) } - Err(code) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -617,12 +1215,34 @@ mod tests { fs::write(root.path().join(executable_name()), b"fake").unwrap(); let path = std::env::join_paths([Path::new("."), root.path()]).unwrap(); assert_eq!( - resolve_at(root.path(), None, true, &path).unwrap_err(), + resolve_at_with_managed_roots(root.path(), None, true, &path, &[], &[]).unwrap_err(), "node-npm-runtime-missing" ); assert!(!valid_version("v24.0.0\nSECRET")); } + #[test] + fn runtime_probe_failure_keeps_bounded_redacted_process_detail() { + let stdout = CapturedProcessOutput { + bytes: b"npm version probe".to_vec(), + total: 18, + }; + let stderr = CapturedProcessOutput { + bytes: b"EACCES: permission denied; Authorization: Bearer private-token; C:\\Users\\private\\npmrc".to_vec(), + total: 91, + }; + let detail = process_failure_output( + "runtime-version-failed", + "exitStatus=code:1", + &stdout, + &stderr, + ); + assert!(detail.contains("runtime-version-failed"), "{detail}"); + assert!(detail.contains("EACCES"), "{detail}"); + assert!(!detail.contains("private-token"), "{detail}"); + assert!(!detail.contains("C:\\Users\\private"), "{detail}"); + } + #[test] fn diagnostic_path_removes_runtime_and_project_entries_but_keeps_other_tools() { let project = tempfile::tempdir().unwrap(); @@ -682,4 +1302,227 @@ mod tests { "1.0.0" ); } + + fn install_node_fixture(prefix: &Path) { + let bin = prefix.join("bin"); + let npm = prefix.join("lib/node_modules/npm/bin"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&npm).unwrap(); + fs::write(bin.join(executable_name()), b"node").unwrap(); + fs::write(npm.join("npm-cli.js"), b"npm").unwrap(); + } + + fn install_fnm_version(root: &Path, version: &str) -> PathBuf { + let installation = root + .join("node-versions") + .join(version) + .join("installation"); + install_node_fixture(&installation); + installation + } + + fn install_nvm_version(root: &Path, version: &str) -> PathBuf { + let prefix = root.join("versions").join("node").join(version); + install_node_fixture(&prefix); + prefix + } + + #[test] + fn node_installation_prefix_rejects_home_incomplete_and_symlink_escape() { + let fnm_root = tempfile::tempdir().unwrap(); + let installation = install_fnm_version(fnm_root.path(), "v22.23.3"); + assert_eq!( + validate_node_installation_prefix(&installation).unwrap(), + installation.canonicalize().unwrap() + ); + if let Some(home) = host_home_directory() { + assert!(validate_node_installation_prefix(&home).is_err()); + } + let incomplete = tempfile::tempdir().unwrap(); + fs::create_dir_all(incomplete.path().join("bin")).unwrap(); + fs::write( + incomplete.path().join("bin").join(executable_name()), + b"node", + ) + .unwrap(); + assert!(validate_node_installation_prefix(incomplete.path()).is_err()); + + let link_root = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let valid_link = link_root.path().join("installation-link"); + symlink(&installation, &valid_link).unwrap(); + // 指向真实安装的 symlink 解析后仍是完整前缀。 + assert_eq!( + validate_node_installation_prefix(&valid_link).unwrap(), + installation.canonicalize().unwrap() + ); + let version_link = link_root.path().join("version-link"); + symlink( + fnm_root.path().join("node-versions").join("v22.23.3"), + &version_link, + ) + .unwrap(); + // 解析后不是安装前缀(缺 bin/node + lib/node_modules/npm)必须失败关闭。 + assert!(validate_node_installation_prefix(&version_link).is_err()); + } + let _ = link_root; + } + + #[test] + fn collects_fnm_and_nvm_installations_and_falls_back_to_highest() { + let fnm_root = tempfile::tempdir().unwrap(); + let nvm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v22.23.3"); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let installed = installed_node_versions( + &[fnm_root.path().to_path_buf()], + &[nvm_root.path().to_path_buf()], + ); + assert_eq!(installed.len(), 2); + assert_eq!(installed[0].manager, "fnm"); + assert_eq!(installed[1].manager, "nvm"); + + let project = tempfile::tempdir().unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[nvm_root.path().to_path_buf()], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + assert!(runtime.node.to_string_lossy().contains("v22.23.3")); + } + + #[test] + fn nvm_major_only_default_alias_selects_the_installed_patch() { + let nvm_root = tempfile::tempdir().unwrap(); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let expected = install_nvm_version(nvm_root.path(), "v22.23.3"); + let alias_dir = nvm_root.path().join("alias"); + fs::create_dir_all(&alias_dir).unwrap(); + // `nvm alias default 22` 写的是主版本号,不是完整三元组。 + fs::write(alias_dir.join("default"), "22\n").unwrap(); + let installed = installed_node_versions(&[], &[nvm_root.path().to_path_buf()]); + assert_eq!(installed.len(), 2); + let default = + default_managed_installation(&installed, &[], &[nvm_root.path().to_path_buf()]) + .expect("major-only default alias should resolve"); + assert_eq!(default.prefix, expected.canonicalize().unwrap()); + assert_eq!(default.version, (22, 23, 3)); + } + + #[test] + fn version_file_pin_is_authoritative_and_blocks_when_not_installed() { + let fnm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v20.11.0"); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let project = tempfile::tempdir().unwrap(); + fs::write(project.path().join(".nvmrc"), "20\n").unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); + + fs::write(project.path().join(".nvmrc"), "v18.0.0\n").unwrap(); + assert_eq!( + resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap_err(), + "node-version-pinned-not-installed" + ); + } + + #[test] + fn engines_range_is_a_preference_and_never_blocks() { + let fnm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v20.11.0"); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let project = tempfile::tempdir().unwrap(); + fs::write( + project.path().join("package.json"), + r#"{"engines":{"node":"^20.0.0"}}"#, + ) + .unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); + + fs::write( + project.path().join("package.json"), + r#"{"engines":{"node":"^18.0.0"}}"#, + ) + .unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert!(runtime.node.to_string_lossy().contains("v22.23.3")); + } + + #[test] + fn unsupported_version_pin_falls_back_instead_of_blocking() { + let fnm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let project = tempfile::tempdir().unwrap(); + for pin in ["iojs\n", ">20\n", "<=20\n"] { + fs::write(project.path().join(".node-version"), pin).unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + } + } + + #[test] + fn version_pin_comparators_follow_the_documented_subset() { + assert_eq!(version_matches_pin((20, 11, 0), "20"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "20.11"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "20.11.0"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "22"), Some(false)); + assert_eq!(version_matches_pin((20, 11, 0), "^20.0.0"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "~20.11.0"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), ">=20 <23"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), ">=22 || 20"), None); + assert_eq!(version_matches_pin((20, 11, 0), ">20"), None); + assert_eq!(version_matches_pin((20, 11, 0), "<=20"), None); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 018e9ff92..5c3d1ee48 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -179,24 +179,6 @@ pub(crate) fn record_new_web_scaffold_at(root: &Path, project_id: &str) -> Resul write_receipt(root, &receipt) } -async fn drain_output( - mut reader: R, -) -> Result<(), std::io::Error> { - // 保留输出大小界限;正文不进入预检回执,也不因管道满而阻塞子进程。 - let mut total = 0usize; - let mut buffer = [0u8; 4096]; - loop { - let count = reader.read(&mut buffer).await?; - if count == 0 { - return Ok(()); - } - total = total.saturating_add(count); - if total > MAX_COMMAND_OUTPUT { - total = MAX_COMMAND_OUTPUT; - } - } -} - async fn host_npm( runtime: &NodeRuntime, cwd: &Path, @@ -205,11 +187,14 @@ async fn host_npm( online: bool, owner: Option<(&Path, &PreparationOwner)>, ) -> Result<(), String> { - let home = tempfile::tempdir().map_err(|_| "web-build-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "web-build-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "web-build-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let mut execution = NpmExecution::new(); if let Some((root, owner)) = owner { record_execution(root, owner, Some(execution.clone()))?; @@ -251,9 +236,11 @@ async fn host_npm( use std::os::unix::process::CommandExt; command.as_std_mut().process_group(0); } - let mut child = command.spawn().map_err(|_| "web-build-start-failed")?; - let tree = - OwnedNpmTree::assign(&child, &execution).map_err(|_| "web-build-process-guard-failed")?; + let mut child = command + .spawn() + .map_err(|error| environment_failure("web-build-start-failed", error))?; + let tree = OwnedNpmTree::assign(&child, &execution) + .map_err(|error| environment_failure("web-build-process-guard-failed", error))?; execution.process = Some(ProcessIdentity::capture( child.id().ok_or("web-build-process-identity-missing")?, )?); @@ -265,17 +252,28 @@ async fn host_npm( stdin .write_all(b"GO\n") .await - .map_err(|_| "web-build-gate-failed")?; + .map_err(|error| environment_failure("web-build-gate-failed", error))?; drop(stdin); let stdout = child.stdout.take().ok_or("web-build-output-missing")?; let stderr = child.stderr.take().ok_or("web-build-output-missing")?; + let mut output = CapturedProcessOutput::default(); + let mut error_output = CapturedProcessOutput::default(); let result = tokio::time::timeout(timeout, async { - let (out, err, status) = - tokio::join!(drain_output(stdout), drain_output(stderr), child.wait()); - out.map_err(|_| "web-build-output-failed")?; - err.map_err(|_| "web-build-output-failed")?; - if !status.map_err(|_| "web-build-wait-failed")?.success() { - return Err("web-build-command-failed"); + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_COMMAND_OUTPUT), + capture_process_output(stderr, &mut error_output, MAX_COMMAND_OUTPUT), + child.wait() + ); + out.map_err(|error| environment_failure("web-build-output-failed", error))?; + err.map_err(|error| environment_failure("web-build-output-failed", error))?; + let status = status.map_err(|error| environment_failure("web-build-wait-failed", error))?; + if !status.success() { + return Err(process_failure_output( + "web-build-command-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); } Ok(()) }) @@ -285,10 +283,19 @@ async fn host_npm( let _ = child.kill().await; let _ = child.wait().await; } - tree.terminate() - .map_err(|_| "web-build-subtree-not-reaped")?; + tree.terminate().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })?; let deadline = Instant::now() + Duration::from_secs(5); - while !tree.empty().map_err(|_| "web-build-subtree-not-reaped")? { + while !tree.empty().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })? { if Instant::now() >= deadline { return Err("web-build-subtree-not-reaped".into()); } @@ -297,9 +304,14 @@ async fn host_npm( if let Some((root, owner)) = owner { record_execution(root, owner, None)?; } - result - .map_err(|_| "web-build-timeout")? - .map_err(str::to_string) + result.map_err(|_| { + process_failure_output( + "web-build-timeout", + format!("npm 执行超过 {} ms", timeout.as_millis()), + &output, + &error_output, + ) + })? } /// 浏览器验证失败的宿主错误码。 @@ -349,11 +361,10 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } -fn browser_validation_diagnostic(error: &str) -> Option<&str> { - (error.starts_with("browser-recovery-") - || error.starts_with("browser-launch-failed:") - || error.starts_with("browser-cleanup-unconfirmed:")) - .then_some(error) +fn browser_validation_diagnostic(error: &str) -> Option { + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + (!diagnostic.is_empty()).then_some(diagnostic) } pub(crate) async fn host_web_creation_preflight() -> Value { @@ -415,19 +426,8 @@ pub(crate) async fn host_web_creation_preflight() -> Value { } .await; let mut result = run.unwrap_or_else(|error| { - let (code, diagnostic) = error - .split_once(";diagnostic=") - .map_or((error.as_str(), None), |(code, diagnostic)| { - (code, Some(diagnostic)) - }); - let mut blocked = json!({ - "schemaVersion": "agc-web-creation-preflight.v1", - "status": "blocked", - "code": code, - }); - if let Some(diagnostic) = diagnostic { - blocked["diagnostic"] = json!(diagnostic); - } + let mut blocked = blocked_environment_report(&error); + blocked["schemaVersion"] = json!("agc-web-creation-preflight.v1"); blocked }); result["elapsedMs"] = json!(started.elapsed().as_millis()); @@ -623,12 +623,12 @@ mod tests { "web-preflight-browser-recovery-failed" ); assert_eq!( - browser_validation_diagnostic(recovery_diagnostic), + browser_validation_diagnostic(recovery_diagnostic).as_deref(), Some(recovery_diagnostic) ); assert_eq!( - browser_validation_diagnostic("启动浏览器失败:原始错误"), - None + browser_validation_diagnostic("启动浏览器失败:原始错误").as_deref(), + Some("启动浏览器失败:原始错误") ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), @@ -640,7 +640,7 @@ mod tests { "web-preflight-browser-cleanup-failed" ); assert_eq!( - browser_validation_diagnostic(cleanup_diagnostic), + browser_validation_diagnostic(cleanup_diagnostic).as_deref(), Some(cleanup_diagnostic) ); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs index b6f8bb86d..58f7a1f8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs @@ -36,12 +36,17 @@ pub(crate) fn schedule_notification() { let (events, current_generation) = snapshot_with_generation(); LAST_EMITTED_GENERATION.store(current_generation, Ordering::Release); if !events.is_empty() { - let _ = handle.emit( + if let Err(error) = handle.emit( "error-report-updated", serde_json::json!({ "generation": current_generation, }), - ); + ) { + let _ = crate::append_application_log_line(&format!( + "RUST error_report::notifications: error_report.notification.emit_failed generation={} detail={error}", + current_generation + )); + } } if let Some(active) = TIMER_ACTIVE.get() { *active diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs index 8ec0d10f8..7739aa7f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs @@ -101,8 +101,15 @@ pub fn report_diagnostic_error( let action = action.map(|value| sanitize(value, MAX_ACTION_CHARS)); let page = page.map(|value| sanitize(value, MAX_PAGE_CHARS)); let sanitized_message = sanitize(message, MAX_MESSAGE_CHARS); - let message = - stable_codex_app_server_error_message(&sanitized_message).unwrap_or(sanitized_message); + // 分类字符串只用于稳定去重,不能覆盖用户在错误报告里看到的正文。 + // 之前这里直接把展示值替换成 `codex-app-server-error:`,导致 401/429、 + // IPC、stderr 等事实被压成一个编号;保留精确脱敏后的正文,另取稳定键参与指纹。 + let stable_message = stable_codex_app_server_error_message(&sanitized_message); + let fingerprint_message = stable_message + .as_deref() + .map(ToOwned::to_owned) + .unwrap_or_else(|| sanitized_message.clone()); + let message = sanitized_message; let stack = stack.map(|value| sanitize(value, MAX_STACK_CHARS)); if message.trim().is_empty() { return None; @@ -111,7 +118,7 @@ pub fn report_diagnostic_error( &source, action.as_deref().unwrap_or_default(), page.as_deref().unwrap_or_default(), - &message, + &fingerprint_message, raw_call_site.as_deref().unwrap_or_default(), ]); let timestamp = now(); @@ -122,6 +129,12 @@ pub fn report_diagnostic_error( if let Some(existing) = state.events.get_mut(&fingerprint) { existing.count = existing.count.saturating_add(1); existing.last_occurred_at = timestamp; + // 如果先到的是前端的短分类、后到的是宿主的详细正文,用后者补全列表。 + if stable_message.as_deref() == Some(existing.message.as_str()) + && message != existing.message + { + existing.message = message.clone(); + } (false, existing.clone()) } else { state.sequence = state.sequence.saturating_add(1); @@ -245,6 +258,33 @@ mod tests { .expect("frontend event"); assert_eq!(runtime.event_id, frontend.event_id); assert_eq!(snapshot()[0].count, 2); - assert_eq!(snapshot()[0].message, "codex-app-server-error:other"); + assert_eq!( + snapshot()[0].message, + "runtime 调用 LLM 失败:kind=codex-app-server-other fingerprint= chars=42" + ); + } + + #[test] + fn detailed_codex_app_server_message_upgrades_an_earlier_short_classification() { + reset_for_tests(); + let short = report_diagnostic_error( + "agent-runtime", + "codex-app-server-error:other", + None, + Some("agent-runtime"), + Some("project-supervisor"), + ) + .expect("short event"); + let detailed = report_agent_runtime_error( + "project-supervisor", + "runtime 调用 LLM 失败:kind=codex-app-server-other exitStatus=1 stderr=IPC EPIPE", + ) + .expect("detailed event"); + assert_eq!(short.event_id, detailed.event_id); + assert_eq!(snapshot()[0].count, 2); + assert_eq!( + snapshot()[0].message, + "runtime 调用 LLM 失败:kind=codex-app-server-other exitStatus=1 stderr=IPC EPIPE" + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs index 54698314f..ecb0adc23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs @@ -12,7 +12,7 @@ use reqwest::StatusCode; use serde_json::Value; use sha2::{Digest, Sha256}; use shared_contracts::error_reports::{CreateErrorReportBatchRequest, ErrorReportLogInput, Event}; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); @@ -69,30 +69,71 @@ fn stable_submission_id(events: &[Event]) -> String { } fn response_error(status: StatusCode, body: &str) -> String { - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return "permission-denied: 当前账号没有提交错误报告的权限".to_string(); - } let detail = serde_json::from_str::(body) .ok() .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) + let error = value.get("error").unwrap_or(&value); + Some( + ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"), + ) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); - format!("错误报告提交失败:{detail}") + .filter(|value| !value.is_empty()) + .or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) + }) + .map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }) + .filter(|value| !value.trim().is_empty()); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: 当前账号没有提交错误报告的权限{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + format!( + "错误报告提交失败:HTTP {}{}", + status.as_u16(), + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ) } fn response_data(body: &str) -> Result { let value: Value = serde_json::from_str(body) - .map_err(|_| "错误报告提交失败:响应不是合法 JSON".to_string())?; + .map_err(|error| format!("错误报告提交失败:响应不是合法 JSON:{error}"))?; Ok(value.get("data").cloned().unwrap_or(value)) } @@ -134,7 +175,7 @@ pub async fn submit_error_report( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建错误报告客户端失败".to_string())?; + .map_err(|error| format!("创建错误报告客户端失败:{error}"))?; validate_session(&snapshot)?; let response = client .post(endpoint(&snapshot)?) @@ -144,12 +185,12 @@ pub async fn submit_error_report( .json(&payload) .send() .await - .map_err(|_| "错误报告提交失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("错误报告提交失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "错误报告提交失败:读取响应失败".to_string())?; + .map_err(|error| format!("错误报告提交失败:读取响应失败:{error}"))?; validate_session(&snapshot)?; if !status.is_success() { return Err(response_error(status, &body)); @@ -219,4 +260,16 @@ mod tests { assert_eq!(value["batchId"], "batch-1"); assert!(!value.to_string().contains("token")); } + + #[test] + fn response_error_keeps_status_and_redacted_server_detail() { + let detail = response_error( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"code":"RATE_LIMITED","message":"too many requests;token=secret"}}"#, + ); + assert!(detail.contains("HTTP 429"), "{detail}"); + assert!(detail.contains("RATE_LIMITED"), "{detail}"); + assert!(detail.contains("too many requests"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs new file mode 100644 index 000000000..44d8e8742 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/mod.rs @@ -0,0 +1,2 @@ +pub mod taonier; +pub mod xhs_minitool; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/build.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/build.rs new file mode 100644 index 000000000..d997e8686 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/build.rs @@ -0,0 +1,654 @@ +//! 跑用户项目自己的导出脚本,并读取固定产物。 +//! +//! 宿主不碰 vite、不碰 `pack.mjs`:它只是以既有 `command.exec` 的边界(脚本白名单、反漂移 +//! `expectedCommand`、沙箱、超时、输出清洗)运行项目声明的那条脚本,然后确认产物真的在,且是一份 +//! **根目录带 `index.html` 的 zip**(与陶泥儿 zip 规范一致)。 +//! +//! 与表单无关:表单不是构建输入,所以这里不校验表单,也不看 cover。 +//! +//! 产物读取入口 [`read_artifact_package`] 只读 [`artifact_path`] 这一个固定路径,供发布链路 +//! 取包;不做任何路径拼接,天然越界安全。 + +use std::io::{Cursor, Read}; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +use crate::project::{ + resolve_project_verification_spec_with_cwd_at, run_project_verification_spec_with_commit_at, +}; + +use super::commands::offload; +use super::dto::TaonierExportRunResult; +use super::error::{ + TaonierArtifactMissing, TaonierCommandDenied, TaonierCommandFailed, TaonierExportBuildError, + TaonierExportUnavailable, TaonierProjectUnavailable, +}; +use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME}; +use super::script::{self, NpmScript}; + +/// 权限点位复用既有命令口径:这就是在跑项目命令,不新增 `GAME_CREATION_APP_COMMANDS` 条目。 +const PERMISSION_COMMAND_ID: &str = "command.exec"; +/// 与前端一致:构建可能真的要跑一遍 vite,给到 `project.verify` 的同一档上限(300 秒)。 +const TIMEOUT_SECONDS: u64 = 300; +/// 回给前端的输出尾部上限(字符)。失败时用户要看的是最后几行报错,不是整份构建日志。 +const OUTPUT_TAIL_MAX_CHARS: usize = 4_000; +/// 陶泥儿 zip 规范的入口:根目录必须有这个文件,否则平台打不开。 +const ARTIFACT_ENTRY_FILE_NAME: &str = "index.html"; +/// 读取产物时单次读入的字节数。sha256 与字节缓冲共用这一遍流式读取。 +const PACKAGE_READ_CHUNK_BYTES: usize = 64 * 1024; +/// 读取产物时的体积安全线:超过它直接拒绝读入内存(与旧本地发行包读取口径一致)。 +const ARTIFACT_PACKAGE_READ_MAX_BYTES: u64 = 512 * 1024 * 1024; + +/// 平台发行包上限(200 MiB):单一来源是 `shared-contracts`,这里只做发布前的同口径预检。 +/// +/// 与上一条读取安全线不是一个概念:安全线管「能不能读进内存」,这里管「平台上能不能收」。 +/// 构建收尾与发布读包两处都过它,避免作者等完一轮再吃服务端 413。 +fn ensure_within_platform_package_limit(size_bytes: u64) -> Result<(), String> { + let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; + if size_bytes > limit { + return Err(format!( + "发行包 {:.1} MiB 超过平台上限 {} MiB;请精简资源后重新导出再发布", + size_bytes as f64 / (1024.0 * 1024.0), + limit / (1024 * 1024) + )); + } + Ok(()) +} + +/// 固定产物读出来的包:发布链路只需要这几项事实,不需要逐条文件摘要。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TaonierArtifactPackage { + pub(crate) package_bytes: Vec, + pub(crate) package_sha256: String, + pub(crate) package_size_bytes: u64, + /// zip 内的文件条目数(不含目录条目)。 + pub(crate) package_file_count: u32, +} + +pub(crate) async fn run(root: &Path) -> Result { + // 找脚本、读权限策略、清上一次产物都是同步文件 IO:与读/存命令同一口径挪到阻塞线程池, + // 不占 async worker。 + let root = root.to_path_buf(); + let script = offload({ + let root = root.clone(); + move || prepare(&root) + }) + .await?; + + // 项目侧预检(package.json / 脚本缺失或漂移 / 包管理器不支持等)单独跑:它失败时报 + // `projectUnavailable`(改项目代码可解),不能和沙箱、日志这类宿主故障一起收成 + // `exportUnavailable`,否则用户会被引导去重试一个改项目才能解决的问题。 + let spec = resolve_project_verification_spec_with_cwd_at( + &root, + SCRIPT_NAME, + &script.command, + TIMEOUT_SECONDS, + &script.cwd_relative, + ) + .map_err(|cause| { + TaonierExportBuildError::ProjectUnavailable(TaonierProjectUnavailable::new(cause)) + })?; + + let result = run_project_verification_spec_with_commit_at( + &root, + spec, + // 导出不推进项目 revision,也不改任何正式状态:产物只是 `.export/` 下的一个文件。 + || Ok(()), + ) + .await + .map_err(TaonierExportUnavailable::new)?; + + // `target_exec != "established"` 就是「脚本根本没跑起来」:沙箱建不起来(`not-attempted`)或目标 + // 可执行文件执行失败(`failed`)。这跟「脚本跑了但被信号杀掉」(`exit_code` 同样是 None,但 + // `target_exec` 是 `established`)不是一回事,所以判据用它,而不是 `exit_code.is_none()`。 + let script_launched = result.target_exec == "established"; + let exit_code = result.exit_code; + let timed_out = result.timed_out; + let output = result.output; + + // 收尾判产物同样是同步 IO(symlink_metadata + 读 zip),一样搬去阻塞线程池。 + offload(move || conclude(&root, exit_code, timed_out, script_launched, &output)).await +} + +/// 构建前置(同步):找到脚本、确认权限点位没被拒、清掉上一次的产物。 +/// +/// 产物判据只看「非空且是合规 zip」,所以先清掉上一次的 zip:脚本这次什么都没写时,残留的旧 zip 会 +/// 让 no-op 脚本被判成功,用户上传的其实是上一轮的产物。删不掉(权限等)就 fail closed—— +/// 留着旧产物继续判成功,比这次构建失败更糟。 +fn prepare(root: &Path) -> Result { + let Some(script) = script::find(root) else { + return Err(script::missing_error()); + }; + // 用 typed 拒绝而不是 `enforce_project_permission_policy` 的字符串版本:那个版本把 + // 「策略拒绝」与「策略读不出来」拼成同一句人话,调用方就分不出「预期拒绝」与「宿主故障」了。 + crate::project::enforce_project_permission_policy_rejection(root, PERMISSION_COMMAND_ID) + .map_err(|rejection| match rejection { + crate::agent::tool::error::ProjectPermissionRejection::Denied { command_id } => { + TaonierExportBuildError::CommandDenied(TaonierCommandDenied { command_id }) + } + crate::agent::tool::error::ProjectPermissionRejection::PolicyUnavailable { cause } => { + TaonierExportBuildError::ExportUnavailable(TaonierExportUnavailable::new(cause)) + } + })?; + + let artifact = artifact_path(root); + match std::fs::remove_file(&artifact) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(TaonierExportBuildError::ExportUnavailable( + TaonierExportUnavailable::new(format!( + "清理上一次导出产物失败:{}:{error}", + artifact.display() + )), + )); + } + } + + Ok(script) +} + +/// 把宿主的构建结果收成导出结果:先判脚本有没有真的跑起来,再判成功,最后确认产物真的在、且是 +/// 根目录带 `index.html` 的 zip。 +/// +/// 只吃四个字段而不是整份 `ProjectVerificationResult`:这条判据是导出链路里唯一"脚本跑完之后 +/// 算不算数"的规则,抽成纯函数才能脱离"真的跑一遍 npm"逐条测(未启动、非零退出、超时、产物缺失、 +/// zip 不合规、成功)。 +fn conclude( + root: &Path, + exit_code: Option, + timed_out: bool, + script_launched: bool, + output: &str, +) -> Result { + if !script_launched { + // 脚本根本没跑起来(沙箱建不起来 / 目标可执行文件执行失败):这是宿主侧事实故障, + // 不是项目代码的毛病。报 `exportUnavailable` 让前端按宿主故障处理(并抛进错误池上报), + // 而不是 `commandFailed` 去引导 code agent 改一份它改不动的宿主环境。 + let (output_tail, _) = tail(output); + return Err(TaonierExportBuildError::ExportUnavailable( + TaonierExportUnavailable::new(output_tail), + )); + } + + if timed_out || exit_code != Some(0) { + let (output_tail, omitted_characters) = tail(output); + return Err(TaonierExportBuildError::CommandFailed( + TaonierCommandFailed { + exit_code, + output_tail, + omitted_characters, + }, + )); + } + + let path = artifact_path(root); + let produced = matches!( + std::fs::symlink_metadata(&path), + // `metadata` 会跟随符号链接:指向项目外某个非空文件的链接会被当成「产出成功」, + // 而下游下载路径又按 fail closed 拒收链接。这里用 symlink_metadata 与之一致。 + Ok(metadata) + if metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.len() > 0 + ); + // zip 规范:根目录必须有 `index.html`,且包体不得超过平台发行包上限。脚本"成功"但产物 + // 不合规(或写到了别处),与没有产物同类,都报 artifactMissing。 + // + // 只有 `produced`(普通文件、非符号链接、非空)才去读;读之前先看体积,超过读入安全线 + // 直接当作不合规,避免构建脚本写出多 GB 文件时先把宿主内存读满。`std::fs::read` 会跟随 + // 符号链接,所以这里必须先用 `produced` 挡住链接,再走体积门禁。 + let bytes = if produced { + std::fs::symlink_metadata(&path) + .ok() + .filter(|metadata| { + metadata.len() <= ARTIFACT_PACKAGE_READ_MAX_BYTES + && ensure_within_platform_package_limit(metadata.len()).is_ok() + }) + .and_then(|_| std::fs::read(&path).ok()) + } else { + None + }; + let conformant = produced + && bytes.as_deref().is_some_and(|bytes| { + ensure_within_platform_package_limit(bytes.len() as u64).is_ok() + && inspect_artifact(bytes).is_ok() + }); + if !conformant { + return Err(TaonierExportBuildError::ArtifactMissing( + TaonierArtifactMissing { + path: artifact_relative_path(), + }, + )); + } + + // 成功也回输出尾部:`pack.mjs` 会把 zip 的条目数与体积打在这里,正是用户上传前要看的数字。 + let (output_tail, omitted_characters) = tail(output); + Ok(TaonierExportRunResult { + output_tail, + omitted_characters, + }) +} + +/// 读取固定产物,交给发布链路。 +/// +/// 只读 [`artifact_path`]:不接用户传入路径、不做拼接,所以没有越界面。校验顺序与 +/// `conclude` 一致:普通文件、非空、在体积上限内、能作为 zip 打开、根目录有 `index.html`。 +/// 失败统一返回给发布链路的 `String` 现场。 +pub(crate) fn read_artifact_package(root: &Path) -> Result { + let path = artifact_path(root); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("读取陶泥儿产物失败:{}:{error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("陶泥儿产物必须是普通文件".to_string()); + } + if metadata.len() == 0 { + return Err("陶泥儿产物为空".to_string()); + } + if metadata.len() > ARTIFACT_PACKAGE_READ_MAX_BYTES { + return Err(format!( + "陶泥儿产物大小超出上限:{}B > {}B", + metadata.len(), + ARTIFACT_PACKAGE_READ_MAX_BYTES + )); + } + ensure_within_platform_package_limit(metadata.len())?; + + let (package_bytes, package_sha256) = read_package_bytes(&path, metadata.len())?; + if package_bytes.len() as u64 != metadata.len() { + // 读取期间文件被替换:宁可让作者重新导出,也不上传一份哈希与内容对不上的包。 + return Err("陶泥儿产物在读取期间发生变化,请重新导出".to_string()); + } + let package_size_bytes = package_bytes.len() as u64; + let package_file_count = inspect_artifact(&package_bytes)?; + Ok(TaonierArtifactPackage { + package_bytes, + package_sha256, + package_size_bytes, + package_file_count, + }) +} + +/// 一遍读完产物并同时算 sha256:不为了哈希再读一次盘。上限之外立即失败,避免把超大文件读进内存。 +/// +/// 调用方已经确认过 `expected_bytes` 在上限内,按它预分配容量即可,避免接近 200 MiB 的包在 +/// 反复扩容时多复制约一倍数据。 +fn read_package_bytes(path: &Path, expected_bytes: u64) -> Result<(Vec, String), String> { + // 不跟随符号链接打开:`read_artifact_package` 先做了 symlink_metadata 检查,这里用 + // no-follow 打开关掉「检查后、打开前被换成同长度链接」的 TOCTOU 窗口。 + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(path) + .map_err(|error| format!("打开陶泥儿产物失败:{}:{error}", path.display()))?; + let mut hasher = Sha256::new(); + let mut bytes = Vec::with_capacity(expected_bytes as usize); + let mut buffer = vec![0u8; PACKAGE_READ_CHUNK_BYTES]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("读取陶泥儿产物失败:{error}"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + bytes.extend_from_slice(&buffer[..read]); + if bytes.len() as u64 > ARTIFACT_PACKAGE_READ_MAX_BYTES { + return Err(format!( + "陶泥儿产物大小超出上限:{}B", + ARTIFACT_PACKAGE_READ_MAX_BYTES + )); + } + } + Ok((bytes, format!("{:x}", hasher.finalize()))) +} + +/// 校验 zip 能打开、根目录有 `index.html`,并返回文件条目数(不含目录条目)。 +/// +/// `conclude` 与 [`read_artifact_package`] 共用它,保证「构建算成功」与「发布读得出来」是同一 +/// 条规范,不会一处放行一处拒绝。 +fn inspect_artifact(bytes: &[u8]) -> Result { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("陶泥儿产物不是合法 ZIP:{error}"))?; + let mut file_count: u32 = 0; + let mut has_root_index = false; + for index in 0..archive.len() { + let entry = archive + .by_index(index) + .map_err(|error| format!("读取陶泥儿产物条目失败:{error}"))?; + if entry.is_dir() { + continue; + } + file_count = file_count.saturating_add(1); + if entry.name() == ARTIFACT_ENTRY_FILE_NAME { + has_root_index = true; + } + } + if !has_root_index { + return Err(format!("陶泥儿产物根目录缺少 {ARTIFACT_ENTRY_FILE_NAME}")); + } + Ok(file_count) +} + +/// 只留尾部若干字符:整份构建日志塞进 IPC 没有意义,还会把界面拖住。 +/// +/// 返回(尾部原文, 省略了多少个字符)。省略量是**事实**,「省略前 N 个字符」那句话由前端拼—— +/// 宿主不预拼用户可见文案,否则前端没法按自己的现场排版,也没法判断这段尾巴是否完整。 +fn tail(output: &str) -> (String, usize) { + let trimmed = output.trim(); + let characters: Vec = trimmed.chars().collect(); + if characters.len() <= OUTPUT_TAIL_MAX_CHARS { + return (trimmed.to_string(), 0); + } + let skipped = characters.len() - OUTPUT_TAIL_MAX_CHARS; + let tail: String = characters[skipped..].iter().collect(); + (tail, skipped) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + /// 每次都新建一个临时项目根:产物判据是文件系统事实,测试之间不能共享目录。 + fn fixture_root(label: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-taonier-build-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn write_artifact(root: &Path, bytes: &[u8]) { + let path = artifact_path(root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + } + + /// 用 zip 写入器造一份真实产物,避免测试依赖脆弱的 zip 字节字面量。 + fn zip_with_entries(entries: &[(&str, &[u8])]) -> Vec { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, content) in entries { + writer.start_file(*name, options).unwrap(); + writer.write_all(content).unwrap(); + } + writer.finish().unwrap().into_inner() + } + + fn valid_artifact() -> Vec { + zip_with_entries(&[("index.html", b"")]) + } + + #[test] + fn keeps_short_output_as_is_and_omits_nothing() { + assert_eq!(tail(" 构建失败\n"), ("构建失败".to_string(), 0)); + } + + #[test] + fn keeps_only_the_tail_of_long_output_and_reports_the_fact() { + let output: String = "前".repeat(OUTPUT_TAIL_MAX_CHARS) + "后"; + let (kept, omitted) = tail(&output); + // 载荷里只有原文与省略量两个事实:说明那句话由前端拼,宿主不写。 + assert!(kept.ends_with('后')); + assert!(!kept.contains("省略")); + assert_eq!(omitted, 1); + } + + #[test] + fn reports_a_missing_script_before_touching_the_project() { + let root = fixture_root("missing-script"); + + let error = tauri::async_runtime::block_on(run(&root)) + .expect_err("no package.json means no script"); + assert!(matches!( + error, + TaonierExportBuildError::BuildScriptMissing(_) + )); + } + + #[test] + fn a_non_zero_exit_keeps_the_code_and_the_output_tail() { + let root = fixture_root("non-zero"); + + let error = conclude(&root, Some(1), false, true, "vite 构建失败\n入口不存在") + .expect_err("non-zero exit must fail"); + let TaonierExportBuildError::CommandFailed(failure) = error else { + panic!("非零退出必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, Some(1)); + assert_eq!(failure.output_tail, "vite 构建失败\n入口不存在"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_timeout_still_reports_command_failed_without_an_exit_code() { + let root = fixture_root("timeout"); + + let error = conclude(&root, None, true, true, "已取消") + .expect_err("timeout must fail even with exit code 0"); + let TaonierExportBuildError::CommandFailed(failure) = error else { + panic!("超时必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, None); + assert_eq!(failure.output_tail, "已取消"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_successful_script_without_the_artifact_is_reported_as_missing() { + let root = fixture_root("no-artifact"); + + let error = + conclude(&root, Some(0), false, true, "构建完成").expect_err("产物缺失必须失败"); + let TaonierExportBuildError::ArtifactMissing(missing) = error else { + panic!("没有 zip 必须映射成 artifactMissing"); + }; + assert_eq!(missing.path, ".export/taonier.zip"); + } + + #[test] + fn an_empty_artifact_file_does_not_count() { + let root = fixture_root("empty-artifact"); + write_artifact(&root, b""); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + )); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_artifact_does_not_count() { + let root = fixture_root("symlink-artifact"); + let path = artifact_path(&root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let target = root.join("outside.zip"); + std::fs::write(&target, valid_artifact()).unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + + assert!( + matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + ), + "指向项目外非空文件的链接不能算产出成功" + ); + } + + #[test] + fn a_zip_without_a_root_index_does_not_count() { + let root = fixture_root("no-root-index"); + // 合法 zip,但入口在 game/ 下:陶泥儿规范要求根 index.html。 + write_artifact( + &root, + &zip_with_entries(&[("game/index.html", b"")]), + ); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + )); + } + + #[test] + fn a_non_zip_artifact_does_not_count() { + let root = fixture_root("not-a-zip"); + write_artifact(&root, b"PK\x03\x04 not really a zip"); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(TaonierExportBuildError::ArtifactMissing(_)) + )); + } + + #[test] + fn a_successful_script_with_a_conformant_artifact_returns_the_output_tail() { + let root = fixture_root("ok"); + write_artifact(&root, &valid_artifact()); + + let result = conclude( + &root, + Some(0), + false, + true, + "zip 已生成:.export/taonier.zip(3 个条目)", + ) + .expect("产物在就应当成功"); + assert!(result.output_tail.contains("3 个条目")); + assert_eq!(result.omitted_characters, 0); + } + + #[test] + fn a_script_that_never_launched_is_host_unavailable_not_command_failed() { + let root = fixture_root("not-launched"); + + // 沙箱建不起来或目标可执行文件执行失败:`exit_code` 同样是 None,但脚本根本没跑, + // 必须报宿主侧故障,不能让「让陶泥儿修」的按钮出现在一个它改不动的环境问题上。 + let error = conclude(&root, None, false, false, "命令沙箱不可用") + .expect_err("脚本没跑起来必须失败"); + let TaonierExportBuildError::ExportUnavailable(unavailable) = error else { + panic!("脚本未启动必须映射成 exportUnavailable"); + }; + assert_eq!(unavailable.cause, "命令沙箱不可用"); + } + + #[test] + fn platform_package_limit_is_the_shared_contract_and_reports_mib() { + let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; + assert!(ensure_within_platform_package_limit(limit).is_ok()); + // 读取安全线(512 MiB)与平台上限(200 MiB)不是一个概念:这里必须卡平台上限。 + assert!(limit < ARTIFACT_PACKAGE_READ_MAX_BYTES); + let error = + ensure_within_platform_package_limit(limit + 1).expect_err("超过平台上限必须失败"); + assert!( + error.contains("超过平台上限"), + "错误文案应说明超限:{error}" + ); + assert!(error.contains("200 MiB"), "错误文案应带上平台上限:{error}"); + } + + #[test] + fn reads_the_fixed_artifact_with_hash_size_and_file_count() { + let root = fixture_root("read-package"); + let bytes = zip_with_entries(&[ + ("index.html", b""), + ("assets/app.js", b"console.log(1);"), + ]); + write_artifact(&root, &bytes); + + let package = read_artifact_package(&root).expect("合规产物应当读得出来"); + assert_eq!(package.package_bytes, bytes); + assert_eq!(package.package_size_bytes, bytes.len() as u64); + assert_eq!(package.package_file_count, 2); + assert_eq!( + package.package_sha256, + format!("{:x}", Sha256::digest(&bytes)) + ); + } + + #[test] + fn reading_a_missing_or_illegal_artifact_fails() { + let root = fixture_root("read-package-missing"); + assert!(read_artifact_package(&root).is_err(), "缺文件必须失败"); + + write_artifact(&root, b"not a zip"); + assert!(read_artifact_package(&root).is_err(), "非法 zip 必须失败"); + + write_artifact(&root, &zip_with_entries(&[("game/index.html", b"x")])); + assert!( + read_artifact_package(&root).is_err(), + "根目录没有 index.html 必须失败" + ); + } + + /// 真起进程跑一遍宿主链路:解析 npm 包、走 `command.exec` 边界执行项目脚本、再按产物判据收尾。 + /// + /// 默认 `#[ignore]`:它要本机 `node` / `npm` 与可用的命令沙箱,慢且依赖宿主环境,不进常规单测。 + /// 手动跑:`cargo test --bin genarrative-ai-game-creator-shell -- --ignored executes_the_project_script`。 + #[test] + #[ignore = "需要本机 node/npm 与可用命令沙箱,手动用 --ignored 运行"] + fn executes_the_project_script_and_requires_a_conformant_artifact_each_time() { + const MARKER: &str = "打包完成:3 个条目"; + // store-only 的最小 zip:根目录一个 `index.html`。用 base64 内联,避免脚本依赖外部打包器。 + const FIXTURE_ZIP_BASE64: &str = "UEsDBBQAAAAAACkER12BbcM1JQAAACUAAAAKAAAAaW5kZXguaHRtbDwhZG9jdHlwZSBodG1sPjx0aXRsZT50YW9uaWVyPC90aXRsZT5QSwMEFAAAAAAAKQRHXT3uIyAPAAAADwAAAA0AAABhc3NldHMvYXBwLmpzY29uc29sZS5sb2coMSk7UEsBAhQDFAAAAAAAKQRHXYFtwzUlAAAAJQAAAAoAAAAAAAAAAAAAAIABAAAAAGluZGV4Lmh0bWxQSwECFAMUAAAAAAApBEddPe4jIA8AAAAPAAAADQAAAAAAAAAAAAAAgAFNAAAAYXNzZXRzL2FwcC5qc1BLBQYAAAAAAgACAHMAAACHAAAAAAA="; + let root = fixture_root("real-run"); + std::fs::write( + root.join("package.json"), + format!( + "{{\"name\":\"taonier-real-run-fixture\",\"private\":true,\"scripts\":{{\"{SCRIPT_NAME}\":\"node make-artifact.mjs\"}}}}" + ), + ) + .unwrap(); + std::fs::write( + root.join("make-artifact.mjs"), + format!( + "import {{ mkdirSync, writeFileSync }} from 'node:fs';\n\ + mkdirSync('.export', {{ recursive: true }});\n\ + writeFileSync('.export/taonier.zip', Buffer.from('{FIXTURE_ZIP_BASE64}', 'base64'));\n\ + console.log('{MARKER}');\n" + ), + ) + .unwrap(); + + let first = tauri::async_runtime::block_on(run(&root)).expect("真实脚本应当跑通"); + assert!( + first.output_tail.contains(MARKER), + "输出尾部应当带回脚本的 stdout:{}", + first.output_tail + ); + + // 每一次导出都重新构建:删掉产物再点一次导出,必须是脚本重新产出的那一份。 + std::fs::remove_file(artifact_path(&root)).expect("删掉第一次的产物"); + tauri::async_runtime::block_on(run(&root)).expect("第二次导出也应当重新产出"); + assert!(artifact_path(&root).is_file(), "产物必须被重新写出来"); + + // 脚本"成功"却没产出:改成什么都不写,必须报 artifactMissing 而不是假成功。 + std::fs::write(root.join("make-artifact.mjs"), "console.log('nothing');\n").unwrap(); + std::fs::remove_file(artifact_path(&root)).unwrap(); + let error = + tauri::async_runtime::block_on(run(&root)).expect_err("脚本没产出 zip 时必须失败"); + assert!(matches!(error, TaonierExportBuildError::ArtifactMissing(_))); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/commands.rs new file mode 100644 index 000000000..7c4a9049e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/commands.rs @@ -0,0 +1,254 @@ +//! 导出面板用到的四个 `#[tauri::command]`。 +//! +//! 每个命令只做一件事,失败表也各有一条([`super::error`])。没有项目写锁、不推进 revision:宿主 +//! 在这里读写的,就是 `.export/` 下那一个注册表文件。 +//! +//! 「内容」与「注册表指纹」是两条命令:[`read_taonier_export`] 是面板自动刷新反复调的 +//! 那条,拿不到指纹;指纹(写回时的 `baseHash`)只在进编辑会话、或内容真的被采纳时单独取一次。 +//! 分开是为了让刷新动作在类型上就动不了写回基线——否则外部改动会被静默吞掉。 +//! +//! 入参在 JS 侧是 camelCase(Tauri 的既有口径):`projectPath`、`baseHash`。 +//! +//! 命令边界各记一条 `app_log!`(走在 `project/*` 模块同样的口径上):面板卡在「正在读取」时, +//! AppData 里的 `export.<命令>.failed` 那一行就是答案。读命令每 2 秒重试一次,所以**同一个失败只 +//! 记一次**,变了或中间成功过一次才再记——否则日志会被同一句刷满。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; + +use super::dto::{TaonierExportForm, TaonierExportRunResult, TaonierExportState}; +use super::error::{ + TaonierExportBuildError, TaonierExportReadError, TaonierExportSaveError, + TaonierExportUnavailable, +}; +use super::registry::{self, RegistrySnapshot}; +use super::{build, script}; + +/// 读内容:注册表(缺则补一份空表单)+ 脚本是否存在。自动刷新反复调的就是这一条。 +#[tauri::command] +pub(crate) async fn read_taonier_export( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content(&project_path) + }) + .await; + report("read", &project_path, &result); + result +} + +/// 读注册表指纹:写回时原样带回做冲突判据。 +/// +/// 单独一条命令,且**只在内容被采纳时**取:让「刷新」这条高频路径在类型上碰不到写回基线。 +#[tauri::command] +pub(crate) async fn read_taonier_export_hash( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content_hash(&project_path) + }) + .await; + report("readHash", &project_path, &result); + result +} + +/// 保存表单:只按 `base_hash` 判冲突,成功后回新的指纹。 +/// +/// **不校验表单内容**——必填与字数上限由界面在提交前挡住(用户最终面对的是界面,这份表单也只是 +/// 一份草稿),宿主只负责把它原样写进注册表。`coverPath` / `screenshotPaths` 这类路径事实在真正读 +/// 它的那条路(预览、下载)由统一的路径门禁复核,不在这里预支。 +#[tauri::command] +pub(crate) async fn save_taonier_export_form( + project_path: String, + form: TaonierExportForm, + base_hash: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + let form = form.clone(); + let base_hash = base_hash.clone(); + move || save_form(&project_path, &form, &base_hash) + }) + .await; + report("save", &project_path, &result); + if let Ok(hash) = &result { + app_log!( + "export.save.ok project={project_path} titleChars={} summaryChars={} categoryChars={} coverPath={} screenshotCount={} baseHash={} contentHash={}", + form.title.trim().chars().count(), + form.summary.trim().chars().count(), + form.category.trim().chars().count(), + form.cover_path.trim(), + form.screenshot_paths.len(), + short(&base_hash), + short(hash), + ); + } + result +} + +/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。**不校验表单**——表单不是构建输入。 +#[tauri::command] +pub(crate) async fn run_taonier_export_build( + project_path: String, +) -> Result { + app_log!("export.build.start project={project_path}"); + let result = run_build(&project_path).await; + match &result { + Ok(run) => { + clear_failure("build", &project_path); + app_log!( + "export.build.ok project={project_path} outputChars={} omittedCharacters={}", + run.output_tail.chars().count(), + run.omitted_characters, + ); + } + Err(error) => log_failure("build", &project_path, error), + } + result +} + +/// `hasScript` 永远现算:注册表里不存、也不缓存这个事实。 +fn content(root: &Path, snapshot: RegistrySnapshot) -> TaonierExportState { + TaonierExportState { + form: snapshot.form, + has_script: script::find(root).is_some(), + } +} + +/// 项目根校验对三条命令都是同一种宿主故障:各命令用 `From` 自己收口。 +fn project_root(project_path: &str) -> Result { + let root = PathBuf::from(project_path); + crate::project::validate_project_root(&root).map_err(TaonierExportUnavailable::new)?; + Ok(root) +} + +/// 把小块同步 IO 挪到 Tokio 的阻塞线程池。 +/// +/// 读命令每 2 秒被轮询一次,注册表 + `package.json` 探测虽然小,但直接在 `async fn` 里做同步 +/// 文件 IO 会占着 worker 线程,拖慢同一 runtime 上的其它异步任务(构建子进程、SpacetimeDB +/// 客户端)。任务 panic 时 `JoinError` 收口成 `ExportUnavailable`,走各命令既有的失败表。 +/// +/// 构建命令(`build::run`)的前后置同步 IO 也复用这里,所以对同级的 `build` 模块可见。 +pub(super) async fn offload(job: F) -> Result +where + T: Send + 'static, + E: From + Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + match tokio::task::spawn_blocking(job).await { + Ok(result) => result, + Err(error) => Err(E::from(TaonierExportUnavailable::new(format!( + "后台执行导出读写任务失败:{error}" + )))), + } +} + +fn read_content(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(content( + &root, + registry::load::(&root)?, + )) +} + +fn read_content_hash(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(registry::load::(&root)?.content_hash) +} + +fn save_form( + project_path: &str, + form: &TaonierExportForm, + base_hash: &str, +) -> Result { + let root = project_root(project_path)?; + Ok(registry::save(&root, form, base_hash)?.content_hash) +} + +async fn run_build(project_path: &str) -> Result { + // project_root 内部有同步文件 IO(Windows 上还可能触发一次性 UAC/ACL 修复), + // 与同模块其它同步 IO 一样挪到 spawn_blocking,别占着 Tokio worker。 + let root = offload({ + let project_path = project_path.to_string(); + move || project_root(&project_path) + }) + .await?; + build::run(&root).await +} + +/// 上一次记过的失败(按命令名 + 项目路径)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。 +/// +/// 去重键必须带上项目:`registryMalformed` 之类的现场文案不含文件路径,两个项目可能给出同一句 +/// `Debug`,只按命令名去重会把项目 B 的失败当成项目 A 的重复现场静默吞掉。 +static LAST_FAILURE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// 命令边界的统一收口:成功清掉去重记录,失败记一条。 +fn report(command: &'static str, project_path: &str, result: &Result) { + match result { + Ok(_) => clear_failure(command, project_path), + Err(error) => log_failure(command, project_path, error), + } +} + +/// 一条失败日志最多记多少个字符。 +/// +/// `CommandFailed` 的 `Debug` 里带着最多 4,000 字符的脚本输出尾部,原样落进 AppData 日志会把一次 +/// 失败撑成好几 KB,去重表也跟着留一份长字符串。日志只需要认得出「哪个变体、哪条命令失败」, +/// 完整现场已经结构化回给前端了。 +const MAX_LOGGED_FAILURE_CHARS: usize = 512; + +fn log_failure(command: &'static str, project_path: &str, error: &impl std::fmt::Debug) { + let message = bounded_for_log(&format!("{error:?}"), MAX_LOGGED_FAILURE_CHARS); + let key = (command, project_path.trim().to_string()); + if let Ok(mut last) = LAST_FAILURE.lock() { + if last.get(&key).is_some_and(|seen| *seen == message) { + return; + } + last.insert(key, message.clone()); + } + app_log!("export.{command}.failed project={project_path}: {message}"); +} + +/// 按**字符**(不是字节)截断日志文本,避免切坏 UTF-8;发生截断时补一个省略号说明。 +fn bounded_for_log(message: &str, max_chars: usize) -> String { + if message.chars().count() <= max_chars { + return message.to_string(); + } + let head: String = message.chars().take(max_chars).collect(); + format!("{head}…") +} + +fn clear_failure(command: &'static str, project_path: &str) { + if let Ok(mut last) = LAST_FAILURE.lock() { + last.remove(&(command, project_path.trim().to_string())); + } +} + +/// 日志里的指纹只留前 8 位:长度一样能看出「换了没有」,又不用把整串抄进日志。 +fn short(hash: &str) -> &str { + let end = hash.char_indices().nth(8).map_or(hash.len(), |(at, _)| at); + &hash[..end] +} + +#[cfg(test)] +mod tests { + use super::{bounded_for_log, short}; + + #[test] + fn short_keeps_first_eight_characters() { + assert_eq!(short("0123456789abcdef"), "01234567"); + assert_eq!(short("0123"), "0123"); + assert_eq!(short(""), ""); + } + + #[test] + fn bounded_for_log_keeps_short_text_and_truncates_on_char_boundaries() { + assert_eq!(bounded_for_log("构建失败", 8), "构建失败"); + // 按字符截断:4 个中文字符不会被切坏,末尾补省略号。 + assert_eq!(bounded_for_log("一二三四五六", 4), "一二三四…"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/dto.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/dto.rs new file mode 100644 index 000000000..ca04a7a2f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/dto.rs @@ -0,0 +1,88 @@ +//! 陶泥儿导出的线上形状(Rust → TS 由 ts-rs 生成,生成物不手改)。 + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这些值的唯一用途是让用户 +/// 复制粘贴到陶泥儿平台的上传页。 +/// +/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能 +/// 被静默忽略后带着半份表单继续走。 +/// +/// `cover_path` 为空字符串表示「沿用线上封面」;`screenshot_paths` 为空数组表示「整组沿用线上」。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportForm { + pub(crate) title: String, + pub(crate) summary: String, + pub(crate) category: String, + /// 项目内相对路径。空字符串表示沿用线上封面。 + pub(crate) cover_path: String, + /// 项目内相对路径列表。空数组表示整组沿用线上截图。 + pub(crate) screenshot_paths: Vec, +} + +impl TaonierExportForm { + /// 落盘形态:文本字段去掉首尾空白,路径列表逐项去空白并**丢弃空串**。 + /// + /// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白, + /// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。截图列表里的 + /// 空串是界面清空某项后的残留,不能作为一条「有路径」的记录落盘。 + pub(crate) fn normalized(&self) -> Self { + Self { + title: self.title.trim().to_string(), + summary: self.summary.trim().to_string(), + category: self.category.trim().to_string(), + cover_path: self.cover_path.trim().to_string(), + screenshot_paths: self + .screenshot_paths + .iter() + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) + .collect(), + } + } +} + +/// 注册表文件形状:`.export/taonier.json`。 +/// +/// 只有 `form` 一个键——没有 status、没有已适配标记、没有产物存在标记、没有 revision,那些要么是 +/// `package.json` 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。 +/// +/// 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它 +/// 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportRegistry { + pub(crate) form: TaonierExportForm, +} + +/// 面板读到的现场内容。 +/// +/// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 +/// 永远从 npm 包的 `package.json` 现算,不落盘。也没有注册表指纹:那是写回时的同步细节, +/// 由 `read_taonier_export_hash` 单独给,自动刷新读的这条命令碰不到它。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportState { + pub(crate) form: TaonierExportForm, + /// `scripts["build:taonier"]` 是否存在。 + pub(crate) has_script: bool, +} + +/// 一次成功导出的结果。产物路径是常量(`.export/taonier.zip`),不进 DTO。 +/// +/// 不带 `exit_code`:走到这里的出口码必然是 0,带回来只是一个恒为 0 的死字段;失败侧 +/// (`CommandFailed`)自己带退出码。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportRunResult { + /// 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。 + pub(crate) output_tail: String, + /// 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。 + pub(crate) omitted_characters: usize, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/error.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/error.rs new file mode 100644 index 000000000..a64087f16 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/error.rs @@ -0,0 +1,218 @@ +//! 命令边界的 typed 失败。 +//! +//! **一条命令一张失败表**,不合并成一张大枚举:调用方拿到的 `Result` 本身就限定了「这条命令 +//! 可能怎么失败」,JS 侧也因此不用按命令再挑一遍变体。 +//! +//! - [`TaonierExportReadError`]:读注册表(`read_taonier_export` / +//! `read_taonier_export_hash`); +//! - [`TaonierExportSaveError`]:写表单(`save_taonier_export_form`,自动保存与「打包」前的 +//! flush 走同一条)。`saveConflict` 只出现在这里——它是「这份输入还没落盘」的结果,不是构建 +//! 失败。表单内容的必填与字数由界面在提交前校验,宿主这一层不做业务校验,所以这张表里没有 +//! 「字段不合法」这一类; +//! - [`TaonierExportBuildError`]:跑构建(`run_taonier_export_build`)。构建不是表单的消费者, +//! 所以这张表里没有表单类变体。 +//! +//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。每条命令都是 +//! 首个错误即返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。 +//! +//! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的 +//! TS 联合类型。 +//! +//! 载荷结构体统一带 `Taonier` 前缀(变体名不变,所以线上 `type` 标签与小红书目标完全一致)。 +//! 原因是 ts-rs 的 `export_to` 是一个**按类型名落文件的平铺目录**:`RegistryMalformed` / +//! `BuildScriptMissing` / `SaveConflict` 这些通用名会被每个导出目标各写一遍,同名互相覆盖, +//! 生成结果取决于测试执行顺序。加平台前缀后每个目标的绑定各落各的文件,重生成可逐字节复现。 + +use serde::Serialize; +use ts_rs::TS; + +use super::dto::TaonierExportForm; + +/// 读注册表可能怎么失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum TaonierExportReadError { + /// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。 + RegistryMalformed(TaonierRegistryMalformed), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(TaonierExportUnavailable), +} + +/// 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum TaonierExportSaveError { + /// 注册表存在但不是本目标期望的形状。 + RegistryMalformed(TaonierRegistryMalformed), + /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 + SaveConflict(TaonierSaveConflict), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(TaonierExportUnavailable), +} + +/// 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum TaonierExportBuildError { + /// npm 包里没有 `build:taonier`;这就是「还没适配」的唯一现算判据。 + BuildScriptMissing(TaonierBuildScriptMissing), + /// 项目权限策略拒绝执行 `command.exec`:用户/项目策略说了不让跑,不是缺陷。 + CommandDenied(TaonierCommandDenied), + /// 脚本跑完没有正常收场(非零退出或超时)。 + CommandFailed(TaonierCommandFailed), + /// 脚本正常收场但合格产物不在(没有 zip,或 zip 根目录没有 `index.html`)。 + ArtifactMissing(TaonierArtifactMissing), + /// 项目侧预检没过(package.json / 脚本缺失或漂移 / 包管理器不支持等):改项目代码可解, + /// 与宿主环境跑不起来区分开。 + ProjectUnavailable(TaonierProjectUnavailable), + /// 脚本无法启动或其它宿主侧事实故障。 + ExportUnavailable(TaonierExportUnavailable), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierRegistryMalformed { + /// 解析器原文。它是引导语,不是给前端做判断的依据。 + pub(crate) cause: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierBuildScriptMissing { + /// 期望的脚本名(`build:taonier`)。 + pub(crate) script: String, + /// 宿主找过哪些 `package.json`,让用户知道去哪看。 + pub(crate) searched: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierSaveConflict { + /// 文件里现在是什么(字段级二选一的「文件里的」那一侧)。 + pub(crate) current: TaonierExportForm, + /// 冲突时文件的指纹;用户选完之后带着它重试保存。 + pub(crate) content_hash: String, +} + +/// 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障 +/// (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierCommandDenied { + /// 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。 + pub(crate) command_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierCommandFailed { + pub(crate) exit_code: Option, + /// 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。 + pub(crate) output_tail: String, + /// 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。 + pub(crate) omitted_characters: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierArtifactMissing { + /// 产物相对项目根的路径。 + pub(crate) path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierExportUnavailable { + pub(crate) cause: String, +} + +/// 项目侧预检失败的原文:缺哪个脚本、漂移到什么值、检测到哪种包管理器等。 +/// +/// 它和 [`TaonierExportUnavailable`] 分开,因为「要改的东西在不在项目代码里」是前端分流的唯一判据: +/// 这里的 `cause` 只是引导 agent 的现场原文,前端不解析它。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct TaonierProjectUnavailable { + pub(crate) cause: String, +} + +impl TaonierProjectUnavailable { + pub(crate) fn new(cause: impl Into) -> Self { + Self { + cause: cause.into(), + } + } +} + +impl TaonierExportUnavailable { + pub(crate) fn new(cause: impl Into) -> Self { + Self { + cause: cause.into(), + } + } +} + +/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。 +impl From for TaonierExportReadError { + fn from(payload: TaonierExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for TaonierExportSaveError { + fn from(payload: TaonierExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for TaonierExportBuildError { + fn from(payload: TaonierExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +/// 注册表层的失败折叠成各命令自己的表:读不出来的形状是 `registryMalformed`,其余是宿主故障。 +macro_rules! impl_from_registry_error { + ($error:ty) => { + impl From for $error { + fn from(error: crate::export::RegistryError) -> Self { + match error { + crate::export::RegistryError::Malformed { cause, .. } => { + Self::RegistryMalformed(TaonierRegistryMalformed { + cause: cause.to_string(), + }) + } + other => { + Self::ExportUnavailable(TaonierExportUnavailable::new(other.to_string())) + } + } + } + } + }; +} + +impl_from_registry_error!(TaonierExportReadError); +impl_from_registry_error!(TaonierExportSaveError); diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/layout.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/layout.rs new file mode 100644 index 000000000..e9f5be532 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/layout.rs @@ -0,0 +1,92 @@ +//! 陶泥儿在 `.export/` 下的固定名字与路径。 +//! +//! 这些名字同时出现在三处:宿主、agent 写的 npm 脚本、用户手动上传的文件名。所以它们是 +//! **契约常量**,只在这里定义一次,改名字要三处一起改。 + +use std::path::{Path, PathBuf}; + +use crate::export::EXPORT_WORK_RELATIVE_DIR; + +/// npm 脚本名。冒号形式不是审美选择:既有 `command.exec` 白名单只放行 `build` 与 `build:<后缀>`。 +pub(crate) const SCRIPT_NAME: &str = "build:taonier"; + +/// 注册表文件名(`.export/` 下)。 +pub(crate) const REGISTRY_FILE_NAME: &str = "taonier.json"; + +/// 交付产物文件名(`.export/` 下),人工上传用。 +pub(crate) const ARTIFACT_FILE_NAME: &str = "taonier.zip"; + +pub(crate) fn export_dir(root: &Path) -> PathBuf { + root.join(EXPORT_WORK_RELATIVE_DIR) +} + +pub(crate) fn registry_path(root: &Path) -> PathBuf { + export_dir(root).join(REGISTRY_FILE_NAME) +} + +pub(crate) fn artifact_path(root: &Path) -> PathBuf { + export_dir(root).join(ARTIFACT_FILE_NAME) +} + +/// 产物相对项目根的路径。前端下载与 prompt 用的都是它。 +pub(crate) fn artifact_relative_path() -> String { + format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") +} + +#[cfg(test)] +mod tests { + use std::path::Component; + + use super::*; + + /// 前端拿的是**字符串**相对路径(下载命令与 prompt 都用它),宿主写的是 `PathBuf`。两条路各自 + /// 拼一遍,必须落在同一个文件上——谁只改了一边,这条先红。 + #[test] + fn the_relative_path_names_the_same_file_the_host_writes() { + let root = Path::new("/tmp/project"); + assert_eq!(root.join(artifact_relative_path()), artifact_path(root)); + assert_eq!( + artifact_path(root) + .file_name() + .and_then(|name| name.to_str()), + Some(ARTIFACT_FILE_NAME) + ); + } + + /// 这个字符串会交给宿主的路径门禁(下载命令复核项目内相对路径)并写进 prompt:它必须是 + /// **项目内、单层、不带 `..`** 的相对路径。前端与 agent 都按它找文件,跑出 `.export/` 就是越界。 + #[test] + fn the_relative_path_cannot_walk_out_of_the_export_directory() { + let relative_path = artifact_relative_path(); + let relative = Path::new(&relative_path); + assert!(relative.is_relative()); + assert!(relative + .components() + .all(|component| matches!(component, Component::Normal(_)))); + // flat 布局:导出目录**直接**下的一个文件名,不为目标建子目录。 + assert_eq!(relative.parent(), Some(Path::new(EXPORT_WORK_RELATIVE_DIR))); + // 跨平台:它是给前端与 prompt 的字符串,Windows 上不许漏出 `\`。 + assert!(!relative_path.contains('\\')); + } + + /// flat 是刻意的决定:注册表与产物共用一个**项目根锚定**的目录,多目标也只是同目录不同文件名。 + /// 谁加了一层 per-target 子目录,这条就红。 + #[test] + fn every_file_of_this_target_shares_one_flat_directory() { + let root = Path::new("/tmp/project"); + assert_eq!(registry_path(root).parent(), artifact_path(root).parent()); + assert_eq!(export_dir(root).parent(), Some(root)); + assert_eq!( + export_dir(root).file_name().and_then(|name| name.to_str()), + Some(EXPORT_WORK_RELATIVE_DIR) + ); + } + + /// 脚本名不是自由文本:`command.exec` 的白名单只放行 `build` 与 `build:<安全后缀>`。 + /// 尤其不能退化成裸 `build`——那会跑掉项目自己的构建,产物永远不会出现。 + #[test] + fn the_script_name_keeps_the_form_the_command_allowlist_requires() { + assert_ne!(SCRIPT_NAME, "build"); + assert!(SCRIPT_NAME.starts_with("build:")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/mod.rs new file mode 100644 index 000000000..64298d6f6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/mod.rs @@ -0,0 +1,25 @@ +//! 陶泥儿导出目标。 +//! +//! 文件都堆在项目根的 `.export/`(见 [`crate::export::EXPORT_WORK_RELATIVE_DIR`]): +//! `taonier.json` 是注册表,`taonier.zip` 是交付给人工上传的产物,`taonier.*.mjs` +//! 是 agent 首次适配时从 `vite-export-taonier` skill 显式拷进来的脚本(允许它改)。共享 +//! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 +//! +//! 分层:`dto` 是线上形状,`error` 是命令边界失败表,`layout` 是契约常量,`registry` 管注册表, +//! `script` 管 npm 脚本发现,`build` 跑脚本、确认产物并对外提供固定产物读取入口,`commands` 是 +//! 唯一对外的命令面。 +//! +//! 表单**不在宿主侧做业务校验**:命令只把用户那份草稿原样读出来、写回去,必填与字数由界面在 +//! 提交前挡住。宿主要挡的只有「这份注册表文件写得对不对」这类 IO 事实(见 `registry`)。 + +pub mod commands; + +mod build; +pub(crate) mod dto; +mod error; +pub(crate) mod layout; +pub(crate) mod registry; +mod script; + +/// 发布链路读固定产物的入口。只读 `.export/taonier.zip`,不接用户路径。 +pub(crate) use build::{read_artifact_package, TaonierArtifactPackage}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/registry.rs new file mode 100644 index 000000000..42dc30b60 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/registry.rs @@ -0,0 +1,214 @@ +//! 陶泥儿注册表的读取与保存。 +//! +//! 文件级细节(自动创建、严格解析、原子写入、内容指纹)都在 [`crate::export::registry`];这里 +//! 只管本目标的事:注册表 ↔ 表单的映射,以及冲突判定。 +//! +//! 已知取舍(评审提出):这里用共享的 `load_or_create` / `save`,**不**额外检查注册表文件与 +//! `.export` 目录是不是符号链接。也就是说,如果 `.export/taonier.json` 被换成链接,读写会 +//! 落到项目外。 +//! +//! **决定不改,理由**:要挡的是「把项目内容交给打包链」的入口——产物(`build::conclude`)、脚本 +//! (`script::find`)和图标在预览 / 下载时走的路径门禁都拒链,因为它们把项目内容送出项目边界。 +//! 注册表读写的是用户自己项目里的一份草稿,用户既然能在磁盘上把它换成链接,就按「项目文件由用户 +//! 掌控」接受这个重定向;为它单独加守卫会改掉共享读写的契约,收益与成本不成比例。若以后注册表要 +//! 承载跨项目共享的内容,再按与那三处同口径补 `symlink_metadata` 检查。 + +use std::path::Path; + +use crate::export::{load_or_create, save as write_registry}; + +use super::dto::{TaonierExportForm, TaonierExportRegistry}; +use super::error::{TaonierExportSaveError, TaonierSaveConflict}; +use super::layout::registry_path; + +/// 注册表现场:表单,加上**这份内容**的指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegistrySnapshot { + pub(crate) form: TaonierExportForm, + pub(crate) content_hash: String, +} + +/// 读注册表;文件不存在时补一份全空表单。解析失败原样上抛成 `registryMalformed`。 +/// +/// 返回的错误类型由调用方定:读命令与保存命令各有一张失败表,两边都实现了 +/// `From`,所以这里泛型化,不替调用方选表。 +pub(crate) fn load(root: &Path) -> Result +where + E: From, +{ + let loaded = load_or_create(®istry_path(root), &TaonierExportRegistry::default())?; + Ok(RegistrySnapshot { + form: loaded.value.form, + content_hash: loaded.content_hash, + }) +} + +/// 保存表单。 +/// +/// `base_hash` 是调用方读到的指纹。文件没被换过就直接写;被换过但字段完全一致(只剩格式差异) +/// 就静默吸收、不写盘;字段真的不同则回 `saveConflict`,把文件里的值交给用户逐字段挑——**绝不** +/// 覆盖用户或 agent 的输入。 +pub(crate) fn save( + root: &Path, + form: &TaonierExportForm, + base_hash: &str, +) -> Result { + let path = registry_path(root); + // 读 `load_or_create` 与下面的 `write_registry` 之间没有锁,两步不是原子的:并发写(用户保存 + // 撞上 code agent 改同一个 `.export/taonier.json`)存在「后写覆盖先写」的窗口。 + // + // 这里明确接受这个窗口,不加 compare-and-swap:保存一律由用户手动触发(点保存 / 停手后的 + // 自动落盘 / 点打包前的 flush),用户本来就知道 code agent 在同一项目里改文件;真撞上时重新 + // 读一次注册表就能看到最新内容,不需要写入门禁级别的并发保证。把 `base_hash` 比较下沉进共享的 + // `crate::export::save`(原子重读 + 比较 + rename)会改掉所有导出目标的写契约,成本高于收益。 + let current = load_or_create(&path, &TaonierExportRegistry::default())?; + let form = form.normalized(); + if !current.created && current.content_hash != base_hash { + let current_form = current.value.form.normalized(); + if current_form != form { + return Err(TaonierExportSaveError::SaveConflict(TaonierSaveConflict { + current: current_form, + content_hash: current.content_hash, + })); + } + return Ok(RegistrySnapshot { + form: current_form, + content_hash: current.content_hash, + }); + } + let content_hash = write_registry(&path, &TaonierExportRegistry { form: form.clone() })?; + Ok(RegistrySnapshot { form, content_hash }) +} + +/// 发布链路只读草稿表单。 +/// +/// 发布不是写草稿的入口,所以这里只取表单、不关心指纹,也不做冲突判定;文件缺失时与读命令 +/// 同一口径(`load_or_create` 补一份空表单),让后续的封面/张数校验给出确定的「请先选择封面」 +/// 提示,而不是把「草稿不存在」误报成宿主故障。 +pub(crate) fn read_form(root: &Path) -> Result { + let loaded = load_or_create(®istry_path(root), &TaonierExportRegistry::default()) + .map_err(|error| error.to_string())?; + Ok(loaded.value.form) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-taonier-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + fn form(title: &str) -> TaonierExportForm { + TaonierExportForm { + title: title.to_string(), + summary: "简介".to_string(), + category: "休闲".to_string(), + cover_path: "assets/cover.png".to_string(), + screenshot_paths: vec!["assets/shot-1.png".to_string()], + } + } + + #[test] + fn creates_an_empty_registry_on_first_read() { + let root = fixture_project("create"); + + let snapshot = load::(&root).expect("load"); + assert_eq!(snapshot.form, TaonierExportForm::default()); + assert!( + root.join(".export/taonier.json").is_file(), + "读取必须顺手补出空注册表" + ); + } + + #[test] + fn saves_and_normalizes_whitespace() { + let root = fixture_project("save"); + let base = load::(&root).expect("load"); + + let mut value = form(" 陶泥儿作品 "); + value.summary = " 一句话简介 ".to_string(); + let saved = save(&root, &value, &base.content_hash).expect("save"); + assert_eq!(saved.form.title, "陶泥儿作品"); + assert_eq!(saved.form.summary, "一句话简介"); + + let reread = load::(&root).expect("reload"); + assert_eq!(reread.form, saved.form); + assert_eq!(reread.content_hash, saved.content_hash); + } + + #[test] + fn normalizes_each_screenshot_path_and_drops_empty_entries() { + let root = fixture_project("screenshots"); + let base = load::(&root).expect("load"); + + let mut value = form("陶泥儿作品"); + value.screenshot_paths = vec![ + " assets/a.png ".to_string(), + " ".to_string(), + String::new(), + "assets/b.png".to_string(), + ]; + let saved = save(&root, &value, &base.content_hash).expect("save"); + assert_eq!( + saved.form.screenshot_paths, + vec!["assets/a.png".to_string(), "assets/b.png".to_string()] + ); + + let reread = load::(&root).expect("reload"); + assert_eq!(reread.form, saved.form); + } + + #[test] + fn absorbs_formatting_only_changes_without_rewriting() { + let root = fixture_project("format"); + let base = load::(&root).expect("load"); + // agent 重排了文件:字段没变,指纹变了。 + let compact = "{\"form\":{\"title\":\"\",\"summary\":\"\",\"category\":\"\",\"coverPath\":\"\",\"screenshotPaths\":[]}}"; + fs::write(root.join(".export/taonier.json"), compact).unwrap(); + + let saved = save(&root, &TaonierExportForm::default(), &base.content_hash) + .expect("formatting-only change is absorbed"); + // 吸收的是「字段相同」这个现实:不覆写别人的排版,但指纹要跟着文件走。 + assert_eq!( + fs::read_to_string(root.join(".export/taonier.json")).unwrap(), + compact + ); + assert_eq!(saved.content_hash, crate::export::content_hash(compact)); + assert_ne!(saved.content_hash, base.content_hash); + } + + #[test] + fn refuses_to_overwrite_a_real_change() { + let root = fixture_project("conflict"); + let base = load::(&root).expect("load"); + let theirs = form("agent 写的"); + fs::write( + root.join(".export/taonier.json"), + serde_json::to_vec_pretty(&TaonierExportRegistry { + form: theirs.clone(), + }) + .unwrap(), + ) + .unwrap(); + + let error = save(&root, &form("用户写的"), &base.content_hash) + .expect_err("real change must not be overwritten"); + match error { + TaonierExportSaveError::SaveConflict(payload) => { + assert_eq!(payload.current, theirs); + } + other => panic!("expected saveConflict, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/script.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/script.rs new file mode 100644 index 000000000..819803f66 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/taonier/script.rs @@ -0,0 +1,157 @@ +//! 找声明 `build:taonier` 的 npm 包。 +//! +//! 判据只有一个:`package.json` 的 `scripts` 里有没有这个名字。**不落盘、不缓存**——「已适配」 +//! 就是此刻这个问题的答案,用户手删脚本或换机器之后必须立刻反映出来。候选目录与发布流程 +//! (`resolve_publish_build_cwd`)同口径:项目根,或模板工程常用的 `game/` 子工程。 + +use std::path::Path; + +use super::error::{TaonierBuildScriptMissing, TaonierExportBuildError}; +use super::layout::SCRIPT_NAME; + +/// npm 工作目录候选,按顺序取第一个声明了脚本的。 +pub(crate) const NPM_WORK_CANDIDATES: [&str; 2] = [".", "game"]; + +/// 命中的 npm 脚本。 +pub(crate) struct NpmScript { + /// 运行脚本时的工作目录(相对项目根)。 + pub(crate) cwd_relative: &'static str, + /// `package.json` 里声明的脚本文本。交给运行器做反漂移的 `expectedCommand`。 + pub(crate) command: String, + /// 相对项目根的 `package.json` 路径,报错与提示都用它。 + pub(crate) package_json_relative: String, +} + +/// 找到脚本就返回它,否则 `None`。读不了或不是合法 JSON 的 `package.json` 直接跳过,与发布 +/// 流程保持一致:这里不是校验工程结构的地方,找不到就是「还没适配」。 +pub(crate) fn find(root: &Path) -> Option { + for cwd in NPM_WORK_CANDIDATES { + let package_root = if cwd == "." { + root.to_path_buf() + } else { + match crate::project::resolve_local_project_path(root, cwd) { + Ok(path) => path, + Err(_) => continue, + } + }; + let package_path = package_root.join("package.json"); + let Ok(metadata) = std::fs::symlink_metadata(&package_path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&package_path) else { + continue; + }; + let Ok(package) = serde_json::from_str::(&content) else { + continue; + }; + let Some(command) = package + .get("scripts") + .and_then(|scripts| scripts.get(SCRIPT_NAME)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + return Some(NpmScript { + cwd_relative: cwd, + command: command.to_string(), + package_json_relative: package_json_relative(cwd), + }); + } + None +} + +/// 找不到脚本时的 typed 失败:把找过的位置一起给出去,用户才知道去哪看。 +pub(crate) fn missing_error() -> TaonierExportBuildError { + TaonierExportBuildError::BuildScriptMissing(TaonierBuildScriptMissing { + script: SCRIPT_NAME.to_string(), + searched: NPM_WORK_CANDIDATES + .iter() + .map(|cwd| package_json_relative(cwd)) + .collect(), + }) +} + +fn package_json_relative(cwd: &str) -> String { + if cwd == "." { + "package.json".to_string() + } else { + format!("{cwd}/package.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-taonier-script-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + #[test] + fn finds_the_script_in_the_root_package() { + let root = fixture_project("root"); + fs::write( + root.join("package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"node pack.mjs\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "."); + assert_eq!(script.command, "node pack.mjs"); + assert_eq!(script.package_json_relative, "package.json"); + } + + #[test] + fn finds_the_script_in_the_game_subproject() { + let root = fixture_project("game"); + fs::create_dir_all(root.join("game")).unwrap(); + fs::write( + root.join("game/package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"vite build\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "game"); + assert_eq!(script.package_json_relative, "game/package.json"); + } + + #[test] + fn ignores_other_scripts_and_broken_packages() { + let root = fixture_project("missing"); + fs::write( + root.join("package.json"), + "{\"scripts\":{\"build\":\"vite build\"}}", + ) + .unwrap(); + assert!(find(&root).is_none()); + + fs::write(root.join("package.json"), "{ not json").unwrap(); + assert!(find(&root).is_none()); + + let error = missing_error(); + match error { + TaonierExportBuildError::BuildScriptMissing(payload) => { + assert_eq!(payload.script, SCRIPT_NAME); + assert_eq!(payload.searched, vec!["package.json", "game/package.json"]); + } + other => panic!("expected buildScriptMissing, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs new file mode 100644 index 000000000..29ae466ab --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/build.rs @@ -0,0 +1,391 @@ +//! 跑用户项目自己的导出脚本。 +//! +//! 宿主不碰 vite、不碰 `pack.mjs`、不解析 zip:它只是以既有 `command.exec` 的边界(脚本白名单、 +//! 反漂移 `expectedCommand`、沙箱、超时、输出清洗)运行项目声明的那条脚本,然后确认产物真的在。 +//! 中间目录叫什么、脚本内部怎么调 vite,都是 agent 与用户的事。 +//! +//! 与表单无关:表单不是构建输入,所以这里不校验表单,也不看 icon。 + +use std::path::Path; + +use crate::project::{ + resolve_project_verification_spec_with_cwd_at, run_project_verification_spec_with_commit_at, +}; + +use super::commands::offload; +use super::dto::XHSMiniToolExportRunResult; +use super::error::{ + ArtifactMissing, CommandDenied, CommandFailed, ExportUnavailable, ProjectUnavailable, + XHSMiniToolExportBuildError, +}; +use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME}; +use super::script::{self, NpmScript}; + +/// 权限点位复用既有命令口径:这就是在跑项目命令,不新增 `GAME_CREATION_APP_COMMANDS` 条目。 +const PERMISSION_COMMAND_ID: &str = "command.exec"; +/// 与前端一致:构建可能真的要跑一遍 vite,给到 `project.verify` 的同一档上限(300 秒)。 +const TIMEOUT_SECONDS: u64 = 300; +/// 回给前端的输出尾部上限(字符)。失败时用户要看的是最后几行报错,不是整份构建日志。 +const OUTPUT_TAIL_MAX_CHARS: usize = 4_000; + +pub(crate) async fn run( + root: &Path, +) -> Result { + // 找脚本、读权限策略、清上一次产物都是同步文件 IO:与读/存命令同一口径挪到阻塞线程池, + // 不占 async worker。 + let root = root.to_path_buf(); + let script = offload({ + let root = root.clone(); + move || prepare(&root) + }) + .await?; + + // 项目侧预检(package.json / 脚本缺失或漂移 / 包管理器不支持等)单独跑:它失败时报 + // `projectUnavailable`(改项目代码可解),不能和沙箱、日志这类宿主故障一起收成 + // `exportUnavailable`,否则用户会被引导去重试一个改项目才能解决的问题。 + let spec = resolve_project_verification_spec_with_cwd_at( + &root, + SCRIPT_NAME, + &script.command, + TIMEOUT_SECONDS, + &script.cwd_relative, + ) + .map_err(|cause| { + XHSMiniToolExportBuildError::ProjectUnavailable(ProjectUnavailable::new(cause)) + })?; + + let result = run_project_verification_spec_with_commit_at( + &root, + spec, + // 导出不推进项目 revision,也不改任何正式状态:产物只是 `.export/` 下的一个文件。 + || Ok(()), + ) + .await + .map_err(ExportUnavailable::new)?; + + // `target_exec != "established"` 就是「脚本根本没跑起来」:沙箱建不起来(`not-attempted`)或目标 + // 可执行文件执行失败(`failed`)。这跟「脚本跑了但被信号杀掉」(`exit_code` 同样是 None,但 + // `target_exec` 是 `established`)不是一回事,所以判据用它,而不是 `exit_code.is_none()`。 + let script_launched = result.target_exec == "established"; + let exit_code = result.exit_code; + let timed_out = result.timed_out; + let output = result.output; + + // 收尾判产物同样是同步 IO(symlink_metadata),一样搬去阻塞线程池。 + offload(move || conclude(&root, exit_code, timed_out, script_launched, &output)).await +} + +/// 构建前置(同步):找到脚本、确认权限点位没被拒、清掉上一次的产物。 +/// +/// 产物判据只看「文件非空」,所以先清掉上一次的 zip:脚本这次什么都没写时,残留的旧 zip 会 +/// 让 no-op 脚本被判成功,用户上传的其实是上一轮的产物。删不掉(权限等)就 fail closed—— +/// 留着旧产物继续判成功,比这次构建失败更糟。 +fn prepare(root: &Path) -> Result { + let Some(script) = script::find(root) else { + return Err(script::missing_error()); + }; + // 用 typed 拒绝而不是 `enforce_project_permission_policy` 的字符串版本:那个版本把 + // 「策略拒绝」与「策略读不出来」拼成同一句人话,调用方就分不出「预期拒绝」与「宿主故障」了。 + crate::project::enforce_project_permission_policy_rejection(root, PERMISSION_COMMAND_ID) + .map_err(|rejection| match rejection { + crate::agent::tool::error::ProjectPermissionRejection::Denied { command_id } => { + XHSMiniToolExportBuildError::CommandDenied(CommandDenied { command_id }) + } + crate::agent::tool::error::ProjectPermissionRejection::PolicyUnavailable { cause } => { + XHSMiniToolExportBuildError::ExportUnavailable(ExportUnavailable::new(cause)) + } + })?; + + let artifact = artifact_path(root); + match std::fs::remove_file(&artifact) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(XHSMiniToolExportBuildError::ExportUnavailable( + ExportUnavailable::new(format!( + "清理上一次导出产物失败:{}:{error}", + artifact.display() + )), + )); + } + } + + Ok(script) +} + +/// 把宿主的构建结果收成导出结果:先判脚本有没有真的跑起来,再判成功,最后确认产物真的在。 +/// +/// 只吃四个字段而不是整份 `ProjectVerificationResult`:这条判据是导出链路里唯一"脚本跑完之后 +/// 算不算数"的规则,抽成纯函数才能脱离"真的跑一遍 npm"逐条测(未启动、非零退出、超时、产物缺失、 +/// 成功)。 +fn conclude( + root: &Path, + exit_code: Option, + timed_out: bool, + script_launched: bool, + output: &str, +) -> Result { + if !script_launched { + // 脚本根本没跑起来(沙箱建不起来 / 目标可执行文件执行失败):这是宿主侧事实故障, + // 不是项目代码的毛病。报 `exportUnavailable` 让前端按宿主故障处理(并抛进错误池上报), + // 而不是 `commandFailed` 去引导 code agent 改一份它改不动的宿主环境。 + let (output_tail, _) = tail(output); + return Err(XHSMiniToolExportBuildError::ExportUnavailable( + ExportUnavailable::new(output_tail), + )); + } + + if timed_out || exit_code != Some(0) { + let (output_tail, omitted_characters) = tail(output); + return Err(XHSMiniToolExportBuildError::CommandFailed(CommandFailed { + exit_code, + output_tail, + omitted_characters, + })); + } + + let path = artifact_path(root); + let produced = matches!( + std::fs::symlink_metadata(&path), + // `metadata` 会跟随符号链接:指向项目外某个非空文件的链接会被当成「产出成功」, + // 而下游下载路径又按 fail closed 拒收链接。这里用 symlink_metadata 与之一致。 + Ok(metadata) + if metadata.is_file() + && !metadata.file_type().is_symlink() + && metadata.len() > 0 + ); + if !produced { + // 脚本"成功"却没产出:多半是 agent 把 zip 写到了别处,或 pack.mjs 的参数没对齐。 + return Err(XHSMiniToolExportBuildError::ArtifactMissing( + ArtifactMissing { + path: artifact_relative_path(), + }, + )); + } + + // 成功也回输出尾部:`pack.mjs` 会把 zip 的条目数与体积打在这里,正是用户上传前要看的数字。 + let (output_tail, omitted_characters) = tail(output); + Ok(XHSMiniToolExportRunResult { + output_tail, + omitted_characters, + }) +} + +/// 只留尾部若干字符:整份构建日志塞进 IPC 没有意义,还会把界面拖住。 +/// +/// 返回(尾部原文, 省略了多少个字符)。省略量是**事实**,「省略前 N 个字符」那句话由前端拼—— +/// 宿主不预拼用户可见文案,否则前端没法按自己的现场排版,也没法判断这段尾巴是否完整。 +fn tail(output: &str) -> (String, usize) { + let trimmed = output.trim(); + let characters: Vec = trimmed.chars().collect(); + if characters.len() <= OUTPUT_TAIL_MAX_CHARS { + return (trimmed.to_string(), 0); + } + let skipped = characters.len() - OUTPUT_TAIL_MAX_CHARS; + let tail: String = characters[skipped..].iter().collect(); + (tail, skipped) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 每次都新建一个临时项目根:产物判据是文件系统事实,测试之间不能共享目录。 + fn fixture_root(label: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-build-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn write_artifact(root: &Path, bytes: &[u8]) { + let path = artifact_path(root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, bytes).unwrap(); + } + + #[test] + fn keeps_short_output_as_is_and_omits_nothing() { + assert_eq!(tail(" 构建失败\n"), ("构建失败".to_string(), 0)); + } + + #[test] + fn keeps_only_the_tail_of_long_output_and_reports_the_fact() { + let output: String = "前".repeat(OUTPUT_TAIL_MAX_CHARS) + "后"; + let (kept, omitted) = tail(&output); + // 载荷里只有原文与省略量两个事实:说明那句话由前端拼,宿主不写。 + assert!(kept.ends_with('后')); + assert!(!kept.contains("省略")); + assert_eq!(omitted, 1); + } + + #[test] + fn reports_a_missing_script_before_touching_the_project() { + let root = fixture_root("missing-script"); + + let error = tauri::async_runtime::block_on(run(&root)) + .expect_err("no package.json means no script"); + assert!(matches!( + error, + XHSMiniToolExportBuildError::BuildScriptMissing(_) + )); + } + + #[test] + fn a_non_zero_exit_keeps_the_code_and_the_output_tail() { + let root = fixture_root("non-zero"); + + let error = conclude(&root, Some(1), false, true, "vite 构建失败\n入口不存在") + .expect_err("non-zero exit must fail"); + let XHSMiniToolExportBuildError::CommandFailed(failure) = error else { + panic!("非零退出必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, Some(1)); + assert_eq!(failure.output_tail, "vite 构建失败\n入口不存在"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_timeout_still_reports_command_failed_without_an_exit_code() { + let root = fixture_root("timeout"); + + let error = conclude(&root, None, true, true, "已取消") + .expect_err("timeout must fail even with exit code 0"); + let XHSMiniToolExportBuildError::CommandFailed(failure) = error else { + panic!("超时必须映射成 commandFailed"); + }; + assert_eq!(failure.exit_code, None); + assert_eq!(failure.output_tail, "已取消"); + assert_eq!(failure.omitted_characters, 0); + } + + #[test] + fn a_successful_script_without_the_artifact_is_reported_as_missing() { + let root = fixture_root("no-artifact"); + + let error = + conclude(&root, Some(0), false, true, "构建完成").expect_err("产物缺失必须失败"); + let XHSMiniToolExportBuildError::ArtifactMissing(missing) = error else { + panic!("没有 zip 必须映射成 artifactMissing"); + }; + assert_eq!(missing.path, ".export/xhs-minitool.zip"); + } + + #[test] + fn an_empty_artifact_file_does_not_count() { + let root = fixture_root("empty-artifact"); + write_artifact(&root, b""); + + assert!(matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) + )); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_artifact_does_not_count() { + let root = fixture_root("symlink-artifact"); + let path = artifact_path(&root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let target = root.join("outside.zip"); + std::fs::write(&target, b"PK\x03\x04zip").unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + + assert!( + matches!( + conclude(&root, Some(0), false, true, "构建完成"), + Err(XHSMiniToolExportBuildError::ArtifactMissing(_)) + ), + "指向项目外非空文件的链接不能算产出成功" + ); + } + + #[test] + fn a_successful_script_with_a_non_empty_artifact_returns_the_output_tail() { + let root = fixture_root("ok"); + write_artifact(&root, b"PK\x03\x04zip"); + + let result = conclude( + &root, + Some(0), + false, + true, + "zip 已生成:.export/xhs-minitool.zip(3 个条目)", + ) + .expect("产物在就应当成功"); + assert!(result.output_tail.contains("3 个条目")); + assert_eq!(result.omitted_characters, 0); + } + + #[test] + fn a_script_that_never_launched_is_host_unavailable_not_command_failed() { + let root = fixture_root("not-launched"); + + // 沙箱建不起来或目标可执行文件执行失败:`exit_code` 同样是 None,但脚本根本没跑, + // 必须报宿主侧故障,不能让「让陶泥儿修」的按钮出现在一个它改不动的环境问题上。 + let error = conclude(&root, None, false, false, "命令沙箱不可用") + .expect_err("脚本没跑起来必须失败"); + let XHSMiniToolExportBuildError::ExportUnavailable(unavailable) = error else { + panic!("脚本未启动必须映射成 exportUnavailable"); + }; + assert_eq!(unavailable.cause, "命令沙箱不可用"); + } + + /// 真起进程跑一遍宿主链路:解析 npm 包、走 `command.exec` 边界执行项目脚本、再按产物判据收尾。 + /// + /// 默认 `#[ignore]`:它要本机 `node` / `npm` 与可用的命令沙箱,慢且依赖宿主环境,不进常规单测。 + /// 手动跑:`cargo test --bin genarrative-ai-game-creator-shell -- --ignored executes_the_project_script`。 + #[test] + #[ignore = "需要本机 node/npm 与可用命令沙箱,手动用 --ignored 运行"] + fn executes_the_project_script_and_requires_a_non_empty_artifact_each_time() { + const MARKER: &str = "打包完成:3 个条目"; + let root = fixture_root("real-run"); + std::fs::write( + root.join("package.json"), + format!( + "{{\"name\":\"xhs-real-run-fixture\",\"private\":true,\"scripts\":{{\"{SCRIPT_NAME}\":\"node make-artifact.mjs\"}}}}" + ), + ) + .unwrap(); + std::fs::write( + root.join("make-artifact.mjs"), + format!( + "import {{ mkdirSync, writeFileSync }} from 'node:fs';\n\ + mkdirSync('.export', {{ recursive: true }});\n\ + writeFileSync('.export/xhs-minitool.zip', Buffer.from('PK\\u0003\\u0004fixture'));\n\ + console.log('{MARKER}');\n" + ), + ) + .unwrap(); + + let first = tauri::async_runtime::block_on(run(&root)).expect("真实脚本应当跑通"); + assert!( + first.output_tail.contains(MARKER), + "输出尾部应当带回脚本的 stdout:{}", + first.output_tail + ); + + // 每一次导出都重新构建:删掉产物再点一次导出,必须是脚本重新产出的那一份。 + std::fs::remove_file(artifact_path(&root)).expect("删掉第一次的产物"); + tauri::async_runtime::block_on(run(&root)).expect("第二次导出也应当重新产出"); + assert!(artifact_path(&root).is_file(), "产物必须被重新写出来"); + + // 脚本"成功"却没产出:改成什么都不写,必须报 artifactMissing 而不是假成功。 + std::fs::write(root.join("make-artifact.mjs"), "console.log('nothing');\n").unwrap(); + std::fs::remove_file(artifact_path(&root)).unwrap(); + let error = + tauri::async_runtime::block_on(run(&root)).expect_err("脚本没产出 zip 时必须失败"); + assert!(matches!( + error, + XHSMiniToolExportBuildError::ArtifactMissing(_) + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs new file mode 100644 index 000000000..6bf86b987 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/commands.rs @@ -0,0 +1,248 @@ +//! 导出面板用到的四个 `#[tauri::command]`。 +//! +//! 每个命令只做一件事,失败表也各有一条([`super::error`])。没有项目写锁、不推进 revision:宿主 +//! 在这里读写的,就是 `.export/` 下那一个注册表文件。 +//! +//! 「内容」与「注册表指纹」是两条命令:[`read_xhs_minitool_export`] 是面板自动刷新反复调的 +//! 那条,拿不到指纹;指纹(写回时的 `baseHash`)只在进编辑会话、或内容真的被采纳时单独取一次。 +//! 分开是为了让刷新动作在类型上就动不了写回基线——否则外部改动会被静默吞掉。 +//! +//! 入参在 JS 侧是 camelCase(Tauri 的既有口径):`projectPath`、`baseHash`。 +//! +//! 命令边界各记一条 `app_log!`(走在 `project/*` 模块同样的口径上):面板卡在「正在读取」时, +//! AppData 里的 `export.<命令>.failed` 那一行就是答案。读命令每 2 秒重试一次,所以**同一个失败只 +//! 记一次**,变了或中间成功过一次才再记——否则日志会被同一句刷满。 + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; + +use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRunResult, XHSMiniToolExportState}; +use super::error::{ + ExportUnavailable, XHSMiniToolExportBuildError, XHSMiniToolExportReadError, + XHSMiniToolExportSaveError, +}; +use super::registry::{self, RegistrySnapshot}; +use super::{build, script}; + +/// 读内容:注册表(缺则补一份空表单)+ 脚本是否存在。自动刷新反复调的就是这一条。 +#[tauri::command] +pub(crate) async fn read_xhs_minitool_export( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content(&project_path) + }) + .await; + report("read", &project_path, &result); + result +} + +/// 读注册表指纹:写回时原样带回做冲突判据。 +/// +/// 单独一条命令,且**只在内容被采纳时**取:让「刷新」这条高频路径在类型上碰不到写回基线。 +#[tauri::command] +pub(crate) async fn read_xhs_minitool_export_hash( + project_path: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + move || read_content_hash(&project_path) + }) + .await; + report("readHash", &project_path, &result); + result +} + +/// 保存表单:只按 `base_hash` 判冲突,成功后回新的指纹。 +/// +/// **不校验表单内容**——必填与字数上限由界面在提交前挡住(用户最终面对的是界面,这份表单也只是 +/// 一份草稿),宿主只负责把它原样写进注册表。`iconPath` 这类路径事实在真正读它的那条路(预览、 +/// 下载)由统一的路径门禁复核,不在这里预支。 +#[tauri::command] +pub(crate) async fn save_xhs_minitool_export_form( + project_path: String, + form: XHSMiniToolExportForm, + base_hash: String, +) -> Result { + let result = offload({ + let project_path = project_path.clone(); + let form = form.clone(); + let base_hash = base_hash.clone(); + move || save_form(&project_path, &form, &base_hash) + }) + .await; + report("save", &project_path, &result); + if let Ok(hash) = &result { + app_log!( + "export.save.ok project={project_path} nameChars={} introductionChars={} iconPath={} baseHash={} contentHash={}", + form.name.trim().chars().count(), + form.introduction.trim().chars().count(), + form.icon_path.trim(), + short(&base_hash), + short(hash), + ); + } + result +} + +/// 跑一次导出:每次都重新执行项目脚本、重新产出 zip。**不校验表单**——表单不是构建输入。 +#[tauri::command] +pub(crate) async fn run_xhs_minitool_export_build( + project_path: String, +) -> Result { + app_log!("export.build.start project={project_path}"); + let result = run_build(&project_path).await; + match &result { + Ok(run) => { + clear_failure("build", &project_path); + app_log!( + "export.build.ok project={project_path} outputChars={} omittedCharacters={}", + run.output_tail.chars().count(), + run.omitted_characters, + ); + } + Err(error) => log_failure("build", &project_path, error), + } + result +} + +/// `hasScript` 永远现算:注册表里不存、也不缓存这个事实。 +fn content(root: &Path, snapshot: RegistrySnapshot) -> XHSMiniToolExportState { + XHSMiniToolExportState { + form: snapshot.form, + has_script: script::find(root).is_some(), + } +} + +/// 项目根校验对三条命令都是同一种宿主故障:各命令用 `From` 自己收口。 +fn project_root(project_path: &str) -> Result { + let root = PathBuf::from(project_path); + crate::project::validate_project_root(&root).map_err(ExportUnavailable::new)?; + Ok(root) +} + +/// 把小块同步 IO 挪到 Tokio 的阻塞线程池。 +/// +/// 读命令每 2 秒被轮询一次,注册表 + `package.json` 探测虽然小,但直接在 `async fn` 里做同步 +/// 文件 IO 会占着 worker 线程,拖慢同一 runtime 上的其它异步任务(构建子进程、SpacetimeDB +/// 客户端)。任务 panic 时 `JoinError` 收口成 `ExportUnavailable`,走各命令既有的失败表。 +/// +/// 构建命令(`build::run`)的前后置同步 IO 也复用这里,所以对同级的 `build` 模块可见。 +pub(super) async fn offload(job: F) -> Result +where + T: Send + 'static, + E: From + Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + match tokio::task::spawn_blocking(job).await { + Ok(result) => result, + Err(error) => Err(E::from(ExportUnavailable::new(format!( + "后台执行导出读写任务失败:{error}" + )))), + } +} + +fn read_content(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(content( + &root, + registry::load::(&root)?, + )) +} + +fn read_content_hash(project_path: &str) -> Result { + let root = project_root(project_path)?; + Ok(registry::load::(&root)?.content_hash) +} + +fn save_form( + project_path: &str, + form: &XHSMiniToolExportForm, + base_hash: &str, +) -> Result { + let root = project_root(project_path)?; + Ok(registry::save(&root, form, base_hash)?.content_hash) +} + +async fn run_build( + project_path: &str, +) -> Result { + let root = project_root(project_path)?; + build::run(&root).await +} + +/// 上一次记过的失败(按命令名 + 项目路径)。面板每 2 秒重读,同一个失败会反复落到同一条命令上。 +/// +/// 去重键必须带上项目:`registryMalformed` 之类的现场文案不含文件路径,两个项目可能给出同一句 +/// `Debug`,只按命令名去重会把项目 B 的失败当成项目 A 的重复现场静默吞掉。 +static LAST_FAILURE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// 命令边界的统一收口:成功清掉去重记录,失败记一条。 +fn report(command: &'static str, project_path: &str, result: &Result) { + match result { + Ok(_) => clear_failure(command, project_path), + Err(error) => log_failure(command, project_path, error), + } +} + +/// 一条失败日志最多记多少个字符。 +/// +/// `CommandFailed` 的 `Debug` 里带着最多 4,000 字符的脚本输出尾部,原样落进 AppData 日志会把一次 +/// 失败撑成好几 KB,去重表也跟着留一份长字符串。日志只需要认得出「哪个变体、哪条命令失败」, +/// 完整现场已经结构化回给前端了。 +const MAX_LOGGED_FAILURE_CHARS: usize = 512; + +fn log_failure(command: &'static str, project_path: &str, error: &impl std::fmt::Debug) { + let message = bounded_for_log(&format!("{error:?}"), MAX_LOGGED_FAILURE_CHARS); + let key = (command, project_path.trim().to_string()); + if let Ok(mut last) = LAST_FAILURE.lock() { + if last.get(&key).is_some_and(|seen| *seen == message) { + return; + } + last.insert(key, message.clone()); + } + app_log!("export.{command}.failed project={project_path}: {message}"); +} + +/// 按**字符**(不是字节)截断日志文本,避免切坏 UTF-8;发生截断时补一个省略号说明。 +fn bounded_for_log(message: &str, max_chars: usize) -> String { + if message.chars().count() <= max_chars { + return message.to_string(); + } + let head: String = message.chars().take(max_chars).collect(); + format!("{head}…") +} + +fn clear_failure(command: &'static str, project_path: &str) { + if let Ok(mut last) = LAST_FAILURE.lock() { + last.remove(&(command, project_path.trim().to_string())); + } +} + +/// 日志里的指纹只留前 8 位:长度一样能看出「换了没有」,又不用把整串抄进日志。 +fn short(hash: &str) -> &str { + let end = hash.char_indices().nth(8).map_or(hash.len(), |(at, _)| at); + &hash[..end] +} + +#[cfg(test)] +mod tests { + use super::{bounded_for_log, short}; + + #[test] + fn short_keeps_first_eight_characters() { + assert_eq!(short("0123456789abcdef"), "01234567"); + assert_eq!(short("0123"), "0123"); + assert_eq!(short(""), ""); + } + + #[test] + fn bounded_for_log_keeps_short_text_and_truncates_on_char_boundaries() { + assert_eq!(bounded_for_log("构建失败", 8), "构建失败"); + // 按字符截断:4 个中文字符不会被切坏,末尾补省略号。 + assert_eq!(bounded_for_log("一二三四五六", 4), "一二三四…"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs new file mode 100644 index 000000000..f9bb9da18 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/dto.rs @@ -0,0 +1,75 @@ +//! 小红书小工具导出的线上形状(Rust → TS 由 ts-rs 生成,生成物不手改)。 + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// 平台上传表单。**不是构建输入**:zip 规范里没有任何 app 元数据,这三个值的唯一用途是让用户 +/// 复制粘贴到小红书平台的上传页。 +/// +/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能 +/// 被静默忽略后带着半份表单继续走。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportForm { + pub(crate) name: String, + pub(crate) introduction: String, + /// 项目内相对路径。icon 文件与目标无关,多个导出目标可以共用同一个。 + pub(crate) icon_path: String, +} + +impl XHSMiniToolExportForm { + /// 落盘形态:三个字段都去掉首尾空白。 + /// + /// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白, + /// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。 + pub(crate) fn normalized(&self) -> Self { + Self { + name: self.name.trim().to_string(), + introduction: self.introduction.trim().to_string(), + icon_path: self.icon_path.trim().to_string(), + } + } +} + +/// 注册表文件形状:`.export/xhs-minitool.json`。 +/// +/// 只有 `form` 一个键——没有 status、没有已适配标记、没有 revision,那些要么是 `package.json` +/// 的现算结果,要么由内容指纹承担。外层留一层对象是为了以后加目标级键而不动表单形状。 +/// +/// 这个形状也进 agent 的指令正文(前端拿 ts-rs 生成的类型拼一份「值的位置写要求」的示意),所以它 +/// 必须导出绑定:Rust 侧加键、改名,前端那份示意会在 `typecheck` 里当场对不上。 +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportRegistry { + pub(crate) form: XHSMiniToolExportForm, +} + +/// 面板读到的现场内容。 +/// +/// 没有「产物是否已存在」:每次导出都重新构建。也没有 status / 已适配标记——「有没有脚本」 +/// 永远从 npm 包的 `package.json` 现算,不落盘。也没有注册表指纹:那是写回时的同步细节, +/// 由 `read_xhs_minitool_export_hash` 单独给,自动刷新读的这条命令碰不到它。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportState { + pub(crate) form: XHSMiniToolExportForm, + /// `scripts["build:xhs-minitool"]` 是否存在。 + pub(crate) has_script: bool, +} + +/// 一次成功导出的结果。产物路径是常量(`.export/xhs-minitool.zip`),不进 DTO。 +/// +/// 不带 `exit_code`:走到这里的出口码必然是 0,带回来只是一个恒为 0 的死字段;失败侧 +/// (`CommandFailed`)自己带退出码。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct XHSMiniToolExportRunResult { + /// 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。 + pub(crate) output_tail: String, + /// 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。 + pub(crate) omitted_characters: usize, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs new file mode 100644 index 000000000..d168b0aa1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/error.rs @@ -0,0 +1,211 @@ +//! 命令边界的 typed 失败。 +//! +//! **一条命令一张失败表**,不合并成一张大枚举:调用方拿到的 `Result` 本身就限定了「这条命令 +//! 可能怎么失败」,JS 侧也因此不用按命令再挑一遍变体。 +//! +//! - [`XHSMiniToolExportReadError`]:读注册表(`read_xhs_minitool_export` / +//! `read_xhs_minitool_export_hash`); +//! - [`XHSMiniToolExportSaveError`]:写表单(`save_xhs_minitool_export_form`,自动保存与「打包」 +//! 前的 flush 走同一条)。`saveConflict` 只出现在这里——它是「这份输入还没落盘」的结果,不是 +//! 构建失败。表单内容的必填与字数由界面在提交前校验,宿主这一层不做业务校验,所以这张表里没有 +//! 「字段不合法」这一类; +//! - [`XHSMiniToolExportBuildError`]:跑构建(`run_xhs_minitool_export_build`)。构建不是表单的 +//! 消费者,所以这张表里没有表单类变体。 +//! +//! 变体名就是线上的分流键(`type`),前端只按它选通道与话术,**不解析任何文案**。每条命令都是 +//! 首个错误即返回,不留问题数组——表单一次只报一个字段,用户改完再报下一个。 +//! +//! 形状沿用仓库既有约定:带载荷的变体是 newtype 变体持有具名结构体,ts-rs 才能生成可判别的 +//! TS 联合类型。 + +use serde::Serialize; +use ts_rs::TS; + +use super::dto::XHSMiniToolExportForm; + +/// 读注册表可能怎么失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportReadError { + /// 注册表存在但不是本目标期望的形状。fail closed:不猜测、不修补,引导重新适配。 + RegistryMalformed(RegistryMalformed), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +/// 保存表单可能怎么失败。自动保存与「打包」前的 flush 共用这一张表。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportSaveError { + /// 注册表存在但不是本目标期望的形状。 + RegistryMalformed(RegistryMalformed), + /// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。 + SaveConflict(SaveConflict), + /// 项目根不可用或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +/// 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) enum XHSMiniToolExportBuildError { + /// npm 包里没有 `build:xhs-minitool`;这就是「还没适配」的唯一现算判据。 + BuildScriptMissing(BuildScriptMissing), + /// 项目权限策略拒绝执行 `command.exec`:用户/项目策略说了不让跑,不是缺陷。 + CommandDenied(CommandDenied), + /// 脚本跑完没有正常收场(非零退出或超时)。 + CommandFailed(CommandFailed), + /// 脚本正常收场但产物不在。 + ArtifactMissing(ArtifactMissing), + /// 项目侧预检没过(package.json / 脚本缺失或漂移 / 包管理器不支持等):改项目代码可解, + /// 与宿主环境跑不起来区分开。 + ProjectUnavailable(ProjectUnavailable), + /// 脚本无法启动或其它宿主侧事实故障。 + ExportUnavailable(ExportUnavailable), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct RegistryMalformed { + /// 解析器原文。它是引导语,不是给前端做判断的依据。 + pub(crate) cause: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct BuildScriptMissing { + /// 期望的脚本名(`build:xhs-minitool`)。 + pub(crate) script: String, + /// 宿主找过哪些 `package.json`,让用户知道去哪看。 + pub(crate) searched: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct SaveConflict { + /// 文件里现在是什么(字段级二选一的「文件里的」那一侧)。 + pub(crate) current: XHSMiniToolExportForm, + /// 冲突时文件的指纹;用户选完之后带着它重试保存。 + pub(crate) content_hash: String, +} + +/// 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障 +/// (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct CommandDenied { + /// 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。 + pub(crate) command_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct CommandFailed { + pub(crate) exit_code: Option, + /// 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。 + pub(crate) output_tail: String, + /// 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。 + pub(crate) omitted_characters: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct ArtifactMissing { + /// 产物相对项目根的路径。 + pub(crate) path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct ExportUnavailable { + pub(crate) cause: String, +} + +/// 项目侧预检失败的原文:缺哪个脚本、漂移到什么值、检测到哪种包管理器等。 +/// +/// 它和 [`ExportUnavailable`] 分开,因为「要改的东西在不在项目代码里」是前端分流的唯一判据: +/// 这里的 `cause` 只是引导 agent 的现场原文,前端不解析它。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))] +pub(crate) struct ProjectUnavailable { + pub(crate) cause: String, +} + +impl ProjectUnavailable { + pub(crate) fn new(cause: impl Into) -> Self { + Self { + cause: cause.into(), + } + } +} + +impl ExportUnavailable { + pub(crate) fn new(cause: impl Into) -> Self { + Self { + cause: cause.into(), + } + } +} + +/// 三条命令都要能把「项目根不可用 / 脚本起不来」装进自己的表里。 +impl From for XHSMiniToolExportReadError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for XHSMiniToolExportSaveError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +impl From for XHSMiniToolExportBuildError { + fn from(payload: ExportUnavailable) -> Self { + Self::ExportUnavailable(payload) + } +} + +/// 注册表层的失败折叠成各命令自己的表:读不出来的形状是 `registryMalformed`,其余是宿主故障。 +macro_rules! impl_from_registry_error { + ($error:ty) => { + impl From for $error { + fn from(error: crate::export::RegistryError) -> Self { + match error { + crate::export::RegistryError::Malformed { cause, .. } => { + Self::RegistryMalformed(RegistryMalformed { + cause: cause.to_string(), + }) + } + other => Self::ExportUnavailable(ExportUnavailable::new(other.to_string())), + } + } + } + }; +} + +impl_from_registry_error!(XHSMiniToolExportReadError); +impl_from_registry_error!(XHSMiniToolExportSaveError); diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs new file mode 100644 index 000000000..f78c6f691 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/layout.rs @@ -0,0 +1,92 @@ +//! 小红书小工具在 `.export/` 下的固定名字与路径。 +//! +//! 这些名字同时出现在三处:宿主、agent 写的 npm 脚本、用户手动上传的文件名。所以它们是 +//! **契约常量**,只在这里定义一次,改名字要三处一起改。 + +use std::path::{Path, PathBuf}; + +use crate::export::EXPORT_WORK_RELATIVE_DIR; + +/// npm 脚本名。冒号形式不是审美选择:既有 `command.exec` 白名单只放行 `build` 与 `build:<后缀>`。 +pub(crate) const SCRIPT_NAME: &str = "build:xhs-minitool"; + +/// 注册表文件名(`.export/` 下)。 +pub(crate) const REGISTRY_FILE_NAME: &str = "xhs-minitool.json"; + +/// 交付产物文件名(`.export/` 下),人工上传用。 +pub(crate) const ARTIFACT_FILE_NAME: &str = "xhs-minitool.zip"; + +pub(crate) fn export_dir(root: &Path) -> PathBuf { + root.join(EXPORT_WORK_RELATIVE_DIR) +} + +pub(crate) fn registry_path(root: &Path) -> PathBuf { + export_dir(root).join(REGISTRY_FILE_NAME) +} + +pub(crate) fn artifact_path(root: &Path) -> PathBuf { + export_dir(root).join(ARTIFACT_FILE_NAME) +} + +/// 产物相对项目根的路径。前端下载与 prompt 用的都是它。 +pub(crate) fn artifact_relative_path() -> String { + format!("{EXPORT_WORK_RELATIVE_DIR}/{ARTIFACT_FILE_NAME}") +} + +#[cfg(test)] +mod tests { + use std::path::Component; + + use super::*; + + /// 前端拿的是**字符串**相对路径(下载命令与 prompt 都用它),宿主写的是 `PathBuf`。两条路各自 + /// 拼一遍,必须落在同一个文件上——谁只改了一边,这条先红。 + #[test] + fn the_relative_path_names_the_same_file_the_host_writes() { + let root = Path::new("/tmp/project"); + assert_eq!(root.join(artifact_relative_path()), artifact_path(root)); + assert_eq!( + artifact_path(root) + .file_name() + .and_then(|name| name.to_str()), + Some(ARTIFACT_FILE_NAME) + ); + } + + /// 这个字符串会交给宿主的路径门禁(下载命令复核项目内相对路径)并写进 prompt:它必须是 + /// **项目内、单层、不带 `..`** 的相对路径。前端与 agent 都按它找文件,跑出 `.export/` 就是越界。 + #[test] + fn the_relative_path_cannot_walk_out_of_the_export_directory() { + let relative_path = artifact_relative_path(); + let relative = Path::new(&relative_path); + assert!(relative.is_relative()); + assert!(relative + .components() + .all(|component| matches!(component, Component::Normal(_)))); + // flat 布局:导出目录**直接**下的一个文件名,不为目标建子目录。 + assert_eq!(relative.parent(), Some(Path::new(EXPORT_WORK_RELATIVE_DIR))); + // 跨平台:它是给前端与 prompt 的字符串,Windows 上不许漏出 `\`。 + assert!(!relative_path.contains('\\')); + } + + /// flat 是刻意的决定:注册表与产物共用一个**项目根锚定**的目录,多目标也只是同目录不同文件名。 + /// 谁加了一层 per-target 子目录,这条就红。 + #[test] + fn every_file_of_this_target_shares_one_flat_directory() { + let root = Path::new("/tmp/project"); + assert_eq!(registry_path(root).parent(), artifact_path(root).parent()); + assert_eq!(export_dir(root).parent(), Some(root)); + assert_eq!( + export_dir(root).file_name().and_then(|name| name.to_str()), + Some(EXPORT_WORK_RELATIVE_DIR) + ); + } + + /// 脚本名不是自由文本:`command.exec` 的白名单只放行 `build` 与 `build:<安全后缀>`。 + /// 尤其不能退化成裸 `build`——那会跑掉项目自己的构建,产物永远不会出现。 + #[test] + fn the_script_name_keeps_the_form_the_command_allowlist_requires() { + assert_ne!(SCRIPT_NAME, "build"); + assert!(SCRIPT_NAME.starts_with("build:")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs new file mode 100644 index 000000000..3c58814d4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/mod.rs @@ -0,0 +1,21 @@ +//! 小红书小工具导出目标。 +//! +//! 文件都堆在项目根的 `.export/`(见 [`crate::export::EXPORT_WORK_RELATIVE_DIR`]): +//! `xhs-minitool.json` 是注册表,`xhs-minitool.zip` 是交付给人工上传的产物,`xhs-minitool.*.mjs` +//! 是 agent 首次适配时从 `vite-export-xhs-minitool` skill 显式拷进来的脚本(允许它改)。共享 +//! icon 放 `.export/` 根,注册表只存项目内相对路径,多个目标可以指同一个文件。 +//! +//! 分层:`dto` 是线上形状,`error` 是命令边界失败表,`layout` 是契约常量,`registry` 管注册表, +//! `script` 管 npm 脚本发现,`build` 跑脚本并确认产物,`commands` 是唯一对外的命令面。 +//! +//! 表单**不在宿主侧做业务校验**:命令只把用户那份草稿原样读出来、写回去,必填与字数由界面在 +//! 提交前挡住。宿主要挡的只有「这份注册表文件写得对不对」这类 IO 事实(见 `registry`)。 + +pub mod commands; + +mod build; +mod dto; +mod error; +mod layout; +mod registry; +mod script; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs new file mode 100644 index 000000000..d5ed12aa5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/registry.rs @@ -0,0 +1,179 @@ +//! 小红书注册表的读取与保存。 +//! +//! 文件级细节(自动创建、严格解析、原子写入、内容指纹)都在 [`crate::export::registry`];这里 +//! 只管本目标的事:注册表 ↔ 表单的映射,以及冲突判定。 +//! +//! 已知取舍(评审提出):这里用共享的 `load_or_create` / `save`,**不**额外检查注册表文件与 +//! `.export` 目录是不是符号链接。也就是说,如果 `.export/xhs-minitool.json` 被换成链接,读写会 +//! 落到项目外。 +//! +//! **决定不改,理由**:要挡的是「把项目内容交给打包链」的入口——产物(`build::conclude`)、脚本 +//! (`script::find`)和图标在预览 / 下载时走的路径门禁都拒链,因为它们把项目内容送出项目边界。 +//! 注册表读写的是用户自己项目里的一份草稿,用户既然能在磁盘上把它换成链接,就按「项目文件由用户 +//! 掌控」接受这个重定向;为它单独加守卫会改掉共享读写的契约,收益与成本不成比例。若以后注册表要 +//! 承载跨项目共享的内容,再按与那三处同口径补 `symlink_metadata` 检查。 + +use std::path::Path; + +use crate::export::{load_or_create, save as write_registry}; + +use super::dto::{XHSMiniToolExportForm, XHSMiniToolExportRegistry}; +use super::error::{SaveConflict, XHSMiniToolExportSaveError}; +use super::layout::registry_path; + +/// 注册表现场:表单,加上**这份内容**的指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegistrySnapshot { + pub(crate) form: XHSMiniToolExportForm, + pub(crate) content_hash: String, +} + +/// 读注册表;文件不存在时补一份全空表单。解析失败原样上抛成 `registryMalformed`。 +/// +/// 返回的错误类型由调用方定:读命令与保存命令各有一张失败表,两边都实现了 +/// `From`,所以这里泛型化,不替调用方选表。 +pub(crate) fn load(root: &Path) -> Result +where + E: From, +{ + let loaded = load_or_create(®istry_path(root), &XHSMiniToolExportRegistry::default())?; + Ok(RegistrySnapshot { + form: loaded.value.form, + content_hash: loaded.content_hash, + }) +} + +/// 保存表单。 +/// +/// `base_hash` 是调用方读到的指纹。文件没被换过就直接写;被换过但字段完全一致(只剩格式差异) +/// 就静默吸收、不写盘;字段真的不同则回 `saveConflict`,把文件里的值交给用户逐字段挑——**绝不** +/// 覆盖用户或 agent 的输入。 +pub(crate) fn save( + root: &Path, + form: &XHSMiniToolExportForm, + base_hash: &str, +) -> Result { + let path = registry_path(root); + // 读 `load_or_create` 与下面的 `write_registry` 之间没有锁,两步不是原子的:并发写(用户保存 + // 撞上 code agent 改同一个 `.export/xhs-minitool.json`)存在「后写覆盖先写」的窗口。 + // + // 这里明确接受这个窗口,不加 compare-and-swap:保存一律由用户手动触发(点保存 / 停手后的 + // 自动落盘 / 点打包前的 flush),用户本来就知道 code agent 在同一项目里改文件;真撞上时重新 + // 读一次注册表就能看到最新内容,不需要写入门禁级别的并发保证。把 `base_hash` 比较下沉进共享的 + // `crate::export::save`(原子重读 + 比较 + rename)会改掉所有导出目标的写契约,成本高于收益。 + let current = load_or_create(&path, &XHSMiniToolExportRegistry::default())?; + let form = form.normalized(); + if !current.created && current.content_hash != base_hash { + let current_form = current.value.form.normalized(); + if current_form != form { + return Err(XHSMiniToolExportSaveError::SaveConflict(SaveConflict { + current: current_form, + content_hash: current.content_hash, + })); + } + return Ok(RegistrySnapshot { + form: current_form, + content_hash: current.content_hash, + }); + } + let content_hash = write_registry(&path, &XHSMiniToolExportRegistry { form: form.clone() })?; + Ok(RegistrySnapshot { form, content_hash }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + fn form(name: &str) -> XHSMiniToolExportForm { + XHSMiniToolExportForm { + name: name.to_string(), + introduction: "简介".to_string(), + icon_path: "assets/icon.png".to_string(), + } + } + + #[test] + fn creates_an_empty_registry_on_first_read() { + let root = fixture_project("create"); + + let snapshot = load::(&root).expect("load"); + assert_eq!(snapshot.form, XHSMiniToolExportForm::default()); + assert!( + root.join(".export/xhs-minitool.json").is_file(), + "读取必须顺手补出空注册表" + ); + } + + #[test] + fn saves_and_normalizes_whitespace() { + let root = fixture_project("save"); + let base = load::(&root).expect("load"); + + let mut value = form(" 小工具 "); + value.introduction = " 一句话简介 ".to_string(); + let saved = save(&root, &value, &base.content_hash).expect("save"); + assert_eq!(saved.form.name, "小工具"); + assert_eq!(saved.form.introduction, "一句话简介"); + + let reread = load::(&root).expect("reload"); + assert_eq!(reread.form, saved.form); + assert_eq!(reread.content_hash, saved.content_hash); + } + + #[test] + fn absorbs_formatting_only_changes_without_rewriting() { + let root = fixture_project("format"); + let base = load::(&root).expect("load"); + // agent 重排了文件:字段没变,指纹变了。 + let compact = "{\"form\":{\"name\":\"\",\"introduction\":\"\",\"iconPath\":\"\"}}"; + fs::write(root.join(".export/xhs-minitool.json"), compact).unwrap(); + + let saved = save(&root, &XHSMiniToolExportForm::default(), &base.content_hash) + .expect("formatting-only change is absorbed"); + // 吸收的是「字段相同」这个现实:不覆写别人的排版,但指纹要跟着文件走。 + assert_eq!( + fs::read_to_string(root.join(".export/xhs-minitool.json")).unwrap(), + compact + ); + assert_eq!(saved.content_hash, crate::export::content_hash(compact)); + assert_ne!(saved.content_hash, base.content_hash); + } + + #[test] + fn refuses_to_overwrite_a_real_change() { + let root = fixture_project("conflict"); + let base = load::(&root).expect("load"); + let theirs = form("agent 写的"); + fs::write( + root.join(".export/xhs-minitool.json"), + serde_json::to_vec_pretty(&XHSMiniToolExportRegistry { + form: theirs.clone(), + }) + .unwrap(), + ) + .unwrap(); + + let error = save(&root, &form("用户写的"), &base.content_hash) + .expect_err("real change must not be overwritten"); + match error { + XHSMiniToolExportSaveError::SaveConflict(payload) => { + assert_eq!(payload.current, theirs); + } + other => panic!("expected saveConflict, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs new file mode 100644 index 000000000..c1f0fd81c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/draft/xhs_minitool/script.rs @@ -0,0 +1,157 @@ +//! 找声明 `build:xhs-minitool` 的 npm 包。 +//! +//! 判据只有一个:`package.json` 的 `scripts` 里有没有这个名字。**不落盘、不缓存**——「已适配」 +//! 就是此刻这个问题的答案,用户手删脚本或换机器之后必须立刻反映出来。候选目录与发布流程 +//! (`resolve_publish_build_cwd`)同口径:项目根,或模板工程常用的 `game/` 子工程。 + +use std::path::Path; + +use super::error::{BuildScriptMissing, XHSMiniToolExportBuildError}; +use super::layout::SCRIPT_NAME; + +/// npm 工作目录候选,按顺序取第一个声明了脚本的。 +pub(crate) const NPM_WORK_CANDIDATES: [&str; 2] = [".", "game"]; + +/// 命中的 npm 脚本。 +pub(crate) struct NpmScript { + /// 运行脚本时的工作目录(相对项目根)。 + pub(crate) cwd_relative: &'static str, + /// `package.json` 里声明的脚本文本。交给运行器做反漂移的 `expectedCommand`。 + pub(crate) command: String, + /// 相对项目根的 `package.json` 路径,报错与提示都用它。 + pub(crate) package_json_relative: String, +} + +/// 找到脚本就返回它,否则 `None`。读不了或不是合法 JSON 的 `package.json` 直接跳过,与发布 +/// 流程保持一致:这里不是校验工程结构的地方,找不到就是「还没适配」。 +pub(crate) fn find(root: &Path) -> Option { + for cwd in NPM_WORK_CANDIDATES { + let package_root = if cwd == "." { + root.to_path_buf() + } else { + match crate::project::resolve_local_project_path(root, cwd) { + Ok(path) => path, + Err(_) => continue, + } + }; + let package_path = package_root.join("package.json"); + let Ok(metadata) = std::fs::symlink_metadata(&package_path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&package_path) else { + continue; + }; + let Ok(package) = serde_json::from_str::(&content) else { + continue; + }; + let Some(command) = package + .get("scripts") + .and_then(|scripts| scripts.get(SCRIPT_NAME)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + return Some(NpmScript { + cwd_relative: cwd, + command: command.to_string(), + package_json_relative: package_json_relative(cwd), + }); + } + None +} + +/// 找不到脚本时的 typed 失败:把找过的位置一起给出去,用户才知道去哪看。 +pub(crate) fn missing_error() -> XHSMiniToolExportBuildError { + XHSMiniToolExportBuildError::BuildScriptMissing(BuildScriptMissing { + script: SCRIPT_NAME.to_string(), + searched: NPM_WORK_CANDIDATES + .iter() + .map(|cwd| package_json_relative(cwd)) + .collect(), + }) +} + +fn package_json_relative(cwd: &str) -> String { + if cwd == "." { + "package.json".to_string() + } else { + format!("{cwd}/package.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn fixture_project(prefix: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "genarrative-xhs-script-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create fixture project"); + root + } + + #[test] + fn finds_the_script_in_the_root_package() { + let root = fixture_project("root"); + fs::write( + root.join("package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"node pack.mjs\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "."); + assert_eq!(script.command, "node pack.mjs"); + assert_eq!(script.package_json_relative, "package.json"); + } + + #[test] + fn finds_the_script_in_the_game_subproject() { + let root = fixture_project("game"); + fs::create_dir_all(root.join("game")).unwrap(); + fs::write( + root.join("game/package.json"), + format!("{{\"scripts\":{{\"{SCRIPT_NAME}\":\"vite build\"}}}}"), + ) + .unwrap(); + + let script = find(&root).expect("script found"); + assert_eq!(script.cwd_relative, "game"); + assert_eq!(script.package_json_relative, "game/package.json"); + } + + #[test] + fn ignores_other_scripts_and_broken_packages() { + let root = fixture_project("missing"); + fs::write( + root.join("package.json"), + "{\"scripts\":{\"build\":\"vite build\"}}", + ) + .unwrap(); + assert!(find(&root).is_none()); + + fs::write(root.join("package.json"), "{ not json").unwrap(); + assert!(find(&root).is_none()); + + let error = missing_error(); + match error { + XHSMiniToolExportBuildError::BuildScriptMissing(payload) => { + assert_eq!(payload.script, SCRIPT_NAME); + assert_eq!(payload.searched, vec!["package.json", "game/package.json"]); + } + other => panic!("expected buildScriptMissing, got {other:?}"), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs new file mode 100644 index 000000000..9c02c65b2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/mod.rs @@ -0,0 +1,20 @@ +//! 导出产物:把用户项目导出成第三方平台的制品。 +//! +//! 边界见 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的 +//! 2026-10-05 条目。一句话:宿主只做四件确定性的事——读写项目内 `.export/` 注册表、校验表单 +//! 与构建脚本是否存在、以既有 `command.exec` 边界运行项目自己的 npm 脚本、把产物交给用户。 +//! 适配与打包全部由 code agent 首次实验出来并固化成用户项目里的脚本;宿主不复刻平台规范、 +//! 不解析 zip 结构,也不替用户跑 `pack.mjs`。 + +pub mod draft; + +mod registry; + +pub(crate) use registry::{content_hash, load_or_create, save, Loaded, RegistryError}; + +/// 导出工作目录:项目根下的 `.export/`。 +/// +/// 全部导出目标共用一个 flat 目录:共享文件(例如多目标复用的同一个 icon)直接放这一层, +/// 目标专属文件挂 target 前缀(`xhs-minitool.json`、`xhs-minitool.zip`……)。整个目录排除出 +/// 项目快照——所以恢复或换机后适配成果不跟随,需要重新让 agent 适配。 +pub(crate) const EXPORT_WORK_RELATIVE_DIR: &str = ".export"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs new file mode 100644 index 000000000..96460f7d7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/export/registry.rs @@ -0,0 +1,403 @@ +//! 导出注册表的**文件级**读写。 +//! +//! 这个模块只认「一个 JSON 文件 + 一个内容指纹」,不认任何 target:自动创建、严格解析、原子 +//! 写入与内容 hash 全部收在这里,目标模块只负责提供自己的 DTO 与路径。指纹是**原始文件字节** +//! 的 SHA-256,不写进文件——它是「有没有别人改过」的判据,不是持久化状态。 + +use std::fmt; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// 已读出的注册表值与它的内容指纹。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Loaded { + pub(crate) value: T, + /// 原始文件内容的指纹;保存时用它判断「读完之后有没有别人写过」。 + pub(crate) content_hash: String, + /// 这次读取是不是刚补出来的空文件:刚补出来的文件没有可冲突的对象。 + pub(crate) created: bool, +} + +/// 注册表失败的底层现场。 +/// +/// 能拿到底层错误类型时(`io` / `serde`)就把类型带出来,这样 Rust 侧调用方可以按 +/// `std::io::ErrorKind` 或 serde 错误分流,日志也能沿 `source()` 追到根因;只有本模块自己拼 +/// 出来的一句话(例如「注册表路径缺少父目录」)才退化成 [`RegistryErrorCause::Message`]。 +/// `context` 是给原始错误补的中文说明,只影响展示,不影响类型。 +#[derive(Debug)] +pub(crate) enum RegistryErrorCause { + Io { + context: Option<&'static str>, + error: std::io::Error, + }, + Serde { + context: Option<&'static str>, + error: serde_json::Error, + }, + Message(String), +} + +impl RegistryErrorCause { + fn message(message: impl Into) -> Self { + Self::Message(message.into()) + } + + fn io(error: std::io::Error) -> Self { + Self::Io { + context: None, + error, + } + } + + fn io_with_context(context: &'static str, error: std::io::Error) -> Self { + Self::Io { + context: Some(context), + error, + } + } + + fn serde(error: serde_json::Error) -> Self { + Self::Serde { + context: None, + error, + } + } +} + +impl fmt::Display for RegistryErrorCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { context, error } => match context { + Some(context) => write!(formatter, "{context}:{error}"), + None => write!(formatter, "{error}"), + }, + Self::Serde { context, error } => match context { + Some(context) => write!(formatter, "{context}:{error}"), + None => write!(formatter, "{error}"), + }, + Self::Message(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for RegistryErrorCause { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { error, .. } => Some(error), + Self::Serde { error, .. } => Some(error), + Self::Message(_) => None, + } + } +} + +/// 注册表文件级失败。目标模块负责把它映射成自己的 typed error。 +#[derive(Debug)] +pub(crate) enum RegistryError { + /// 路径不可读写(含父目录创建失败)。 + Io { + path: PathBuf, + cause: RegistryErrorCause, + }, + /// 文件内容不是本目标期望的形状。fail closed:不猜测、不修补。 + Malformed { + path: PathBuf, + cause: RegistryErrorCause, + }, + /// 宿主自己的 DTO 序列化失败,属于编程错误。 + Encode { cause: RegistryErrorCause }, +} + +impl std::error::Error for RegistryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { cause, .. } | Self::Malformed { cause, .. } | Self::Encode { cause } => { + Some(cause) + } + } + } +} + +impl fmt::Display for RegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, cause } => { + write!(formatter, "注册表读写失败:{}:{cause}", path.display()) + } + Self::Malformed { path, cause } => { + write!( + formatter, + "注册表内容不符合约定:{}:{cause}", + path.display() + ) + } + Self::Encode { cause } => write!(formatter, "注册表序列化失败:{cause}"), + } + } +} + +/// 原始内容指纹(SHA-256,小写十六进制)。 +pub(crate) fn content_hash(text: &str) -> String { + format!("{:x}", Sha256::digest(text.as_bytes())) +} + +/// 读注册表;文件不存在时先用 `empty` 的规范序列化补一份空注册表,再读回来。 +/// +/// 「自动创建」是产品要求:面板一打开就要能显示空表单,而不是先报「文件不存在」。 +/// +/// 这里明确接受一个并发窗口:`read_to_string` 返回 `NotFound` 到下面 `write_atomically` 之间 +/// 没有锁,如果这期间别的写者(code agent 的脚本、并发的保存命令)抢先建好了同一个文件,本次 +/// 写入会用空注册表把它盖掉。不为此加锁或改成「原子重读 + 比较」:注册表本来就是用户与 agent +/// 共写的本机文件,真撞上时重新读一次就能看到最新内容,写入门禁级别的并发保证成本高于收益 +///(目标模块 `xhs_minitool::registry::save` 对同类窗口也是明确接受)。 +pub(crate) fn load_or_create(path: &Path, empty: &T) -> Result, RegistryError> +where + T: Serialize + DeserializeOwned, +{ + let (text, created) = match fs::read_to_string(path) { + Ok(text) => (text, false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let created = encode(empty)?; + write_atomically(path, &created)?; + (created, true) + } + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + // 非 UTF-8 字节就是「内容不是本目标期望的形状」,与 JSON 解析失败同类;落进通用 Io 会 + // 被报成「路径 / 权限故障」,把用户和 agent 往修权限的方向引。 + return Err(RegistryError::Malformed { + path: path.to_path_buf(), + cause: RegistryErrorCause::io_with_context("注册表不是有效 UTF-8", error), + }); + } + Err(error) => { + return Err(RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io(error), + }); + } + }; + let value = decode(path, &text)?; + Ok(Loaded { + value, + content_hash: content_hash(&text), + created, + }) +} + +/// 以规范形式写回注册表,返回**新内容**的指纹。 +pub(crate) fn save(path: &Path, value: &T) -> Result { + let text = encode(value)?; + write_atomically(path, &text)?; + Ok(content_hash(&text)) +} + +/// 规范形式:两空格缩进 + 结尾换行。agent 会手改这个文件,人类可读比紧凑更重要。 +fn encode(value: &T) -> Result { + let mut text = serde_json::to_string_pretty(value).map_err(|error| RegistryError::Encode { + cause: RegistryErrorCause::serde(error), + })?; + text.push('\n'); + Ok(text) +} + +fn decode(path: &Path, text: &str) -> Result { + serde_json::from_str(text).map_err(|error| RegistryError::Malformed { + path: path.to_path_buf(), + cause: RegistryErrorCause::serde(error), + }) +} + +/// 先写同目录临时文件再原子替换。 +/// +/// 直接对目标 `File::create` 会在中途失败时同时留下半截文件和「原内容已被截断」两个后果; +/// manifest 与素材保存用的是同一套手法。 +fn write_atomically(path: &Path, text: &str) -> Result<(), RegistryError> { + let parent = path.parent().ok_or_else(|| RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::message("注册表路径缺少父目录"), + })?; + fs::create_dir_all(parent).map_err(|error| RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io_with_context("创建导出目录失败", error), + })?; + let temp = temp_path(path); + let write = || -> std::io::Result<()> { + // `create_new`(O_EXCL)不跟随已存在的路径:项目里预先摆好的同名符号链接或残留文件会让 + // 这次写入直接失败,而不是把链接指向的文件截断成注册表内容。 + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; + file.write_all(text.as_bytes())?; + // 替换前落盘:只 flush 的话,崩溃窗口里换过去的可能是还没写完的内容。 + file.sync_all() + }; + if let Err(error) = write() { + let _ = fs::remove_file(&temp); + return Err(RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io(error), + }); + } + fs::rename(&temp, path).map_err(|error| { + let _ = fs::remove_file(&temp); + RegistryError::Io { + path: path.to_path_buf(), + cause: RegistryErrorCause::io(error), + } + })?; + // rename 只改目录条目:不 fsync 父目录的话,崩溃后「哪个文件才是新的」这件事可能没落盘, + // 数据虽然在磁盘上,目录项仍是旧的(甚至没有)。Windows 打不开目录句柄,打不开就跳过。 + if let Ok(directory) = fs::File::open(parent) { + let _ = directory.sync_all(); + } + Ok(()) +} + +fn temp_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "registry.json".to_string()); + // 临时文件名必须**每次写都不同**:只用 pid 的话,同一进程里两个并发写共用同一个临时文件, + // 一个会截断另一个正在写的内容,rename 也会互相抢。进程内原子计数器保证不重名。 + // + // 计数器从 0 重来,所以还要带上「现在」:上一次进程崩在 `create_new` 与 rename 之间时, + // 会留下 `.tmp` 残骸;系统把同一个 pid 再分给新进程时,只靠 pid + 序号会撞上那份残骸, + // 而 `create_new` 会直接以 already-exists 失败。纳秒时间戳让重启后的第一次写也不再撞名。 + let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + path.with_file_name(format!( + ".{file_name}.{}.{sequence}.{stamp}.tmp", + std::process::id() + )) +} + +/// 同进程内临时文件名的序号,见 [`temp_path`]。 +static TEMP_FILE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Serialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Fixture { + name: String, + } + + fn fixture_directory(prefix: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!( + "genarrative-export-registry-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&directory).expect("create fixture directory"); + directory + } + + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + #[test] + fn creates_empty_registry_and_keeps_its_hash_on_reread() { + let path = fixture_directory("create").join("target.json"); + let empty = Fixture { + name: String::new(), + }; + + let first = load_or_create(&path, &empty).expect("create registry"); + assert!(first.created); + assert_eq!(first.value, empty); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "{\n \"name\": \"\"\n}\n" + ); + + let second = load_or_create(&path, &empty).expect("reread registry"); + assert!(!second.created); + assert_eq!(second.content_hash, first.content_hash); + } + + #[test] + fn rejects_malformed_json_without_rewriting_it() { + let path = fixture_directory("malformed").join("target.json"); + fs::write(&path, "{ not json").unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("malformed must fail closed"); + assert!(matches!(error, RegistryError::Malformed { .. })); + assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); + } + + #[test] + fn rejects_non_utf8_content_as_malformed() { + let path = fixture_directory("non-utf8").join("target.json"); + fs::write(&path, [0xff, 0xfe, 0x00]).unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("invalid utf-8 must fail closed"); + assert!( + matches!(error, RegistryError::Malformed { .. }), + "非 UTF-8 必须报成 malformed,而不是 host/path 故障:{error:?}" + ); + } + + #[test] + fn rejects_unknown_fields_so_agent_drift_is_visible() { + let path = fixture_directory("unknown").join("target.json"); + fs::write(&path, "{\"name\":\"a\",\"status\":\"ready\"}\n").unwrap(); + let empty = Fixture { + name: String::new(), + }; + + let error = load_or_create(&path, &empty).expect_err("unknown field must fail"); + assert!(matches!(error, RegistryError::Malformed { .. })); + } + + #[test] + fn save_returns_the_hash_of_what_it_wrote() { + let path = fixture_directory("save") + .join(".export") + .join("target.json"); + let value = Fixture { + name: "小红书".to_string(), + }; + + let hash = save(&path, &value).expect("save registry"); + let loaded = load_or_create(&path, &value).expect("reload registry"); + assert_eq!(loaded.value, value); + assert_eq!(loaded.content_hash, hash); + } + + #[test] + fn error_keeps_the_underlying_io_kind_for_rust_callers() { + let error = RegistryError::Io { + path: PathBuf::from("/tmp/registry.json"), + cause: RegistryErrorCause::io_with_context( + "创建导出目录失败", + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + ), + }; + + // 展示文案保持原样,Rust 侧则能沿 source() 一路拿回 io::Error 并按 ErrorKind 分流。 + assert!(error.to_string().contains("创建导出目录失败")); + let cause = std::error::Error::source(&error).expect("cause"); + let io = cause + .source() + .and_then(|source| source.downcast_ref::()) + .expect("io source"); + assert_eq!(io.kind(), std::io::ErrorKind::PermissionDenied); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index cc4d8cff0..28cfd60df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -13,7 +13,6 @@ use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{ current_platform_session, validate_platform_session_identity, PlatformSessionSnapshot, }; -use base64::Engine; use reqwest::header::{HeaderName, HeaderValue, CONTENT_TYPE}; use reqwest::{Method, StatusCode}; @@ -27,8 +26,8 @@ use shared_contracts::game_creation_app::{ use shared_contracts::game_distribution::{ GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, GameDistributionDeviceSupport, GameDistributionInputMode, GameDistributionOrientation, - GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, }; +use std::io::Read; use std::path::Path; use std::time::Duration; use tauri::{AppHandle, Emitter, Manager}; @@ -38,10 +37,6 @@ const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); -const COVER_QUEUE_POLL_INTERVAL: Duration = Duration::from_millis(1_600); -const COVER_QUEUE_TIMEOUT: Duration = Duration::from_secs(20 * 60); -const COVER_QUEUE_MAX_POLLS: usize = 800; -const COVER_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024; const PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; @@ -71,11 +66,13 @@ pub(crate) struct GameDistributionPublishResult { /// 回读版本冻结资料时带回的封面/截图草稿条目。 /// -/// `asset_id` 只在版本冻结资料里存在;历史版本没有冻结资料时为 `None`,此时只能给出预览, -/// 更新发布必须让作者重新选择素材。`preview_url` 是服务端换签后的地址,换签失败留空。 +/// `object_key` 是新媒体合同的沿用键(面板据此回填「沿用线上」槽位);`asset_id` 只保留给旧冻结 +/// 资料,新合同下恒为 `None`。`preview_url` 是服务端换签后的地址,换签失败留空。 #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct GameDistributionPublicationImageDraft { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) object_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) asset_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -141,62 +138,6 @@ pub(crate) struct GameDistributionPublicationReadResult { pub(crate) message: Option, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GameDistributionCoverGenerationResult { - pub(crate) asset_object_id: String, - pub(crate) preview_url: String, - pub(crate) task_id: String, - pub(crate) model: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CoverGenerationPayload { - #[serde(default)] - image_src: Option, - #[serde(default)] - object_key: Option, - #[serde(default)] - asset_object_id: Option, - #[serde(default)] - asset: Option, - #[serde(default)] - task_id: Option, - #[serde(default)] - model: Option, - #[serde(default)] - queue_state: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CoverAssetPayload { - #[serde(default)] - asset_object_id: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CoverQueueState { - #[serde(default)] - operation_id: Option, - #[serde(default)] - status: Option, - #[serde(default)] - error: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CoverJobStatus { - status: String, - #[serde(default)] - error: Option, - #[serde(default)] - result: Option, -} - #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatedGame { @@ -344,6 +285,15 @@ fn parse_error_payload(body: &str) -> (Option, Option) { .get("message") .and_then(Value::as_str) .map(str::to_string); + let message = message.or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) + }); (code, message) } @@ -353,7 +303,7 @@ fn response_data(body: &str) -> Result { if value.get("ok").and_then(Value::as_bool) == Some(false) { let (code, message) = parse_error_payload(body); return Err(server_error_detail(code, message, || { - "服务器未返回错误信息".to_string() + "服务端响应 ok=false,但未提供 error/code/message 详情".to_string() })); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -363,7 +313,12 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); let (code, message) = parse_error_payload(body); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match message.or(code) { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( @@ -379,6 +334,10 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { .as_deref() .is_some_and(|value| value.trim_start().starts_with("VERSION_NUMBER_CONFLICT"))) { + let has_meaningful_message = message.as_deref().is_some_and(|value| { + let trimmed = value.trim(); + !trimmed.is_empty() && !trimmed.starts_with('{') + }); let detail = message .as_deref() .map(|value| { @@ -388,27 +347,32 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { .trim_start_matches([':', ':']) .trim() }) - .filter(|value| !value.is_empty()) + .filter(|value| has_meaningful_message && !value.is_empty()) .unwrap_or("服务端无法为这次发布分配版本号"); - return format!("版本号冲突:{detail}"); + let code_suffix = if !has_meaningful_message { + code.as_deref() + .map(|value| format!("(错误码:{value})")) + .unwrap_or_default() + } else { + String::new() + }; + return format!("版本号冲突:{detail}{code_suffix}"); } let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } -async fn request_json( +/// 认证、客户端 marker、响应 envelope 与超时四件套:JSON 与 multipart 写请求共用。 +fn scoped_request( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, method: Method, - segments: &[&str], - body: Option, + url: &str, idempotency_key: Option<&str>, - fallback: &str, -) -> Result { +) -> Result { let current = current_scoped_session(snapshot)?; - let url = endpoint(snapshot, segments)?; let mut request = client - .request(method, &url) + .request(method, url) .bearer_auth(¤t.access_token) .header( HeaderName::from_static(AGC_CLIENT_MARKER_HEADER), @@ -422,15 +386,19 @@ async fn request_json( if let Some(key) = idempotency_key { request = request.header("Idempotency-Key", key); } - if let Some(body) = body { - request = request.header(CONTENT_TYPE, "application/json").json(&body); - } + Ok(request) +} + +async fn send_json_request( + request: reqwest::RequestBuilder, + snapshot: &PlatformSessionSnapshot, + fallback: &str, +) -> Result { let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + ) })?; let status = response.status(); let text = response @@ -444,6 +412,39 @@ async fn request_json( response_data(&text).map_err(|error| format!("{fallback}:{error}")) } +async fn request_json( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + method: Method, + segments: &[&str], + body: Option, + idempotency_key: Option<&str>, + fallback: &str, +) -> Result { + let url = endpoint(snapshot, segments)?; + let mut request = scoped_request(client, snapshot, method, &url, idempotency_key)?; + if let Some(body) = body { + request = request.header(CONTENT_TYPE, "application/json").json(&body); + } + send_json_request(request, snapshot, fallback).await +} + +/// 发布媒体直传:与 [`request_json`] 同一条认证 / envelope / 幂等路径,只是请求体换成 +/// `multipart/form-data`(`metadata` 文本 part + 可选 `cover` / 可重复 `screenshot` 二进制 part)。 +async fn request_multipart( + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + method: Method, + segments: &[&str], + form: reqwest::multipart::Form, + idempotency_key: Option<&str>, + fallback: &str, +) -> Result { + let url = endpoint(snapshot, segments)?; + let request = scoped_request(client, snapshot, method, &url, idempotency_key)?.multipart(form); + send_json_request(request, snapshot, fallback).await +} + fn build_client() -> Result { agc_main_site_client_builder() .timeout(HTTP_TIMEOUT) @@ -451,23 +452,60 @@ fn build_client() -> Result { .map_err(|error| format!("创建陶泥儿网络客户端失败:{error}")) } +fn describe_distribution_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } +} + fn normalized_origin(value: &str) -> String { value.trim().trim_end_matches('/').to_string() } -/// 发布资料摘要:随版本冻结的资料与买断价一起参与。 +/// 发布资料摘要:随版本冻结的资料、买断价与**本次直传的图片字节**一起参与。 /// /// 价格必须进摘要:同一份包、同一版本标签只改价格时,幂等账本必须换键,否则会重放上一次 -/// 尝试的收据,把作者改后的价格静默丢掉。 +/// 尝试的收据,把作者改后的价格静默丢掉。沿用线上图的槽位只带 objectKey,已经在规范化 +/// metadata 的 JSON 里;新图必须把原始字节也算进去,否则换一张同尺寸的本地图会命中旧键, +/// 服务端按幂等账本重放上一次的结果。 fn metadata_digest( metadata: &GameDistributionCreateGameRequest, price_mud_points: u64, + media: &PublishMedia, ) -> Result { let bytes = serde_json::to_vec(metadata).map_err(|error| format!("计算发布资料摘要失败:{error}"))?; let mut hasher = Sha256::new(); hasher.update(&bytes); hasher.update(price_mud_points.to_le_bytes()); + for image in media.cover.iter().chain(media.screenshots.iter()) { + // 逐张带长度前缀:图片之间没有分隔符,只串裸字节会让「封面 [1,2] + 截图 [3]」与 + // 「封面 [1] + 截图 [2,3]」撞成同一摘要,本地幂等账本就会把改过的图当成重放。 + hasher.update((image.bytes.len() as u64).to_le_bytes()); + hasher.update(&image.bytes); + } Ok(format!("{:x}", hasher.finalize())) } @@ -504,11 +542,10 @@ async fn request_json_for_read( ) .timeout(HTTP_TIMEOUT); let response = request.send().await.map_err(|error| { - GameDistributionReadError::Unavailable(if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - }) + GameDistributionReadError::Unavailable(format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + )) })?; let status = response.status(); let text = response.text().await.map_err(|error| { @@ -605,11 +642,27 @@ fn clear_publication_binding(root: &Path) { }); } -fn asset_read_url(api_base_url: &str, object_key: &str) -> Result { - let mut url = Url::parse(&format!( - "{}/api/assets/read-url", - api_base_url.trim_end_matches('/') - )) +/// owner 作用域媒体读地址:`/api/game-distribution/my-games/{gameId}/media/read-url`。 +/// +/// 不能再走 `/api/assets/read-url`:发布媒体是项目快照桶对象、不建 `asset_object`,素材库 ACL +/// 不会给它授权;owner 路由按「objectKey 命中调用者自己作品当前行的媒体」放行,未发布 / 被驳回 +/// 的封面与截图也能预览。 +fn owner_media_read_url( + snapshot: &PlatformSessionSnapshot, + game_id: &str, + object_key: &str, +) -> Result { + let mut url = Url::parse(&endpoint( + snapshot, + &[ + "api", + "game-distribution", + "my-games", + game_id, + "media", + "read-url", + ], + )?) .map_err(|_| "陶泥儿服务地址无效".to_string())?; url.query_pairs_mut().append_pair("objectKey", object_key); Ok(url.to_string()) @@ -619,13 +672,20 @@ fn asset_read_url(api_base_url: &str, object_key: &str) -> Result, ) -> Option { let object_key = object_key?.trim(); if object_key.is_empty() { return None; } - let read_url = asset_read_url(&snapshot.api_base_url, object_key).ok()?; + let read_url = match owner_media_read_url(snapshot, game_id, object_key) { + Ok(url) => url, + Err(error) => { + app_log!("读取发行素材预览地址构造失败:{error}"); + return None; + } + }; match crate::assets::resolve_external_asset_signed_url( client, snapshot.access_token.as_str(), @@ -659,7 +719,7 @@ struct FrozenPublicationMetadata { #[serde(default)] cover_object_key: Option, #[serde(default)] - screenshots: Vec, + screenshots: Vec, #[serde(default)] device_support: Option, #[serde(default)] @@ -690,15 +750,6 @@ fn parse_orientation(value: Option) -> Option, - #[serde(default)] - object_key: Option, -} - async fn fetch_frozen_metadata( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, @@ -779,7 +830,7 @@ async fn build_publication_draft( frozen .screenshots .into_iter() - .map(|screenshot| (screenshot.asset_id, screenshot.object_key)) + .map(|object_key| (None, Some(object_key))) .collect::>(), frozen .device_support @@ -814,19 +865,36 @@ async fn build_publication_draft( .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) }; + // 新媒体合同的沿用键来自 `objectKey`;旧冻结资料里的 objectKey 形状与游戏行字符串同源。 + let cover_object_key = normalize_object_key(&cover_object_key); let cover = if cover_asset_id.is_some() || cover_object_key.is_some() { Some(GameDistributionPublicationImageDraft { + object_key: cover_object_key.clone(), asset_id: normalize_asset_id(cover_asset_id), - preview_url: resolve_preview_url(client, snapshot, cover_object_key.as_deref()).await, + preview_url: resolve_preview_url( + client, + snapshot, + entry.id.as_str(), + cover_object_key.as_deref(), + ) + .await, }) } else { None }; let mut screenshot_drafts = Vec::with_capacity(screenshots.len()); for (asset_id, object_key) in screenshots { + let object_key = normalize_object_key(&object_key); screenshot_drafts.push(GameDistributionPublicationImageDraft { + object_key: object_key.clone(), asset_id: normalize_asset_id(asset_id), - preview_url: resolve_preview_url(client, snapshot, object_key.as_deref()).await, + preview_url: resolve_preview_url( + client, + snapshot, + entry.id.as_str(), + object_key.as_deref(), + ) + .await, }); } GameDistributionPublicationDraft { @@ -1004,277 +1072,236 @@ pub(crate) async fn read_game_distribution_publication( } } -fn read_cover_asset_id(payload: &CoverGenerationPayload) -> Option { - payload - .asset_object_id - .clone() - .or_else(|| { - payload - .asset - .as_ref() - .and_then(|asset| asset.asset_object_id.clone()) - }) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) +/// 一次发布要直传的本地图片:原始字节 + 从扩展名推出的内容类型。 +#[derive(Debug)] +struct PublishImage { + bytes: Vec, + content_type: &'static str, } -fn cover_preview_data_url(media_type: &str, bytes: &[u8]) -> Result { - let media_type = media_type - .split(';') - .next() - .unwrap_or(media_type) - .trim() - .to_ascii_lowercase(); - if !media_type.starts_with("image/") { - return Err("游戏封面预览不是受支持的图片类型".to_string()); - } - if bytes.is_empty() || bytes.len() > COVER_PREVIEW_MAX_BYTES { - return Err("游戏封面预览超过安全大小限制".to_string()); - } - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - Ok(format!("data:{media_type};base64,{encoded}")) +/// 从草稿解析出的发布会话媒体:封面与按出现顺序的截图 part。 +/// +/// 调用方会就地规范化 `metadata`:本地封面把 `coverObjectKey` 归零,`screenshots` 槽位里的 +/// 空串按「没有沿用值」处理(消费草稿本地图),有值的 objectKey 去空白保留。part 顺序就是 +/// 最终截图顺序。 +#[derive(Default, Debug)] +struct PublishMedia { + cover: Option, + screenshots: Vec, } -async fn resolve_cover_preview_data_url( - snapshot: &PlatformSessionSnapshot, - payload: &CoverGenerationPayload, -) -> Result { - current_scoped_session(snapshot)?; - let client = build_client()?; - let source = json!({ - "objectKey": payload.object_key.as_deref(), - "imageSrc": payload.image_src.as_deref(), - }); - let download = crate::assets::resolve_canvas_resource_download_with_limit_route_and_fence( - &client, - snapshot.api_base_url.as_str(), - snapshot.access_token.as_str(), - &source, - COVER_PREVIEW_MAX_BYTES, - "/api/assets/read-url", - || Ok(()), - ) - .await? - .ok_or_else(|| "游戏封面预览内容为空".to_string())?; - current_scoped_session(snapshot)?; - cover_preview_data_url(&download.media_type, &download.bytes) -} - -async fn resolve_cover_queue( - client: &reqwest::Client, - snapshot: &PlatformSessionSnapshot, - initial: CoverGenerationPayload, -) -> Result { - let Some(queue) = initial.queue_state.as_ref() else { - return Ok(initial); - }; - let Some(operation_id) = queue - .operation_id +/// 单个槽位的 objectKey 判据:去空白后非空才算「沿用线上」。 +fn normalize_object_key(value: &Option) -> Option { + value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - else { - return Ok(initial); - }; - if !matches!(queue.status.as_deref(), Some("queued" | "running")) { - if queue.status.as_deref() == Some("failed") { - return Err(queue - .error - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); - } - return Ok(initial); + .map(str::to_string) +} + +/// 扩展名 → 服务端接受的图片内容类型。未知扩展名在发请求前失败关闭,别把非 `image/*` 的 +/// 声明送给只收图片的 multipart 端点。 +fn image_content_type(relative_path: &str) -> Result<&'static str, String> { + let extension = Path::new(relative_path) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + match extension.as_deref() { + Some("png") => Ok("image/png"), + Some("jpg") | Some("jpeg") => Ok("image/jpeg"), + Some("webp") => Ok("image/webp"), + Some("gif") => Ok("image/gif"), + Some("avif") => Ok("image/avif"), + _ => Err(format!( + "发布图片「{relative_path}」不是受支持的格式(PNG/JPG/WebP/GIF/AVIF)" + )), } - let started_at = tokio::time::Instant::now(); - for _ in 0..COVER_QUEUE_MAX_POLLS { - if started_at.elapsed() > COVER_QUEUE_TIMEOUT { - return Err("生成游戏封面超时,请稍后重试".to_string()); - } - tokio::time::sleep(COVER_QUEUE_POLL_INTERVAL).await; - let status = request_json( - client, - snapshot, - Method::GET, - &[ - "api", - "runtime", - "external-generation", - "jobs", - operation_id, - ], - None, - None, - "读取封面生成任务失败", - ) - .await?; - let status: CoverJobStatus = - serde_json::from_value(status.get("job").cloned().unwrap_or(status)) - .map_err(|error| format!("封面生成任务响应缺少状态:{error}"))?; - if status.status == "failed" { - return Err(status - .error - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); - } - if status.status == "completed" { - let result = status.result.unwrap_or(Value::Null); - let completed: CoverGenerationPayload = serde_json::from_value(result) - .map_err(|error| format!("封面生成结果格式无效:{error}"))?; - return Ok(CoverGenerationPayload { - image_src: completed.image_src.or(initial.image_src), - object_key: completed.object_key.or(initial.object_key), - asset_object_id: completed.asset_object_id.or(initial.asset_object_id), - asset: completed.asset.or(initial.asset), - task_id: completed.task_id.or(initial.task_id), - model: completed.model.or(initial.model), - queue_state: None, - }); - } +} + +/// 单张发布图片的读取上限,与服务端 `MAX_SINGLE_MEDIA_BYTES` 保持一致:先看体积再读, +/// 避免面板传入超大本地文件时先把宿主内存顶爆、再由服务端事后拒绝。 +const MAX_PUBLISH_MEDIA_BYTES: u64 = 8 * 1024 * 1024; + +/// 读一张发布面板给出的项目内图片:路径先过宿主既有门禁(拒符号链接/越界),再用 no-follow +/// 句柄读取,避免「先查体积、再按路径读」之间被换成符号链接或超长文件的 TOCTOU。 +fn read_publish_image(root: &Path, relative_path: &str) -> Result { + let absolute = crate::project::resolve_local_project_path(root, relative_path)?; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); } - Err("生成游戏封面超时,请稍后重试".to_string()) -} - -#[tauri::command] -pub(crate) async fn suggest_game_distribution_publish_metadata( - name: String, - goal: Option, - context: Option, -) -> Result { - let snapshot = require_platform_session()?; - let client = build_client()?; - let payload = GameDistributionPublishMetadataSuggestionRequest { - name: name.trim().to_string(), - goal: goal.map(|value| value.trim().to_string()), - context: context.map(|value| value.trim().to_string()), - }; - let value = request_json( - &client, - &snapshot, - Method::POST, - &[ - "api", - "game-distribution", - "publish-metadata", - "suggestions", - ], - Some( - serde_json::to_value(payload) - .map_err(|error| format!("生成发布资料请求无效:{error}"))?, - ), - None, - "生成发布简介和分类失败", - ) - .await?; - serde_json::from_value(value).map_err(|error| format!("发布资料建议响应无效:{error}")) -} - -#[tauri::command] -pub(crate) async fn read_game_cover_generation_price( - model: String, - image_size: String, -) -> Result { - let snapshot = require_platform_session()?; - let client = build_client()?; - let value = request_json( - &client, - &snapshot, - Method::GET, - &["api", "editor", "generation-pricing"], - None, - None, - "读取封面生成价格失败", - ) - .await?; - let price = value - .get("models") - .and_then(|models| models.get(model.trim())) - .and_then(|entry| { - entry - .get("prices") - .and_then(|prices| prices.get(image_size.trim())) - .or_else(|| entry.get("price")) - }) - .and_then(Value::as_f64) - .filter(|price| price.is_finite() && *price >= 0.0) - .ok_or_else(|| "封面生成价格暂不可用".to_string())?; - Ok(price) -} - -#[tauri::command] -pub(crate) async fn generate_game_distribution_cover( - prompt: String, - model: String, - aspect_ratio: Option, - image_size: Option, - asset_label: Option, -) -> Result { - let snapshot = require_platform_session()?; - let client = build_client()?; - let value = request_json( - &client, - &snapshot, - Method::POST, - &["api", "editor", "images", "generations"], - Some(json!({ - "prompt": prompt.trim(), - "kind": "publication-material", - "assetKind": "publication-material", - "model": model.trim(), - "aspectRatio": aspect_ratio.as_deref().unwrap_or("16:9"), - "imageSize": image_size.as_deref().unwrap_or("2K"), - "assetLabel": asset_label.as_deref().unwrap_or("游戏封面").trim(), - // 队列结果按客户端来源选择回填契约:不带这个字段时后端按 Standard - // consumer 紧凑化,队列跑完也不会返回 result,客户端自然拿不到平台素材 ID。 - "generationInputs": { "source": "ai-game-creator-client" }, - })), - None, - "生成游戏封面失败", - ) - .await?; - let initial: CoverGenerationPayload = - serde_json::from_value(value).map_err(|error| format!("生成游戏封面响应无效:{error}"))?; - let payload = resolve_cover_queue(&client, &snapshot, initial).await?; - let asset_object_id = - read_cover_asset_id(&payload).ok_or_else(|| "生成游戏封面未返回平台素材 ID".to_string())?; - let preview_url = match resolve_cover_preview_data_url(&snapshot, &payload).await { - Ok(value) => value, - Err(error) => { - app_log!("游戏封面生成成功但预览下载失败:{error}"); - String::new() - } - }; - Ok(GameDistributionCoverGenerationResult { - asset_object_id, - preview_url, - task_id: payload.task_id.unwrap_or_default().trim().to_string(), - model: payload - .model - .unwrap_or_else(|| model.trim().to_string()) - .trim() - .to_string(), + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options + .open(&absolute) + .map_err(|error| format!("读取发布图片「{relative_path}」失败:{error}"))?; + let metadata = file + .metadata() + .map_err(|error| format!("读取发布图片「{relative_path}」失败:{error}"))?; + if !metadata.is_file() { + return Err(format!("发布图片「{relative_path}」不是普通文件")); + } + if metadata.len() > MAX_PUBLISH_MEDIA_BYTES { + return Err(format!("发布图片「{relative_path}」超过 8MB 大小限制")); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_PUBLISH_MEDIA_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取发布图片「{relative_path}」失败:{error}"))?; + if bytes.len() as u64 > MAX_PUBLISH_MEDIA_BYTES { + return Err(format!("发布图片「{relative_path}」超过 8MB 大小限制")); + } + if bytes.is_empty() { + return Err(format!("发布图片「{relative_path}」是空文件")); + } + Ok(PublishImage { + bytes, + content_type: image_content_type(relative_path)?, }) } +/// 把命令收到的 metadata 与发布面板给出的媒体路径对齐: +/// - `cover_path` 非空 → 读文件字节作 `cover` part,并把 `coverObjectKey` 归零; +/// - `cover_path` 为空 → 不发 `cover` part,沿用 `metadata.coverObjectKey`(必须非空); +/// - `screenshots` 的 `Some(objectKey)` 槽位沿用线上,`None` 槽位按出现顺序消费 +/// `screenshot_paths`(已过滤空串)。 +/// +/// 路径来自发布面板(UI 表单),不再回读 `.export/taonier.json` 草稿注册表:同一份表单既决定 +/// 文字字段,也决定本地图,避免面板状态与磁盘副本漂移。新图槽位数与面板本地图数必须一致, +/// 不一致说明面板状态已经被改乱,发布前失败关闭。 +fn resolve_publish_media( + root: &Path, + metadata: &mut GameDistributionCreateGameRequest, + cover_path: &str, + screenshot_paths: &[String], +) -> Result { + let mut media = PublishMedia::default(); + + let cover_path = cover_path.trim(); + if cover_path.is_empty() { + let reused = normalize_object_key(&metadata.cover_object_key); + if reused.is_none() { + return Err("请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场".to_string()); + } + metadata.cover_object_key = reused; + } else { + media.cover = Some(read_publish_image(root, cover_path)?); + metadata.cover_object_key = None; + } + + let local_screenshots = screenshot_paths + .iter() + .map(|path| path.trim()) + .filter(|path| !path.is_empty()) + .collect::>(); + let local_slots = metadata + .screenshots + .iter() + .filter(|slot| normalize_object_key(slot).is_none()) + .count(); + if local_slots != local_screenshots.len() { + return Err(format!( + "发布截图与面板不一致:发布资料有 {local_slots} 个新截图槽位,发布面板声明了 {} 张本地截图,请重新打开发布面板后再试", + local_screenshots.len() + )); + } + + let mut path_iter = local_screenshots.into_iter(); + let mut normalized = Vec::with_capacity(metadata.screenshots.len()); + for slot in metadata.screenshots.drain(..) { + match normalize_object_key(&slot) { + Some(object_key) => normalized.push(Some(object_key)), + None => { + let path = path_iter + .next() + .ok_or_else(|| "发布截图与面板不一致,请重新打开发布面板后再试".to_string())?; + media.screenshots.push(read_publish_image(root, path)?); + normalized.push(None); + } + } + } + metadata.screenshots = normalized; + Ok(media) +} + +/// 组装发布写请求的 multipart:`metadata` 文本 part + 可选 `cover` + 按序 `screenshot`。 +fn publish_multipart_form( + metadata: &impl Serialize, + media: &PublishMedia, +) -> Result { + let metadata_json = serde_json::to_string(metadata) + .map_err(|error| format!("构建发布资料 part 失败:{error}"))?; + let mut form = reqwest::multipart::Form::new().part( + "metadata", + reqwest::multipart::Part::text(metadata_json) + .mime_str("application/json") + .map_err(|error| format!("构建发布资料 part 失败:{error}"))?, + ); + if let Some(cover) = media.cover.as_ref() { + form = form.part( + "cover", + multipart_image_part( + cover, + &multipart_image_file_name("cover", cover.content_type), + )?, + ); + } + for (index, screenshot) in media.screenshots.iter().enumerate() { + form = form.part( + "screenshot", + multipart_image_part( + screenshot, + &multipart_image_file_name(&format!("screenshot-{index}"), screenshot.content_type), + )?, + ); + } + Ok(form) +} + +/// multipart 文件名按真实内容类型给扩展名:JPEG/WebP 等不再一律叫 `.png`。 +/// +/// 服务端按 MIME part 判定,文件名只是给未来消费者看的事实;这里保持与 `content_type` 一致。 +fn multipart_image_file_name(part_name: &str, content_type: &str) -> String { + let extension = match content_type { + "image/jpeg" => "jpg", + other => other.strip_prefix("image/").unwrap_or("bin"), + }; + format!("{part_name}.{extension}") +} + +fn multipart_image_part( + image: &PublishImage, + file_name: &str, +) -> Result { + reqwest::multipart::Part::bytes(image.bytes.clone()) + .file_name(file_name.to_string()) + .mime_str(image.content_type) + .map_err(|error| format!("构建发布图片 part 失败:{error}")) +} + fn stage_publish_package( app_data_dir: &Path, project_path: &str, - package_relative_path: &str, ) -> Result { let root = Path::new(project_path.trim()); crate::project::enforce_project_permission_policy(root, "project.export_package")?; - // 发布前先按平台合同校验工程栈:非 Phaser 4 / 非 Vite 的 npm 工程在这里就给出 - // 可操作错误,不进入后续任何服务端请求。 - crate::project::ensure_publish_project_stack(root)?; - let payload = - crate::project::read_local_project_export_package_at(root, package_relative_path.trim())?; + // 打包产物固定来自 `.export/taonier.zip`:路径由 `vite-export-taonier` skill 与宿主契约常量 + // 共同锚定,不再接受前端传入的任意 `exports/playtest-package-*.zip`。 + let payload = crate::export::draft::taonier::read_artifact_package(root)?; let staging_dir = game_package_upload_staging_dir(app_data_dir); let mut staged = stage_game_package_bytes( &staging_dir, &payload.package_sha256, &payload.package_bytes, )?; - staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + staged.package_file_count = payload.package_file_count; Ok(staged) } @@ -1288,7 +1315,7 @@ fn emit_publish_progress( received_bytes: u64, total_bytes: u64, ) { - let _ = app.emit( + if let Err(error) = app.emit( PACKAGE_UPLOAD_PROGRESS_EVENT, crate::game_package_upload::progress_event_payload( version_id, @@ -1298,15 +1325,25 @@ fn emit_publish_progress( received_bytes, total_bytes, ), - ); + ) { + app_log!( + "game_distribution.publish_progress.emit_failed publishId={} versionId={} phase={} detail={error}", + publish_id, + version_id, + phase + ); + } } #[tauri::command] pub(crate) async fn publish_local_project_game( app: AppHandle, project_path: String, - package_relative_path: String, - metadata: GameDistributionCreateGameRequest, + mut metadata: GameDistributionCreateGameRequest, + // 发布面板(UI 表单)给出的本地媒体路径:封面与按序截图。发布链路不再回读 + // `.export/taonier.json` 草稿注册表,面板里看到的值就是提交的值。 + cover_path: Option, + screenshot_paths: Option>, game_id: Option, version_number: Option, expected_publication_revision: Option, @@ -1319,7 +1356,8 @@ pub(crate) async fn publish_local_project_game( .path() .app_data_dir() .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?; - let staged = stage_publish_package(&app_data_dir, &project_path, &package_relative_path)?; + let staged = stage_publish_package(&app_data_dir, &project_path)?; + let root = Path::new(project_path.trim()); let local_project_id = metadata .local_project_id .as_deref() @@ -1327,18 +1365,17 @@ pub(crate) async fn publish_local_project_game( .filter(|value| !value.is_empty()) .ok_or_else(|| "发布需要本地项目标识,请重新打开项目后再试".to_string())? .to_string(); - if metadata - .cover_asset_id - .as_deref() - .map(str::trim) - .unwrap_or_default() - .is_empty() - { - return Err("请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场".to_string()); - } if metadata.screenshots.len() > 6 { return Err("游戏截图最多 6 张".to_string()); } + // 发布媒体合同是 multipart 直传(M1):本地图读发布面板声明的项目内文件作为二进制 part, + // 沿用线上图的槽位只发 objectKey。面板路径与张数对不上时在这里失败关闭,不发出半份媒体请求。 + let media = resolve_publish_media( + root, + &mut metadata, + cover_path.as_deref().unwrap_or_default(), + screenshot_paths.as_deref().unwrap_or_default(), + )?; let target_game_id = game_id .as_deref() .map(str::trim) @@ -1364,7 +1401,7 @@ pub(crate) async fn publish_local_project_game( if price_mud_points > MAX_GAME_PRICE_MUD_POINTS { return Err(format!("买断价不能超过 {MAX_GAME_PRICE_MUD_POINTS} 泥点")); } - let digest = metadata_digest(&metadata, price_mud_points)?; + let digest = metadata_digest(&metadata, price_mud_points, &media)?; // 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析: // 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上; // 包内容、目标版本标签或资料任一变化都换键,新标签的提交不会被幂等收据吞掉。 @@ -1391,7 +1428,6 @@ pub(crate) async fn publish_local_project_game( } }; let client = build_client()?; - let root = Path::new(project_path.trim()); // 准备阶段还没有版本标识,事件只用根键关联。更新发布不会新建游戏。 emit_publish_progress( &app, @@ -1413,15 +1449,12 @@ pub(crate) async fn publish_local_project_game( (game_id.to_string(), revision) } None => { - let game_value = request_json( + let game_value = request_multipart( &client, &snapshot, Method::POST, &["api", "game-distribution", "games"], - Some( - serde_json::to_value(&metadata) - .map_err(|error| format!("创建平台游戏请求无效:{error}"))?, - ), + publish_multipart_form(&metadata, &media)?, Some(&format!("{root_key}:game")), "创建平台游戏失败", ) @@ -1445,7 +1478,7 @@ pub(crate) async fn publish_local_project_game( price_mud_points, game_metadata: metadata, }; - let version_value = request_json( + let version_value = request_multipart( &client, &snapshot, Method::POST, @@ -1456,10 +1489,7 @@ pub(crate) async fn publish_local_project_game( game_id.as_str(), "versions", ], - Some( - serde_json::to_value(version_request) - .map_err(|error| format!("创建游戏发行版本请求无效:{error}"))?, - ), + publish_multipart_form(&version_request, &media)?, Some(&format!("{root_key}:version")), "创建游戏发行版本失败", ) @@ -1609,6 +1639,22 @@ pub(crate) async fn publish_local_project_game( mod tests { use super::*; + #[test] + fn frozen_metadata_accepts_string_screenshot_array() { + // 服务端 `version_detail_payload` 的 `frozenMetadata.screenshots` 是字符串数组; + // 旧实现按 `{assetId, objectKey}` 对象解析会整体反序列化失败并退回游戏行数据。 + let frozen: FrozenPublicationMetadata = serde_json::from_value(serde_json::json!({ + "coverObjectKey": "agc/cover.png", + "screenshots": ["agc/a.png", "agc/b.png"], + })) + .expect("新版冻结资料必须按字符串数组解析"); + assert_eq!(frozen.cover_object_key.as_deref(), Some("agc/cover.png")); + assert_eq!( + frozen.screenshots, + vec!["agc/a.png".to_string(), "agc/b.png".to_string()] + ); + } + #[test] fn endpoint_encodes_only_safe_path_segments_and_keeps_origin() { let snapshot = PlatformSessionSnapshot { @@ -1627,6 +1673,33 @@ mod tests { assert!(endpoint(&snapshot, &["api", "bad?query"]).is_err()); } + #[test] + fn owner_media_read_url_targets_owner_route_and_encodes_object_key() { + let snapshot = PlatformSessionSnapshot { + user_id: "user-1".to_string(), + access_token: "token".to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + identity_generation: 1, + revision: 1, + }; + let url = owner_media_read_url( + &snapshot, + "game-1", + "agc/project-snapshots/v1/game-distribution/media/game-1/cover-1.png", + ) + .expect("owner media read url"); + assert!(url.starts_with( + "https://dev.genarrative.world/api/game-distribution/my-games/game-1/media/read-url?" + )); + assert!( + url.contains("objectKey=agc%2Fproject-snapshots%2Fv1%2F"), + "objectKey 必须按查询参数编码:{url}" + ); + // 作者预览不再回退到素材库 ACL 路由:发布媒体不是 asset_object。 + assert!(!url.contains("/api/assets/read-url")); + assert!(owner_media_read_url(&snapshot, "bad/id", "cover.png").is_err()); + } + #[test] fn response_data_unwraps_v1_envelope_and_legacy_payload() { assert_eq!( @@ -1651,6 +1724,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::BAD_REQUEST, @@ -1673,14 +1754,14 @@ mod tests { ), "版本号冲突:版本号已达上限 18446744073709551615,无法自动递增" ); - // 没有 message 时给出兜底提示,不泄漏原始英文代码。 + // 没有 message 时同时给出可读提示和服务端错误码,不能只剩一个编号。 assert_eq!( map_http_error( StatusCode::CONFLICT, r#"{"error":{"code":"VERSION_NUMBER_CONFLICT"}}"#, "创建游戏发行版本失败" ), - "版本号冲突:服务端无法为这次发布分配版本号" + "版本号冲突:服务端无法为这次发布分配版本号(错误码:VERSION_NUMBER_CONFLICT)" ); } @@ -1704,7 +1785,7 @@ mod tests { description: None, category: "益智".to_string(), tags: vec![], - cover_asset_id: Some("asset-cover".to_string()), + cover_object_key: Some("generated/cover.png".to_string()), screenshots: vec![], device_support: shared_contracts::game_distribution::GameDistributionDeviceSupport { desktop: true, @@ -1715,13 +1796,60 @@ mod tests { orientation: shared_contracts::game_distribution::GameDistributionOrientation::Responsive, }; - let first = metadata_digest(&metadata, 0).expect("digest"); + let media = PublishMedia::default(); + let first = metadata_digest(&metadata, 0, &media).expect("digest"); assert_eq!(first.len(), 64); - assert_eq!(metadata_digest(&metadata, 0).expect("stable"), first); + assert_eq!( + metadata_digest(&metadata, 0, &media).expect("stable"), + first + ); // 只改价格的提交必须换幂等键:否则账本会重放上一次收据,静默丢掉改价。 - assert_ne!(metadata_digest(&metadata, 120).expect("priced"), first); + assert_ne!( + metadata_digest(&metadata, 120, &media).expect("priced"), + first + ); + // 只换新图字节、元数据不变也必须换键:本地账本不能把新图吞进上一次的收据。 + let with_cover = PublishMedia { + cover: Some(PublishImage { + bytes: vec![1, 2, 3], + content_type: "image/png", + }), + screenshots: vec![], + }; + assert_ne!( + metadata_digest(&metadata, 0, &with_cover).expect("media changed"), + first + ); metadata.title = "新标题".to_string(); - assert_ne!(metadata_digest(&metadata, 0).expect("changed"), first); + assert_ne!( + metadata_digest(&metadata, 0, &media).expect("changed"), + first + ); + // 分片边界也要进摘要:两组图裸字节串起来相同,但封面/截图的切分不同。 + let split_a = PublishMedia { + cover: Some(PublishImage { + bytes: vec![1, 2], + content_type: "image/png", + }), + screenshots: vec![PublishImage { + bytes: vec![3], + content_type: "image/png", + }], + }; + let split_b = PublishMedia { + cover: Some(PublishImage { + bytes: vec![1], + content_type: "image/png", + }), + screenshots: vec![PublishImage { + bytes: vec![2, 3], + content_type: "image/png", + }], + }; + assert_ne!( + metadata_digest(&metadata, 0, &split_a).expect("split a"), + metadata_digest(&metadata, 0, &split_b).expect("split b") + ); } #[test] @@ -1857,26 +1985,182 @@ mod tests { } #[test] - fn cover_payload_prefers_registered_asset_id() { - let payload: CoverGenerationPayload = serde_json::from_value(json!({ - "imageSrc": "https://asset.test/cover.png", - "asset": { "assetObjectId": "asset-from-nested" }, - "assetObjectId": "asset-from-top-level" - })) - .expect("cover payload"); + fn image_content_type_rejects_unsupported_extensions() { + assert_eq!(image_content_type("a/b/cover.PNG").unwrap(), "image/png"); + assert_eq!(image_content_type("shot.jpeg").unwrap(), "image/jpeg"); + assert_eq!(image_content_type("shot.webp").unwrap(), "image/webp"); + assert!(image_content_type("cover.bmp").is_err()); + assert!(image_content_type("cover").is_err()); + } + + #[test] + fn multipart_image_file_name_follows_content_type() { + assert_eq!(multipart_image_file_name("cover", "image/png"), "cover.png"); assert_eq!( - read_cover_asset_id(&payload).as_deref(), - Some("asset-from-top-level") + multipart_image_file_name("cover", "image/jpeg"), + "cover.jpg" + ); + assert_eq!( + multipart_image_file_name("screenshot-2", "image/webp"), + "screenshot-2.webp" + ); + assert_eq!( + multipart_image_file_name("screenshot-0", "application/octet-stream"), + "screenshot-0.bin" ); } + + fn fixture_root(prefix: &str) -> std::path::PathBuf { + static NEXT_TEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "genarrative-publish-media-{prefix}-{}-{}", + std::process::id(), + NEXT_TEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).expect("create fixture root"); + root + } + + fn fixture_draft(root: &Path, cover_path: &str, screenshot_paths: &[&str]) { + let form = crate::export::draft::taonier::dto::TaonierExportForm { + cover_path: cover_path.to_string(), + screenshot_paths: screenshot_paths + .iter() + .map(|value| value.to_string()) + .collect(), + ..Default::default() + }; + std::fs::create_dir_all(root.join(".export")).expect("create .export"); + std::fs::write( + root.join(".export/taonier.json"), + serde_json::to_vec(&crate::export::draft::taonier::dto::TaonierExportRegistry { form }) + .expect("serialize draft"), + ) + .expect("write draft"); + } + + fn publish_metadata( + cover: Option<&str>, + screenshots: Vec>, + ) -> GameDistributionCreateGameRequest { + GameDistributionCreateGameRequest { + local_project_id: Some("proj-1".to_string()), + title: "游戏".to_string(), + summary: "简介".to_string(), + description: None, + category: "益智".to_string(), + tags: vec![], + cover_object_key: cover.map(str::to_string), + screenshots: screenshots + .into_iter() + .map(|value| value.map(str::to_string)) + .collect(), + device_support: shared_contracts::game_distribution::GameDistributionDeviceSupport { + desktop: true, + mobile: false, + touch: false, + }, + input_modes: vec![], + orientation: + shared_contracts::game_distribution::GameDistributionOrientation::Responsive, + } + } + #[test] - fn cover_preview_data_url_accepts_images_and_rejects_invalid_payloads() { + fn read_publish_image_rejects_oversized_files_before_reading() { + let root = fixture_root("oversized"); + let file = std::fs::File::create(root.join("huge.png")).unwrap(); + file.set_len(MAX_PUBLISH_MEDIA_BYTES + 1).unwrap(); + drop(file); + + let error = read_publish_image(&root, "huge.png").expect_err("must reject oversized media"); + assert!(error.contains("超过 8MB"), "{error}"); + } + + #[test] + fn resolve_publish_media_reads_local_cover_and_screenshots_from_the_panel() { + let root = fixture_root("local"); + std::fs::write(root.join("cover.png"), [1_u8, 2, 3]).unwrap(); + std::fs::write(root.join("shot-a.jpg"), [4_u8, 5]).unwrap(); + std::fs::write(root.join("shot-b.webp"), [6_u8]).unwrap(); + let mut metadata = publish_metadata(None, vec![None, None]); + let panel_screenshots = vec!["shot-a.jpg".to_string(), "shot-b.webp".to_string()]; + + let media = resolve_publish_media(&root, &mut metadata, "cover.png", &panel_screenshots) + .expect("resolve media"); assert_eq!( - cover_preview_data_url("image/png; charset=binary", &[0, 1, 2]) - .expect("image data URL"), - "data:image/png;base64,AAEC" + media.cover.as_ref().map(|image| image.content_type), + Some("image/png") ); - assert!(cover_preview_data_url("text/plain", &[0, 1, 2]).is_err()); - assert!(cover_preview_data_url("image/png", &[]).is_err()); + assert_eq!(media.cover.as_ref().map(|image| image.bytes.len()), Some(3)); + assert_eq!(media.screenshots.len(), 2); + assert_eq!(media.screenshots[0].content_type, "image/jpeg"); + assert_eq!(media.screenshots[1].content_type, "image/webp"); + // 本地封面必须把 coverObjectKey 归零,改由二进制 part 提供。 + assert_eq!(metadata.cover_object_key, None); + // 本地截图槽位保持 null:二进制 part 按出现顺序补齐。 + assert_eq!(metadata.screenshots, vec![None, None]); + } + + #[test] + fn resolve_publish_media_keeps_reused_object_keys_and_only_sends_new_images() { + let root = fixture_root("reuse"); + std::fs::write(root.join("new-shot.png"), [9_u8]).unwrap(); + let mut metadata = publish_metadata( + Some("generated/cover.png"), + vec![Some("generated/shot-1.png"), None], + ); + + let media = resolve_publish_media(&root, &mut metadata, "", &["new-shot.png".to_string()]) + .expect("resolve media"); + assert!(media.cover.is_none()); + assert_eq!(media.screenshots.len(), 1); + assert_eq!(media.screenshots[0].bytes, vec![9_u8]); + assert_eq!( + metadata.cover_object_key.as_deref(), + Some("generated/cover.png") + ); + assert_eq!( + metadata.screenshots, + vec![Some("generated/shot-1.png".to_string()), None] + ); + } + + #[test] + fn resolve_publish_media_uses_panel_paths_and_never_reads_the_draft_registry() { + let root = fixture_root("panel-wins"); + std::fs::write(root.join("panel-cover.png"), [1_u8]).unwrap(); + std::fs::write(root.join("draft-cover.png"), [2_u8, 2]).unwrap(); + // 磁盘草稿仍指向另一张图:发布必须用面板传入的路径,而不是再回读草稿。 + fixture_draft(&root, "draft-cover.png", &[]); + let mut metadata = publish_metadata(None, vec![]); + + let media = resolve_publish_media(&root, &mut metadata, "panel-cover.png", &[]) + .expect("resolve media"); + assert_eq!( + media.cover.as_ref().map(|image| image.bytes.as_slice()), + Some(&[1_u8][..]) + ); + } + + #[test] + fn resolve_publish_media_fails_closed_when_the_panel_count_does_not_match() { + let root = fixture_root("mismatch"); + std::fs::write(root.join("only.png"), [1_u8]).unwrap(); + let mut metadata = publish_metadata(Some("generated/cover.png"), vec![None, None]); + let error = resolve_publish_media(&root, &mut metadata, "", &["only.png".to_string()]) + .expect_err("must fail closed"); + assert!( + error.contains("发布面板"), + "错误应指向面板与张数不匹配:{error}" + ); + } + + #[test] + fn resolve_publish_media_requires_a_cover_when_the_panel_has_none() { + let root = fixture_root("no-cover"); + let mut metadata = publish_metadata(None, vec![]); + let error = resolve_publish_media(&root, &mut metadata, "", &[]).expect_err("must fail"); + assert!(error.contains("请先选择游戏封面"), "{error}"); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index d90d18c3d..679ed41f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -129,7 +129,15 @@ fn parse_server_error(status: u16, body: &str) -> (Option, Option Option Vec { - let Ok(metadata) = fs::symlink_metadata(path) else { - return Vec::new(); +/// 读取账本;只有文件确实不存在时才返回空记录,损坏、权限和类型错误必须阻止发布, +/// 否则可能丢失幂等键并重复创建版本。 +fn read_attempts_at(path: &Path) -> Result, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取发布尝试账本元数据失败:{error}")), }; if metadata.file_type().is_symlink() || !metadata.is_file() { - return Vec::new(); + return Err("发布尝试账本必须是普通文件".to_string()); } - let Ok(content) = fs::read_to_string(path) else { - return Vec::new(); - }; - let Ok(file) = serde_json::from_str::(&content) else { - return Vec::new(); - }; + let content = + fs::read_to_string(path).map_err(|error| format!("读取发布尝试账本失败:{error}"))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析发布尝试账本失败:{error}"))?; file.attempts .into_iter() - .filter_map(normalize_record) + .enumerate() + .map(|(index, record)| { + normalize_record(record) + .ok_or_else(|| format!("发布尝试账本第 {} 条记录无效", index + 1)) + }) .collect() } @@ -249,7 +254,7 @@ pub(crate) fn resolve_game_publish_root_key( let _guard = GAME_PUBLISH_ATTEMPT_LOCK .lock() .map_err(|_| "发布尝试账本锁不可用".to_string())?; - let mut attempts = read_attempts_at(&path); + let mut attempts = read_attempts_at(&path)?; if let Some(existing) = attempts .iter() .find(|record| record_matches_intent(record, &candidate)) @@ -295,7 +300,7 @@ pub(crate) fn clear_game_publish_attempt( let _guard = GAME_PUBLISH_ATTEMPT_LOCK .lock() .map_err(|_| "发布尝试账本锁不可用".to_string())?; - let mut attempts = read_attempts_at(&path); + let mut attempts = read_attempts_at(&path)?; let before = attempts.len(); attempts.retain(|record| { !(record.account_id == account_id @@ -464,6 +469,23 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn corrupt_attempt_ledger_returns_detail_instead_of_starting_a_new_attempt() { + let dir = fixture_dir("corrupt"); + fs::write( + game_publish_attempt_path(&dir), + br#"{"schemaVersion":"agc-game-publish-attempts.v1","attempts":["bad"]}"#, + ) + .expect("write corrupt ledger"); + let error = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &"a".repeat(64)), + ) + .unwrap_err(); + assert!(error.contains("解析发布尝试账本失败"), "{error}"); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn ledger_only_holds_identifiers_and_summaries() { let dir = fixture_dir("shape"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs index 74a7d4b92..a58999cdb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs @@ -1,5 +1,5 @@ use crate::command_exec::resolve_project_command_spec_at; -use crate::project::{normalize_relative_path, should_skip_project_snapshot_path}; +use crate::project::{normalize_relative_path, should_skip_project_derived_path}; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; @@ -1046,7 +1046,7 @@ fn validate_commit_paths(paths: &[String]) -> Result, LocalGitCommit let normalized = normalize_relative_path(path) .map_err(|_| LocalGitCommitError::ordinary("Git 提交路径必须是规范化的项目相对路径"))?; if normalized != *path - || should_skip_project_snapshot_path(&normalized) + || should_skip_project_derived_path(&normalized) || !unique.insert(normalized.clone()) { return Err(LocalGitCommitError::ordinary( @@ -1971,7 +1971,7 @@ fn parse_status(root: &Path, output: &str) -> (Vec, Vec, Vec Option { let normalized = normalize_relative_path(path).ok()?; - if should_skip_project_snapshot_path(&normalized) { + if should_skip_project_derived_path(&normalized) { return None; } if !git_worktree_path_is_safe(root, &normalized) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index eb2d75db9..ac09f9fcd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -29,14 +29,14 @@ struct ApiEnvelope { fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) - .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应不是有效 JSON:{error}"))?; // The platform API normally returns the versioned response envelope. Keep // accepting the raw payload for older dev proxies and local fixtures, but // never accept an error envelope as a successful catalog. if value.get("ok").is_some() { let envelope: ApiEnvelope = serde_json::from_value(value) - .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应格式无效:{error}"))?; if !envelope.ok { let message = envelope .error @@ -53,9 +53,17 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { .unwrap_or("模型列表读取失败"); return Err(message.to_string()); } - return envelope - .data - .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); + return envelope.data.ok_or_else(|| { + // `ok=true` 但 `data` 为空时,把服务端协议里的 error 明细带出来, + // 否则排障只能看到笼统的「缺少 data」。 + let error_detail = envelope + .error + .as_ref() + .and_then(|error| error.message.as_deref().or(error.code.as_deref())) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or("error 字段为空"); + format!("模型列表响应缺少 data:ok=true,error={error_detail}") + }); } serde_json::from_value::(value) @@ -78,7 +86,7 @@ async fn read_bounded_body( .next() .await .transpose() - .map_err(|_| "读取模型列表响应失败或超时,请重试".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len().saturating_add(chunk.len()) > max_bytes { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -107,13 +115,7 @@ async fn fetch_game_creator_llm_models( ) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型服务,请检查网络后重试".to_string() - } - })?; + .map_err(|error| format!("无法连接模型服务:{error};请检查网络后重试"))?; let status = response.status(); let body = read_bounded_body(response, MODEL_CATALOG_MAX_BYTES).await?; @@ -148,7 +150,7 @@ pub(crate) async fn load_game_creator_llm_models() -> Result, -} - #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalProjectDiffEntry { @@ -1327,9 +1302,7 @@ fn default_agent_runtime_run_profile() -> String { } const MAX_CANVAS_EXPORT_FILES: usize = 500; const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024; -const MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200; -const MAX_PROJECT_EXPORT_PACKAGE_BYTES: u64 = 512 * 1024 * 1024; -const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [ +const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 14] = [ ".agent/agent.db", ".agent/spec.md", ".agent/findings.md", @@ -1343,7 +1316,6 @@ const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [ "game/balance.json", "assets/manifest.art.json", "assets/manifest.audio.json", - "exports/README.md", "game/index.html", ]; static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock>> = OnceLock::new(); @@ -1712,23 +1684,11 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat sanitized = sanitized.replace(root.as_ref(), ""); } } - let lowercase = sanitized.to_ascii_lowercase(); - if [ - "authorization", - "bearer ", - "api_key", - "apikey", - "api key", - "x-api-key", - "token=", - "token:", - "credential", - ] - .iter() - .any(|marker| lowercase.contains(marker)) - { - return "".to_string(); - } + // 错误日志是排障材料:只替换敏感值,不能因同一行出现 authorization/token/credential + // 字段就把 HTTP 状态、错误码和其它原因一起删掉。 + // 这里没有项目根时使用一个不会存在的哨兵路径,避免把日志中的普通句点当成项目路径替换。 + sanitized = + agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &sanitized, 2_048); sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); sanitized.chars().take(2_048).collect() } @@ -2058,8 +2018,13 @@ mod diagnostic_log_tests { fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { assert_eq!( sanitize_diagnostic_message("Authorization: Bearer secret", None), - "" + "Authorization: Bearer [redacted-secret]" ); + let upstream = + sanitize_diagnostic_message("upstream HTTP 401: invalid token; retry-after=30", None); + assert!(upstream.contains("HTTP 401"), "{upstream}"); + assert!(upstream.contains("invalid token"), "{upstream}"); + assert!(upstream.contains("retry-after=30"), "{upstream}"); assert_eq!( sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None), "failed at " @@ -2131,6 +2096,7 @@ mod diagnostic_log_tests { } } +pub mod export; #[cfg(test)] mod tests; pub mod ui_editor; diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index 0fa86e867..1baccdd2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -163,27 +163,80 @@ fn response_data(payload: Value) -> Value { fn response_error(status: StatusCode, body: &str, action: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return format!("permission-denied: {action}权限不足"); - } - let detail = serde_json::from_str::(body) - .ok() - .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) + let parsed = serde_json::from_str::(body).ok(); + let error = parsed.as_ref().and_then(|value| value.get("error")); + let detail = ["message", "code", "detail", "additionalDetails"] + .into_iter() + .filter_map(|field| { + let value = error + .and_then(|value| value.get(field)) + .or_else(|| parsed.as_ref().and_then(|value| value.get(field))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) + .map(ToString::to_string)?; + // 先把常见的 `token=... provider rejected` 形状切开,再交给统一脱敏器; + // 否则它会把整行当成长凭据,连后面的真实原因一起吞掉。 + Some(redact_upload_inline_secret_tail(value)) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + .collect::>() + .join(";"); + let detail = (!detail.is_empty()) + .then_some(detail) + .or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) + }) + .map(|detail| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: {action}权限不足{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + let detail = detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{action}失败:{detail}") } +fn redact_upload_inline_secret_tail(mut value: String) -> String { + for key in ["token=", "api_key=", "api-key=", "authorization="] { + let Some(start) = value.to_ascii_lowercase().find(key) else { + continue; + }; + let value_start = start + key.len(); + let value_end = value[value_start..] + .find(|character: char| { + character.is_whitespace() || matches!(character, ';' | ';' | ',' | ',') + }) + .map(|offset| value_start + offset) + .unwrap_or(value.len()); + if value_end > value_start { + value.replace_range(value_start..value_end, "[redacted-secret]"); + } + } + value +} + fn upload_route() -> &'static str { "/api/assets/direct-upload-tickets" } @@ -217,19 +270,19 @@ async fn request_ticket( })) .send() .await - .map_err(|_| "创建素材上传凭证失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "创建素材上传凭证失败:读取响应失败".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "创建素材上传凭证")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "创建素材上传凭证失败:响应格式无效".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:响应格式无效:{error}"))?; let upload = payload .get("upload") .ok_or_else(|| "创建素材上传凭证失败:响应缺少 upload".to_string())?; @@ -285,7 +338,7 @@ async fn request_ticket( } fn validate_upload_host(host: &str) -> Result { - let url = Url::parse(host.trim()).map_err(|_| "素材上传地址无效".to_string())?; + let url = Url::parse(host.trim()).map_err(|error| format!("素材上传地址无效:{error}"))?; let hostname = url.host_str().unwrap_or_default().to_ascii_lowercase(); let is_local = url.scheme() == "http" && matches!(hostname.as_str(), "127.0.0.1" | "localhost"); let is_oss = url.scheme() == "https" && hostname.ends_with(".aliyuncs.com"); @@ -307,7 +360,7 @@ async fn upload_object( .timeout(HTTP_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); @@ -315,18 +368,24 @@ async fn upload_object( let part = Part::bytes(input.bytes.clone()) .file_name(input.file_name.clone()) .mime_str(&input.content_type) - .map_err(|_| "素材媒体类型无效".to_string())?; + .map_err(|error| format!("素材媒体类型无效:{error}"))?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传素材失败:无法访问素材存储,请检查网络后重试".to_string())?; + .map_err(|error| format!("上传素材失败:无法访问素材存储:{error};请检查网络后重试"))?; validate_session(snapshot)?; - if response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传素材到对象存储失败(HTTP {}),请重试", - response.status().as_u16() + let status = response.status(); + if status.as_u16() != ticket.success_action_status { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "上传素材到对象存储", + status, + &body, )); } Ok(()) @@ -356,19 +415,19 @@ async fn confirm_object( })) .send() .await - .map_err(|_| "确认素材资产失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("确认素材资产失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "确认素材资产失败:读取响应失败".to_string())?; + .map_err(|error| format!("确认素材资产失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "确认素材资产")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "确认素材资产失败:响应格式无效".to_string())?; + .map_err(|error| format!("确认素材资产失败:响应格式无效:{error}"))?; let asset = payload .get("assetObject") .ok_or_else(|| "确认素材资产失败:响应缺少 assetObject".to_string())?; @@ -403,7 +462,7 @@ pub(crate) async fn upload_platform_media_asset( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let ticket = request_ticket(&client, &snapshot, &input).await?; upload_object(&snapshot, &ticket, &input).await?; confirm_object(&client, &snapshot, &ticket, &input).await @@ -450,4 +509,29 @@ mod tests { assert_eq!(payload["assetObjectId"], "asset-1"); assert!(!payload.to_string().contains("token")); } + + #[test] + fn response_error_keeps_server_code_and_redacted_body_when_message_is_missing() { + let detail = response_error( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"INVALID_ASSET","additionalDetails":"token=secret provider rejected"}}"#, + "确认素材资产", + ); + assert!(detail.contains("INVALID_ASSET"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + assert!(detail.contains("provider rejected"), "{detail}"); + } + + #[test] + fn response_error_keeps_auth_status_and_server_message() { + let detail = response_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"asset token expired"}}"#, + "上传素材", + ); + assert_eq!( + detail, + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:asset token expired" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs index 8135d41d7..c0ddf7ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs @@ -39,11 +39,16 @@ pub(crate) fn emit_platform_maintenance_notice(status: u16) { let Some(app) = PLATFORM_MAINTENANCE_APP_HANDLE.get() else { return; }; - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, PLATFORM_MAINTENANCE_EVENT, serde_json::json!({ "code": "MAINTENANCE", "status": status }), - ); + ) { + app_log!( + "platform_maintenance.emit_failed status={} detail={error}", + status + ); + } } /// 在平台响应的错误分支上分类维护态:命中就广播一次,未命中什么都不做。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 7694d53f3..d527716d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; @@ -43,6 +43,7 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; +const MAX_PLUGIN_STDERR_BYTES: usize = 64 * 1024; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", @@ -160,9 +161,10 @@ struct RunningPlugin { #[cfg(windows)] _job: crate::process_session::WindowsProcessJob, stdin: Arc>, - lines: Option>, + lines: Option>>, pending: PendingRpc, registrations: Arc>, + stderr_summary: Arc>, next_request_id: u64, editor_context: Option, active: Arc, @@ -513,7 +515,8 @@ fn workspace_plugin_sources(root: &Path) -> Result, Str Err(error) => return Err(format!("读取插件工作区失败:{error}")), }; let mut sources = Vec::new(); - for entry in entries.flatten() { + for entry in entries { + let entry = entry.map_err(|error| format!("读取插件工作区目录项失败:{error}"))?; let plugin_root = entry.path(); if !plugin_root.is_dir() || !plugin_root.join(PLUGIN_MANIFEST_FILE_NAME).is_file() { continue; @@ -577,7 +580,7 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result Result stderr, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err("插件 stderr 不可用".to_string()); + } + }; + let stderr_summary = Arc::new(Mutex::new(String::new())); + let stderr_summary_writer = Arc::clone(&stderr_summary); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stderr + .take((MAX_PLUGIN_STDERR_BYTES + 1) as u64) + .read_to_end(&mut bytes); + let summary = match result { + Ok(_) if bytes.len() > MAX_PLUGIN_STDERR_BYTES => { + "插件 stderr 超过大小上限".to_string() + } + Ok(_) => crate::sanitize_diagnostic_message(&String::from_utf8_lossy(&bytes), None) + .chars() + .take(1200) + .collect(), + Err(error) => format!("读取插件 stderr 失败:{error}"), + }; + if let Ok(mut stored) = stderr_summary_writer.lock() { + *stored = summary; + } + }); #[cfg(windows)] let job = match crate::process_session::WindowsProcessJob::assign_std(&child) { Ok(job) => job, @@ -627,9 +659,18 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result { + if sender.send(Ok(line)).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error)); + break; + } } } }); @@ -641,6 +682,7 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result Result, St loop { let buffer = reader .fill_buf() - .map_err(|_| "读取插件输出失败".to_string())?; + .map_err(|error| format!("读取插件输出失败:{error}"))?; if buffer.is_empty() { return if bytes.is_empty() { Ok(None) @@ -670,7 +712,7 @@ fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result, St if newline.is_some() { return String::from_utf8(bytes) .map(Some) - .map_err(|_| "插件输出不是 UTF-8".to_string()); + .map_err(|error| format!("插件输出不是 UTF-8:{error}")); } } } @@ -967,10 +1009,37 @@ impl PluginHost { .as_ref() .is_some_and(|existing| existing.status == "failed"); let mut running = existing.and_then(|existing| existing.running); - let exited = running - .as_mut() - .and_then(|running| running.child.try_wait().ok().flatten()) - .is_some(); + let process_error = + running + .as_mut() + .and_then(|running| match running.child.try_wait() { + Ok(Some(status)) => Some(if let Some(code) = status.code() { + format!( + "插件进程已退出:exitCode={code}{}", + running + .stderr_summary + .lock() + .ok() + .filter(|summary| !summary.is_empty()) + .map(|summary| format!(";stderr={summary}")) + .unwrap_or_default() + ) + } else { + format!( + "插件进程已被信号终止{}", + running + .stderr_summary + .lock() + .ok() + .filter(|summary| !summary.is_empty()) + .map(|summary| format!(";stderr={summary}")) + .unwrap_or_default() + ) + }), + Ok(None) => None, + Err(error) => Some(format!("读取插件进程状态失败:{error}")), + }); + let exited = process_error.is_some(); if exited { running = None; } @@ -993,11 +1062,7 @@ impl PluginHost { manifest, root: source.root, status, - last_error: if exited { - Some("插件进程已退出".to_string()) - } else { - previous_error - }, + last_error: process_error.or(previous_error), running, }, ); @@ -1177,17 +1242,37 @@ impl PluginHost { .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; + let mut stop_error = None; if let Some(mut running) = record.running.take() { - let _ = running.child.kill(); - let _ = running.child.wait(); + if let Err(error) = running.child.kill() { + stop_error = Some(format!("终止插件进程失败:{error}")); + } + match running.child.wait() { + Ok(_) if stop_error.is_none() => {} + Err(error) => { + stop_error = Some(format!("等待插件进程退出失败:{error}")); + } + _ => {} + } } - record.status = if record.manifest.enabled { + record.status = if stop_error.is_some() { + "failed".to_string() + } else if record.manifest.enabled { "stopped".to_string() } else { "disabled".to_string() }; - record.last_error = None; - audit(&root, id, "stop", true, None); + record.last_error = stop_error.clone(); + audit( + &root, + id, + "stop", + stop_error.is_none(), + stop_error.as_deref(), + ); + if let Some(error) = stop_error { + return Err(error); + } self.summary_locked(record) } @@ -1205,7 +1290,9 @@ impl PluginHost { } let mut cleanup = Ok(()); if !enabled { - let _ = self.stop(id); + self.stop(id) + .map(|_| ()) + .map_err(|error| format!("插件停止失败,未切换禁用状态:{error}"))?; if let Some(editor) = crate::editor_adapters::ManagedEditor::for_plugin(id) { let state = self .state @@ -1437,10 +1524,18 @@ impl PluginHost { let manifest = record.manifest.clone(); let root = root.to_path_buf(); thread::spawn(move || { - while let Ok(line) = lines.recv() { + let mut terminal_error = None; + while let Ok(line_result) = lines.recv() { if !active.load(Ordering::SeqCst) { break; } + let line = match line_result { + Ok(line) => line, + Err(error) => { + terminal_error = Some(error); + break; + } + }; let Ok(envelope) = serde_json::from_str::(&line) else { continue; }; @@ -1489,14 +1584,18 @@ impl PluginHost { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error}}) } }; - let _ = write_rpc_shared(&writer, &payload); + if let Err(error) = write_rpc_shared(&writer, &payload) { + terminal_error = Some(format!("插件 RPC 响应写入失败:{error}")); + break; + } } } } if let Ok(mut waiting) = pending.lock() { let remaining = std::mem::take(&mut *waiting); + let terminal_error = terminal_error.unwrap_or_else(|| "插件进程已退出".to_string()); for (_, sender) in remaining { - let _ = sender.send(Err("插件进程已退出".to_string())); + let _ = sender.send(Err(terminal_error.clone())); } } }); diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs index b1856306d..b403d7ca5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs @@ -153,7 +153,12 @@ pub(crate) async fn set_agc_plugin_project_path( } fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { - if let Ok(extensions) = host.list_extensions() { - let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions); + match host.list_extensions() { + Ok(extensions) => { + if let Err(error) = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions) { + app_log!("agc.plugin_state.emit_failed detail={error}"); + } + } + Err(error) => app_log!("agc.plugin_state.list_failed detail={error}"), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 2f617ce1a..396ac6eb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1107,9 +1107,9 @@ fn canonical_preview_path(root: &Path, file_path: &Path) -> Result Result bool { output.stdin_open = false; live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::Terminate); + if let Err(error) = live.control.send(ProcessControl::Terminate) { + app_log!( + "process_session.terminate_after_snapshot_failed processId={} detail={error}", + live.process_id + ); + } } true } @@ -1175,7 +1180,13 @@ fn drain_process_session_output( } live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::Terminate); + if let Err(error) = live.control.send(ProcessControl::Terminate) + { + app_log!( + "process_session.terminate_after_conpty_failed processId={} detail={error}", + live.process_id + ); + } return; } conpty_cursor_replied = true; @@ -1255,7 +1266,12 @@ fn drain_process_session_output( output.stdin_open = false; live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::OutputLimit); + if let Err(error) = live.control.send(ProcessControl::OutputLimit) { + app_log!( + "process_session.output_limit_control_failed processId={} detail={error}", + live.process_id + ); + } break; } } @@ -1272,7 +1288,12 @@ fn drain_process_session_output( } live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::Terminate); + if let Err(error) = live.control.send(ProcessControl::Terminate) { + app_log!( + "process_session.terminate_after_output_read_failed processId={} detail={error}", + live.process_id + ); + } break; } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index f8d02848c..19ca0894e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -244,13 +244,19 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if process.is_null() { - return Err("无法打开进程核对 Windows Job 归属".into()); + return Err(format!( + "无法打开进程核对 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } let mut in_job = 0; let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法核对进程 Windows Job 归属".into()); + return Err(format!( + "无法核对进程 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } Ok(in_job != 0) } @@ -263,12 +269,18 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; if process.is_null() { - return Err("无法打开进程加入 Windows Job".into()); + return Err(format!( + "无法打开进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } let okay = unsafe { AssignProcessToJobObject(self.0, process) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法将进程加入 Windows Job".into()); + return Err(format!( + "无法将进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -309,7 +321,10 @@ impl WindowsProcessJob { } let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; if snapshot == INVALID_HANDLE_VALUE { - return Err("读取受控命令主线程失败".into()); + return Err(format!( + "读取受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let mut entry = THREADENTRY32::default(); entry.dwSize = std::mem::size_of::() as u32; @@ -337,7 +352,10 @@ impl WindowsProcessJob { ) }; if thread.is_null() { - return Err("打开受控命令主线程失败".into()); + return Err(format!( + "打开受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let belongs = unsafe { GetProcessIdOfThread(thread) } == pid; let previous = if belongs { @@ -347,7 +365,10 @@ impl WindowsProcessJob { }; unsafe { CloseHandle(thread) }; if previous != 1 { - return Err("受控命令恢复未确认,不能无门执行".into()); + return Err(format!( + "受控命令恢复未确认,不能无门执行:previousResumeCount={previous};{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -404,7 +425,10 @@ impl WindowsProcessJob { ) }; if okay == 0 { - return Err("无法确认 Windows Job 子树已退出".into()); + return Err(format!( + "无法确认 Windows Job 子树已退出:{}", + std::io::Error::last_os_error() + )); } Ok(information.ActiveProcesses == 0) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs index da3de5e8e..d255444eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs @@ -162,7 +162,12 @@ pub(crate) fn shutdown_all_process_sessions() { .map(|registry| registry.sessions.values().cloned().collect::>()) .unwrap_or_default(); for live in sessions { - let _ = live.control.send(ProcessControl::Shutdown); + if let Err(error) = live.control.send(ProcessControl::Shutdown) { + app_log!( + "process_session.shutdown.send_failed processId={} detail={error}", + live.process_id + ); + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs index 36ac074df..040ed3d36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs @@ -44,9 +44,11 @@ mod linux { launch: &ProjectCommandLaunchSpec, spec: &ProjectCommandSpec, ) -> Result { + let (target_executable, target_arguments) = + crate::command_exec::project_command_actual_target(spec); if !launch.executable.is_absolute() || !launch.cwd.is_absolute() - || !spec.executable.is_absolute() + || !target_executable.is_absolute() { return Err("process session bridge launch path 必须是绝对路径".to_string()); } @@ -68,8 +70,8 @@ mod linux { ) }) .collect(), - target_executable: spec.executable.as_os_str().as_bytes().to_vec(), - target_arguments: crate::command_exec::project_command_actual_arguments(spec) + target_executable: target_executable.as_os_str().as_bytes().to_vec(), + target_arguments: target_arguments .into_iter() .map(|argument| OsString::from(argument).into_vec()) .collect(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 85ab8fd04..793d06b0b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -9,7 +9,6 @@ mod asset_rename; mod bootstrap; mod checkpoint; mod conversation; -mod export; mod external_editor_bindings; mod filesystem; mod manifest; @@ -29,7 +28,6 @@ pub(crate) use asset_rename::*; pub(crate) use bootstrap::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; -pub(crate) use export::*; pub(crate) use external_editor_bindings::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs index d63fef865..27f5b4226 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs @@ -210,6 +210,21 @@ pub(crate) fn save_local_project_asset_file_at( }) } +/// 解析要在系统文件管理器里定位的那份导出产物。 +/// +/// 与保存共用 [`resolve_export_source_file`]:只有项目根内真实存在的普通文件才会被打开,路径 +/// 门禁、敏感文件与控制面拒绝口径完全一致。它是"保存失败后的兜底出口"——不替用户挑落点,只把 +/// 原件所在位置打开,让他自己复制。 +pub(crate) fn reveal_local_project_asset_file_at( + project_path: &str, + relative_path: &str, +) -> Result { + let root = Path::new(project_path); + validate_project_root(root)?; + enforce_project_auto_permission_policy(root, "file.read")?; + resolve_export_source_file(root, relative_path) +} + #[cfg(test)] mod tests { use super::*; @@ -285,6 +300,23 @@ mod tests { let _ = std::fs::remove_dir_all(&destination_directory); } + #[test] + fn reveal_resolves_existing_project_file_and_rejects_missing() { + let (root, assets) = create_export_project(); + std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset"); + + let path = reveal_local_project_asset_file_at(&root.to_string_lossy(), "assets/hero.png") + .expect("reveal existing project file"); + assert_eq!(path, assets.join("hero.png")); + + let error = + reveal_local_project_asset_file_at(&root.to_string_lossy(), "assets/missing.png") + .expect_err("missing file must fail"); + assert!(error.contains("素材文件不存在或不可读"), "{error}"); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn rejects_directory_as_source_asset() { let (root, assets) = create_export_project(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 70b12ab48..e5b86cb40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -673,7 +673,7 @@ pub(crate) fn restore_local_project_checkpoint_at( } pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { - should_skip_project_snapshot_path(relative_path) + should_skip_project_derived_path(relative_path) || relative_path == ".agent/agent.db" || relative_path == PROJECT_PERMISSION_POLICY_PATH || relative_path == PROJECT_WRITE_LOCK_PATH @@ -787,7 +787,7 @@ pub(crate) fn read_checkpoint_files( } file.path = normalized; } - files.retain(|file| !should_skip_project_snapshot_path(&file.path)); + files.retain(|file| !should_skip_project_derived_path(&file.path)); Ok(files) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs deleted file mode 100644 index 4896eaa65..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ /dev/null @@ -1,1679 +0,0 @@ -use super::*; - -use sha2::{Digest, Sha256}; -use std::collections::BTreeSet; -use std::io::{Cursor, Read}; - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct LocalProjectExportPackageFileDigest { - pub(crate) path: String, - pub(crate) size_bytes: u64, - pub(crate) sha256: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct LocalProjectExportPackagePayload { - pub(crate) package_relative_path: String, - pub(crate) package_bytes: Vec, - pub(crate) package_sha256: String, - pub(crate) package_size_bytes: u64, - pub(crate) files: Vec, -} - -pub(crate) fn export_local_project_package_at( - root: &Path, -) -> Result { - validate_project_root(root)?; - super::verification::validate_project_game_entry(root)?; - ensure_project_export_readme(root)?; - - let files = collect_project_export_package_files(root)?; - let total_bytes = files.iter().map(|(_, _, size)| *size).sum::(); - if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES { - return Err(format!( - "试玩包文件数量超过上限:{} > {}", - files.len(), - MAX_PROJECT_EXPORT_PACKAGE_FILES - )); - } - if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES { - return Err(format!( - "试玩包文件总大小超过上限:{}B > {}B", - total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES - )); - } - let package_relative_path = next_project_export_package_relative_path(root)?; - let package_path = resolve_local_project_path(root, &package_relative_path)?; - if let Some(parent) = package_path.parent() { - ensure_game_creator_private_directory_tree(parent, "导出目录")?; - prepare_game_creator_private_path_for_read(parent, true, "导出目录")?; - } - prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; - - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); - } - let file = options - .open(&package_path) - .map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?; - if let Err(error) = harden_new_game_creator_private_path(&package_path, false, "试玩包") { - drop(file); - let _ = fs::remove_file(&package_path); - return Err(error); - } - let mut writer = zip::ZipWriter::new(file); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - for (relative_path, absolute_path, _) in &files { - writer - .start_file(relative_path, options) - .map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?; - prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?; - let bytes = fs::read(absolute_path) - .map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?; - writer - .write_all(&bytes) - .map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?; - } - writer - .finish() - .map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?; - prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; - - let updated_at = unix_timestamp(); - let log_path = root.join(".agent/logs/command.log"); - let output = format!( - "导出试玩包:{},{} 个文件,{}B", - package_relative_path, - files.len(), - total_bytes - ); - let line = format!("{updated_at} project.export_package: {output}\n"); - append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?; - record_command_run( - root, - GameCreationAppCommandRunState { - command_id: "project.export_package".to_string(), - status: GameCreationAppCommandRunStatus::Completed, - output, - log_path: log_path.to_string_lossy().into_owned(), - updated_at, - }, - )?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "project.export_package", - "packagePath": package_relative_path, - "fileCount": files.len(), - "totalBytes": total_bytes, - }), - )?; - - Ok(LocalProjectExportPackageResult { - package_path: package_path.to_string_lossy().into_owned(), - package_relative_path, - file_count: files.len(), - total_bytes, - }) -} - -/// Read a previously exported package for the explicit AGC publish flow. -/// -/// The caller receives the package bytes and a deterministic file manifest, but -/// never receives a filesystem path that it could accidentally send to the API. -/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤, -/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。 -pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300; - -fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool { - let has_completed_prototype_task = manifest.tasks.iter().any(|task| { - task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed - }); - let has_running_preview = manifest.preview.as_ref().is_some_and(|preview| { - preview.status == GameCreationAppPreviewStatus::Running - && preview - .url - .as_deref() - .is_some_and(|url| !url.trim().is_empty()) - }); - has_completed_prototype_task || has_running_preview -} - -/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。 -/// -/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控 -/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。 -pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result, String> { - for cwd in [".", "game"] { - let package_root = if cwd == "." { - root.to_path_buf() - } else { - resolve_local_project_path(root, cwd)? - }; - let package_path = package_root.join("package.json"); - let metadata = match fs::symlink_metadata(&package_path) { - Ok(metadata) => metadata, - Err(_) => continue, - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - continue; - } - let Ok(content) = fs::read_to_string(&package_path) else { - continue; - }; - let Ok(package) = serde_json::from_str::(&content) else { - continue; - }; - let declared = package - .get("scripts") - .and_then(|scripts| scripts.get("build")) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - if declared.is_some() { - return Ok(Some(cwd)); - } - } - Ok(None) -} - -/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。 -pub(crate) fn read_publish_build_command( - root: &Path, - cwd_relative: &str, -) -> Result { - let package_root = if cwd_relative == "." { - root.to_path_buf() - } else { - resolve_local_project_path(root, cwd_relative)? - }; - let package_path = package_root.join("package.json"); - let content = fs::read_to_string(&package_path).map_err(|error| { - format!( - "读取 package.json 失败:{}: {error}", - package_path.display() - ) - })?; - let package: serde_json::Value = serde_json::from_str(&content) - .map_err(|error| format!("解析 package.json 失败:{error}"))?; - package - .get("scripts") - .and_then(|scripts| scripts.get("build")) - .and_then(serde_json::Value::as_str) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "package.json 未定义 build 脚本".to_string()) -} - -/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。 -fn publish_build_failure_tail(output: &str) -> String { - const MAX_CHARS: usize = 2_000; - let trimmed = output.trim(); - let chars = trimmed.chars().count(); - if chars <= MAX_CHARS { - return trimmed.to_string(); - } - let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::(); - format!("…{tail}") -} - -/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。 -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PublishBuildPlan { - pub(crate) cwd_relative: &'static str, - pub(crate) command: String, - /// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。 - pub(crate) needs_dependency_install: bool, -} - -/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。 -pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result { - let Some(cwd_relative) = resolve_publish_build_cwd(root)? else { - return Err( - "项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试" - .to_string(), - ); - }; - let command = read_publish_build_command(root, cwd_relative)?; - let needs_dependency_install = - cwd_relative == "game" && !root.join("game").join("node_modules").is_dir(); - Ok(PublishBuildPlan { - cwd_relative, - command, - needs_dependency_install, - }) -} - -/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。 -/// -/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包; -/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败 -/// 返回带日志尾部的可操作错误,不回传本地路径。 -pub(crate) async fn export_local_project_package_for_publish_at( - root: &Path, -) -> Result { - if validate_project_game_entry(root).is_ok() { - ensure_publish_project_stack(root)?; - return export_local_project_package_at(root); - } - let manifest = read_manifest_for_project(root)?; - if !project_has_runnable_prototype(&manifest) { - return Err( - "首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。".to_string(), - ); - } - let plan = resolve_publish_build_plan(root)?; - // 首版发行包合同只覆盖 Phaser 4 + Vite 的 npm 工程:在跑安装/构建之前先说清楚, - // 避免作者等完一次 npm install 才拿到一句泛化错误。 - ensure_publish_project_stack_at(root, plan.cwd_relative)?; - // `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装, - // 否则作者要点两次(先 bootstrap 再发布)。 - if plan.needs_dependency_install { - let bootstrap = - crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?; - if bootstrap.status != "completed" { - return Err(format!( - "安装 game 依赖失败(npm install 未通过):\n{}", - publish_build_failure_tail(&bootstrap.output) - )); - } - } - let built = crate::project::verification::run_project_verification_with_commit_at( - root, - "build", - &plan.command, - PUBLISH_BUILD_TIMEOUT_SECONDS, - plan.cwd_relative, - || Ok(()), - ) - .await?; - if built.status != "completed" { - return Err(format!( - "构建可玩版本失败(npm run build 未通过):\n{}", - publish_build_failure_tail(&built.output) - )); - } - validate_project_game_entry(root) - .map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?; - export_local_project_package_at(root) -} - -/// 解析依赖声明里的版本号主版本:跳过 `^`、`~`、`>=` 等前缀,取第一段连续数字。 -/// -/// `*`、`latest`、`workspace:*` 这类没有数字的声明返回 `None`,由调用方决定是否回退到 -/// `package-lock.json` 的解析结果。 -fn declared_version_major(spec: &str) -> Option { - let digits = spec - .trim() - .chars() - .skip_while(|character| !character.is_ascii_digit()) - .take_while(char::is_ascii_digit) - .collect::(); - digits.parse::().ok() -} - -/// 读取依赖声明原文;兼容 `"phaser": "4.2.1"` 与 `"phaser": { "version": "4.2.1" }` 两种写法。 -fn declared_dependency_spec(package: &serde_json::Value, name: &str) -> Option { - const SECTIONS: [&str; 3] = ["dependencies", "devDependencies", "peerDependencies"]; - for section in SECTIONS { - let Some(entry) = package.get(section).and_then(|deps| deps.get(name)) else { - continue; - }; - let spec = match entry { - serde_json::Value::String(spec) => spec.trim().to_string(), - serde_json::Value::Object(object) => object - .get("version") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .unwrap_or_default() - .to_string(), - _ => String::new(), - }; - if !spec.is_empty() { - return Some(spec); - } - } - None -} - -/// `package-lock.json` 的已解析版本:v2/v3 看 `packages["node_modules/"]`,v1 看 `dependencies`。 -fn locked_dependency_version(package_root: &Path, name: &str) -> Option { - let lock_path = package_root.join("package-lock.json"); - let content = fs::read_to_string(&lock_path).ok()?; - let lock: serde_json::Value = serde_json::from_str(&content).ok()?; - let from_packages = lock - .get("packages") - .and_then(|packages| packages.get(format!("node_modules/{name}"))) - .and_then(|entry| entry.get("version")) - .and_then(serde_json::Value::as_str); - from_packages - .or_else(|| { - lock.get("dependencies") - .and_then(|dependencies| dependencies.get(name)) - .and_then(|entry| entry.get("version")) - .and_then(serde_json::Value::as_str) - }) - .map(|version| version.trim().to_string()) - .filter(|version| !version.is_empty()) -} - -/// 读取某个 npm 工作区的 `package.json`;不存在或不是普通文件时返回 `None`。 -fn read_publish_workspace_package( - root: &Path, - cwd_relative: &str, -) -> Result, String> { - let package_root = if cwd_relative == "." { - root.to_path_buf() - } else { - resolve_local_project_path(root, cwd_relative)? - }; - let package_path = package_root.join("package.json"); - let metadata = match fs::symlink_metadata(&package_path) { - Ok(metadata) => metadata, - Err(_) => return Ok(None), - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!("{cwd_relative}/package.json 必须是普通文件")); - } - let content = fs::read_to_string(&package_path) - .map_err(|error| format!("读取 {cwd_relative}/package.json 失败:{error}"))?; - let package: serde_json::Value = serde_json::from_str(&content) - .map_err(|error| format!("解析 {cwd_relative}/package.json 失败:{error}"))?; - Ok(Some((package_root, package))) -} - -fn workspace_has_vite_config(package_root: &Path) -> bool { - [ - "vite.config.js", - "vite.config.mjs", - "vite.config.cjs", - "vite.config.ts", - "vite.config.mts", - "vite.config.cts", - ] - .iter() - .any(|name| package_root.join(name).is_file()) -} - -/// 发布到游戏广场首版只接受 Phaser 4 + Vite 的 npm 工程。 -/// -/// 客户端发布必须命中 npm 构建工作区;无 `package.json` 的单文件 HTML 项目不属于首版 Phaser 4 发布合同。 -/// 判定顺序以**将被构建的那个工作区**为准,它没声明 phaser 时再看项目里的另一个工作区 -/// (根工程 / `game` 子工程),避免多工作区项目被误判;校验失败必须给出可操作错误—— -/// 缺什么依赖、当前声明是什么版本、要怎么做。错误文案只带项目内相对路径。 - -fn ensure_publish_project_stack_at( - root: &Path, - build_cwd_relative: &'static str, -) -> Result<(), String> { - let candidates: [&'static str; 2] = if build_cwd_relative == "." { - [".", "game"] - } else { - [build_cwd_relative, "."] - }; - let mut phaser: Option<(String, String, PathBuf)> = None; - let mut vite_ready = false; - for cwd_relative in candidates { - let Some((package_root, package)) = read_publish_workspace_package(root, cwd_relative)? - else { - continue; - }; - if phaser.is_none() { - if let Some(spec) = declared_dependency_spec(&package, "phaser") { - phaser = Some((spec, cwd_relative.to_string(), package_root.clone())); - } - } - if declared_dependency_spec(&package, "vite").is_some() - || workspace_has_vite_config(&package_root) - { - vite_ready = true; - } - } - let Some((spec, cwd_relative, package_root)) = phaser else { - return Err(format!( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{build_cwd_relative}/package.json 未声明 phaser 依赖。请先安装 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" - )); - }; - let major = declared_version_major(&spec).or_else(|| { - locked_dependency_version(&package_root, "phaser") - .as_deref() - .and_then(declared_version_major) - }); - if major.is_some_and(|major| major != 4) { - return Err(format!( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 声明的 phaser 版本是 {spec}。请升级到 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" - )); - } - if !vite_ready { - return Err(format!( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 未声明 vite 依赖,也没有 vite.config.*。请改用 Vite 构建(例如 npm create vite)后重新发布。" - )); - } - Ok(()) -} - -/// 发布前校验工程栈:客户端发布必须使用 Phaser 4 + Vite npm 工程。 -pub(crate) fn ensure_publish_project_stack(root: &Path) -> Result<(), String> { - let Some(cwd_relative) = resolve_publish_build_cwd(root)? else { - return Err( - "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:项目没有可识别的 npm build 工作区。请先使用 Phaser 4 + Vite 工程并提供 npm run build 后重新发布。".to_string(), - ); - }; - ensure_publish_project_stack_at(root, cwd_relative) -} - -/// 发布前的客户端预检:包体不得超过平台上限。 -/// -/// 服务端仍会独立校验;这里先判一次是为了给作者可操作错误(含实际体积与上限), -/// 而不是让他把包传上去再吃一个 413。上限单一来源在 `shared-contracts`。 -fn ensure_within_platform_package_limit(package_bytes: u64) -> Result<(), String> { - let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; - if package_bytes > limit { - return Err(format!( - "发行包 {:.1} MiB 超过平台上限 {} MiB;请精简资源后重新导出再发布。", - package_bytes as f64 / (1024.0 * 1024.0), - limit / (1024 * 1024) - )); - } - Ok(()) -} - -pub(crate) fn read_local_project_export_package_at( - root: &Path, - package_relative_path: &str, -) -> Result { - validate_project_root(root)?; - let normalized = normalize_export_package_entry_path(package_relative_path)?; - if !normalized.starts_with("exports/playtest-package-") - || !normalized.ends_with(".zip") - || normalized.contains('/') && normalized.split('/').count() != 2 - { - return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string()); - } - let package_path = resolve_local_project_path(root, &normalized)?; - prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?; - let metadata = checked_export_package_metadata(&package_path, &normalized)?; - if !metadata.is_file() { - return Err("发行包必须是普通文件".to_string()); - } - if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES { - return Err("发行包大小超出本地发布上限".to_string()); - } - ensure_within_platform_package_limit(metadata.len())?; - let package_bytes = - fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?; - if package_bytes.len() as u64 != metadata.len() { - return Err("发行包在读取期间发生变化,请重新导出".to_string()); - } - - let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes)) - .map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?; - let mut entries = Vec::with_capacity(archive.len()); - let mut seen = BTreeSet::new(); - for index in 0..archive.len() { - let mut entry = archive - .by_index(index) - .map_err(|error| format!("读取发行包条目失败:{error}"))?; - if entry.is_dir() { - continue; - } - let source_path = normalize_export_package_entry_path(entry.name())?; - // 本地试玩包以 game/index.html 为入口,而平台发行合同要求根 - // index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改 - // 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。 - let path = source_path - .strip_prefix("game/") - .unwrap_or(source_path.as_str()) - .to_string(); - let path = normalize_export_package_entry_path(&path)?; - if !seen.insert(path.clone()) { - return Err(format!("发行包包含重复条目:{path}")); - } - let expected_size = entry.size(); - let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize); - entry - .read_to_end(&mut content) - .map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?; - if content.len() as u64 != expected_size { - return Err(format!("发行包条目长度不一致:{path}")); - } - let content = normalize_release_asset_references(&path, content); - entries.push((path, content)); - } - entries.sort_by(|left, right| left.0.cmp(&right.0)); - if entries.is_empty() { - return Err("发行包没有可上传文件".to_string()); - } - - let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new())); - let options = zip::write::SimpleFileOptions::default() - .compression_method(zip::CompressionMethod::Deflated); - for (path, content) in &entries { - normalized_writer - .start_file(path, options) - .map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?; - normalized_writer - .write_all(content) - .map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?; - } - let normalized_cursor = normalized_writer - .finish() - .map_err(|error| format!("完成发行包失败:{error}"))?; - let package_bytes = normalized_cursor.into_inner(); - if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES { - return Err("归一化发行包大小超出本地发布上限".to_string()); - } - let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes)); - let files = entries - .into_iter() - .map(|(path, content)| LocalProjectExportPackageFileDigest { - size_bytes: content.len() as u64, - sha256: format!("{:x}", Sha256::digest(&content)), - path, - }) - .collect::>(); - if !files.iter().any(|file| file.path == "index.html") { - return Err("归一化发行包缺少根 index.html".to_string()); - } - Ok(LocalProjectExportPackagePayload { - package_relative_path: normalized, - package_size_bytes: package_bytes.len() as u64, - package_bytes, - package_sha256, - files, - }) -} - -fn normalize_release_asset_references(path: &str, content: Vec) -> Vec { - let is_text = matches!( - path.rsplit('.') - .next() - .unwrap_or_default() - .to_ascii_lowercase() - .as_str(), - "css" | "html" | "htm" | "js" | "json" | "mjs" - ); - if !is_text { - return content; - } - let mut source = match String::from_utf8(content) { - Ok(source) => source, - Err(error) => return error.into_bytes(), - }; - for root in ["assets", "game", "ui"] { - source = source.replace(&format!("\"/{root}/"), &format!("\"{root}/")); - source = source.replace(&format!("'/{root}/"), &format!("'{root}/")); - source = source.replace(&format!("`/{root}/"), &format!("`{root}/")); - source = source.replace(&format!("url(/{root}/"), &format!("url({root}/")); - } - source.into_bytes() -} - -#[cfg(test)] -mod tests { - use super::normalize_release_asset_references; - - #[test] - fn normalize_release_asset_references_removes_root_prefix_only_in_text() { - let source = br#""#; - let normalized = normalize_release_asset_references("index.html", source.to_vec()); - let normalized = String::from_utf8(normalized).expect("normalized text"); - assert!(normalized.contains("src=\"assets/hero.png\"")); - assert!(normalized.contains("fetch('ui/config.json')")); - assert!(normalized.contains("url(game/icon.svg)")); - assert_eq!( - normalize_release_asset_references("image.png", b"/assets/image.png".to_vec()), - b"/assets/image.png" - ); - } -} - -pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result { - let seed = unix_millis(); - for suffix in 0..1000 { - let file_name = if suffix == 0 { - format!("playtest-package-{seed}.zip") - } else { - format!("playtest-package-{seed}-{suffix}.zip") - }; - let relative_path = format!("exports/{file_name}"); - let package_path = resolve_local_project_path(root, &relative_path)?; - if !package_path.exists() { - return Ok(relative_path); - } - } - Err("无法生成唯一试玩包文件名".to_string()) -} - -pub(crate) fn list_local_project_export_packages_at( - root: &Path, -) -> Result { - validate_project_root(root)?; - let export_dir = resolve_local_project_path(root, "exports")?; - if !export_dir.exists() { - return Ok(LocalProjectExportPackagesResult { - project_path: root.to_string_lossy().into_owned(), - packages: Vec::new(), - }); - } - let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?; - if !export_dir_metadata.is_dir() { - return Err("exports 必须是目录".to_string()); - } - - let mut packages = Vec::new(); - for entry in fs::read_dir(&export_dir) - .map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))? - { - let entry = entry - .map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?; - let file_type = entry.file_type().map_err(|error| { - format!( - "读取试玩包文件类型失败:{}: {error}", - entry.path().display() - ) - })?; - if file_type.is_symlink() || !file_type.is_file() { - continue; - } - let file_name = match entry.file_name().to_str() { - Some(value) => value.to_string(), - None => continue, - }; - if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") { - continue; - } - let package_relative_path = - normalize_export_package_entry_path(&format!("exports/{file_name}"))?; - let metadata = entry.metadata().map_err(|error| { - format!("读取试玩包元数据失败:{}: {error}", entry.path().display()) - })?; - let modified_at = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) - .unwrap_or(0); - packages.push(LocalProjectExportPackageSummary { - package_path: entry.path().to_string_lossy().into_owned(), - package_relative_path, - total_bytes: metadata.len(), - modified_at, - }); - } - packages.sort_by(|left, right| { - (right.modified_at, &right.package_relative_path) - .cmp(&(left.modified_at, &left.package_relative_path)) - }); - - Ok(LocalProjectExportPackagesResult { - project_path: root.to_string_lossy().into_owned(), - packages, - }) -} - -/// 发布导出前确保 `exports/README.md` 存在。 -/// -/// 缺失时按项目 manifest 生成最小发布说明;已有文件原样保留,避免覆盖作者或 Agent 的正式文案。 -/// README 本身仍沿用试玩包的普通文件 / 符号链接安全口径。 -fn ensure_project_export_readme(root: &Path) -> Result { - let readme_path = resolve_local_project_path(root, "exports/README.md")?; - match fs::symlink_metadata(&readme_path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err("试玩包不能包含符号链接:exports/README.md".to_string()); - } - if !metadata.is_file() { - return Err("exports/README.md 必须是普通文件".to_string()); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let manifest = read_manifest_for_project(root)?; - let content = render_project_export_readme(&manifest); - write_game_creator_private_file(&readme_path, content.as_bytes(), "试玩包发布说明")?; - } - Err(error) => { - return Err(format!( - "读取 exports/README.md 元数据失败:{}: {error}", - readme_path.display() - )); - } - } - Ok(readme_path) -} - -fn render_project_export_readme(manifest: &GameCreationAppManifest) -> String { - let title = manifest.name.trim(); - let title = if title.is_empty() { - "试玩游戏" - } else { - title - }; - let summary = manifest - .goal - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("这是一个可直接在浏览器中试玩的游戏原型。"); - format!( - "# {title} 试玩说明\n\n## 简介\n\n{summary}\n\n## 试玩方式\n\n- 解压试玩包后打开根目录的 `index.html`。\n- 使用游戏内提示开始试玩。\n\n## 反馈\n\n请记录问题发生步骤、预期结果和实际结果,便于快速定位。\n" - ) -} - -pub(crate) fn collect_project_export_package_files( - root: &Path, -) -> Result, String> { - let mut files = Vec::new(); - let game_root = crate::preview::project_game_root(root); - let built = game_root == root.join("dist") || game_root == root.join("game/dist"); - if built { - let relative = relative_project_path(root, &game_root)?; - collect_project_export_package_dir_files(root, &relative, &mut files)?; - for (name, _, _) in &mut files { - *name = format!( - "game/{}", - name.strip_prefix(&format!("{relative}/")) - .ok_or("构建产物路径非法")? - ); - } - // AGC 上传的素材登记在项目根 `assets/`,部分 Phaser 工程直接以 - // `assets/uploads/...` 作为运行 URL,没有通过 Vite public 目录复制到 dist。 - // 构建产物优先;只有 dist 没有同路径时才补入根目录素材,避免重复 ZIP 条目。 - let assets_root = resolve_local_project_path(root, "assets")?; - if assets_root.exists() { - let mut root_assets = Vec::new(); - collect_project_export_package_dir_files(root, "assets", &mut root_assets)?; - for entry in root_assets { - let dist_path = format!("game/{}", entry.0); - if !files.iter().any(|(path, _, _)| path == &dist_path) { - files.push(entry); - } - } - } - } else { - collect_project_export_package_dir_files(root, "game", &mut files)?; - } - if !built && resolve_local_project_path(root, "assets")?.exists() { - collect_project_export_package_dir_files(root, "assets", &mut files)?; - } - let readme_path = resolve_local_project_path(root, "exports/README.md")?; - let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?; - if !readme_metadata.is_file() { - return Err("导出试玩包前需要先生成 exports/README.md".to_string()); - } - let readme_size = readme_metadata.len(); - files.push(("exports/README.md".to_string(), readme_path, readme_size)); - files.sort_by(|left, right| left.0.cmp(&right.0)); - Ok(files) -} - -pub(crate) fn collect_project_export_package_dir_files( - root: &Path, - relative_dir: &str, - files: &mut Vec<(String, PathBuf, u64)>, -) -> Result<(), String> { - let dir = resolve_local_project_path(root, relative_dir)?; - if !dir.exists() { - return Ok(()); - } - let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?; - if !dir_metadata.is_dir() { - return Err(format!("{relative_dir} 必须是目录")); - } - let mut dirs = vec![dir]; - while let Some(current_dir) = dirs.pop() { - for entry in fs::read_dir(¤t_dir) - .map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))? - { - let entry = entry - .map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?; - let file_type = entry.file_type().map_err(|error| { - format!("读取导出文件类型失败:{}: {error}", entry.path().display()) - })?; - let relative_path = - normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?; - if file_type.is_symlink() { - return Err(format!("试玩包不能包含符号链接:{relative_path}")); - } - if file_type.is_dir() { - dirs.push(entry.path()); - } else if file_type.is_file() { - let size = entry - .metadata() - .map_err(|error| { - format!( - "读取导出文件元数据失败:{}: {error}", - entry.path().display() - ) - })? - .len(); - files.push((relative_path, entry.path(), size)); - } - } - } - Ok(()) -} - -pub(crate) fn checked_export_package_metadata( - path: &Path, - relative_path: &str, -) -> Result { - let metadata = fs::symlink_metadata(path) - .map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() { - return Err(format!( - "试玩包不能包含符号链接:{}", - normalize_export_package_entry_path(relative_path)? - )); - } - Ok(metadata) -} - -pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result { - if relative_path.chars().any(char::is_control) { - return Err("试玩包条目路径不能包含控制字符".to_string()); - } - normalize_relative_path(relative_path) -} - -#[cfg(test)] -mod npm_export_tests { - use super::*; - - #[test] - fn declared_version_major_reads_semver_prefixes_and_ranges() { - assert_eq!(declared_version_major("4.2.1"), Some(4)); - assert_eq!(declared_version_major("^4.2.1"), Some(4)); - assert_eq!(declared_version_major("~4.2.1"), Some(4)); - assert_eq!(declared_version_major(">=4 <5"), Some(4)); - assert_eq!(declared_version_major("3.90.0"), Some(3)); - assert_eq!(declared_version_major("latest"), None); - assert_eq!(declared_version_major("*"), None); - assert_eq!(declared_version_major("workspace:*"), None); - } - - #[test] - fn declared_dependency_spec_accepts_string_and_object_forms() { - let package = serde_json::json!({ - "dependencies": { "phaser": "^4.2.1" }, - "devDependencies": { "vite": { "version": "^6.2.0" } } - }); - assert_eq!( - declared_dependency_spec(&package, "phaser").as_deref(), - Some("^4.2.1") - ); - assert_eq!( - declared_dependency_spec(&package, "vite").as_deref(), - Some("^6.2.0") - ); - assert_eq!(declared_dependency_spec(&package, "three"), None); - } - - #[test] - fn publish_precheck_rejects_packages_over_the_platform_limit() { - let limit = shared_contracts::game_distribution::GAME_DISTRIBUTION_MAX_PACKAGE_BYTES; - // 边界:正好等于上限放行,差一个字节也放行(服务端同样按 `>` 判定)。 - ensure_within_platform_package_limit(limit).unwrap(); - ensure_within_platform_package_limit(limit - 1).unwrap(); - // 超限:错误文案必须给出实际体积与上限,作者才能据此精简。 - let error = ensure_within_platform_package_limit(limit + 1).expect_err("over limit"); - assert!(error.contains("超过平台上限"), "{error}"); - assert!(error.contains("200 MiB"), "{error}"); - assert!(error.contains("MiB"), "{error}"); - // 本地导出上限更宽松(512 MiB):超平台的包必须在读包阶段就被拦下, - // 不能等到上传后由服务端 413。 - assert!(limit < MAX_PROJECT_EXPORT_PACKAGE_BYTES); - } - - #[test] - fn npm_package_contains_dist_project_root_assets_and_publish_readme() { - // 仓库约定的跨平台临时根(基于 std::env::temp_dir()):不依赖 HOME, - // Windows 上只有 USERPROFILE 时同样可跑。 - let root = crate::tests::canonical_test_tempdir("agc-npm-export-"); - for directory in [ - "dist/assets/uploads", - "assets/uploads", - "exports", - "node_modules", - "game", - ] { - fs::create_dir_all(root.path().join(directory)).unwrap(); - } - for file in [ - "package.json", - "dist/index.html", - "dist/assets/main.js", - "dist/assets/uploads/shared.png", - "assets/hero.png", - "assets/uploads/shared.png", - "exports/README.md", - "node_modules/private.js", - "game/source.js", - ] { - fs::write(root.path().join(file), "test").unwrap(); - } - let files = collect_project_export_package_files(root.path()).unwrap(); - let names = files - .iter() - .map(|(name, _, _)| name.as_str()) - .collect::>(); - // 构建工程(dist 存在)的试玩包 = dist 构建产物(带 `game/` 前缀,归一化成 - // 发行包时剥离)+ 项目根 `assets/**` 中 dist 未包含的素材(AGC 上传素材以 - // `assets/uploads/...` 作为运行 URL,未必经 Vite public 复制进 dist) - // + 发布说明。`node_modules` 与 `game/` 源码不进包。 - assert_eq!( - names, - vec![ - "assets/hero.png", - "exports/README.md", - "game/assets/main.js", - "game/assets/uploads/shared.png", - "game/index.html", - ] - ); - // 同名素材以 dist 构建产物为准:根 `assets/uploads/shared.png` 会被丢弃, - // 否则归一化剥离 `game/` 前缀后会与 dist 条目重名。 - let shared = files - .iter() - .find(|(name, _, _)| name == "game/assets/uploads/shared.png") - .expect("dist asset entry"); - assert_eq!( - shared.1, - root.path().join("dist/assets/uploads/shared.png"), - "同路径素材必须保留 dist 构建产物" - ); - } - - #[test] - fn built_project_publish_payload_includes_uploaded_assets_and_prefers_dist_assets() { - // 与同批另 5 条 publish_export_tests 同口径:用仓库的跨平台临时根,不依赖 HOME。 - let root = crate::tests::canonical_test_tempdir("agc-publish-payload-"); - for directory in [ - "dist/assets/uploads", - "assets/uploads", - "exports", - "node_modules", - "game/src", - ] { - fs::create_dir_all(root.path().join(directory)).unwrap(); - } - // 构建入口用根绝对引用 `/assets/main.js`:发行包必须把它改写成发行目录内可用的 - // 相对引用,否则包根 index.html 取不到同一包内的资源。 - let built_entry = ""; - for (file, content) in [ - ("package.json", "{\"scripts\":{\"build\":\"vite build\"}}"), - ("dist/index.html", built_entry), - ("dist/assets/main.js", "dist-main"), - ("dist/assets/uploads/shared.png", "dist-shared"), - ("assets/uploads/food.png", "root-food"), - ("assets/uploads/shared.png", "root-shared"), - ("exports/README.md", "publish notes"), - ("node_modules/private.js", "private"), - ("game/src/app.js", "game source"), - ] { - fs::write(root.path().join(file), content).unwrap(); - } - - let exported = export_local_project_package_at(root.path()).expect("export built project"); - let payload = - read_local_project_export_package_at(root.path(), &exported.package_relative_path) - .expect("normalize publish package"); - - // 归一化后的发行包条目:`game/` 前缀已剥掉、dist 产物落在包根、项目根 `assets/**` - // 与 dist 独有路径并存、发布说明在包内;`node_modules` 与 `game/` 源码不入包。 - let names = payload - .files - .iter() - .map(|file| file.path.clone()) - .collect::>(); - assert_eq!( - names, - vec![ - "assets/main.js", - "assets/uploads/food.png", - "assets/uploads/shared.png", - "exports/README.md", - "index.html", - ] - ); - assert!( - names.iter().all(|name| !name.starts_with("game/")), - "发行包不得保留试玩包的 game/ 前缀:{names:?}" - ); - - let mut archive = zip::ZipArchive::new(Cursor::new(payload.package_bytes)) - .expect("read normalized package"); - let read_entry = |archive: &mut zip::ZipArchive>>, name: &str| { - let mut bytes = Vec::new(); - archive - .by_name(name) - .unwrap_or_else(|error| panic!("{name}: {error}")) - .read_to_end(&mut bytes) - .expect("read entry"); - bytes - }; - // dist 未包含的 AGC 上传素材(运行 URL `assets/uploads/...`)必须补入发行包。 - assert_eq!( - read_entry(&mut archive, "assets/uploads/food.png"), - b"root-food" - ); - // 同路径素材以 dist 构建产物为准,根版本被丢弃(否则归一化后会重名)。 - assert_eq!( - read_entry(&mut archive, "assets/uploads/shared.png"), - b"dist-shared" - ); - assert_eq!(read_entry(&mut archive, "assets/main.js"), b"dist-main"); - assert_eq!( - read_entry(&mut archive, "exports/README.md"), - b"publish notes" - ); - let entry_html = - String::from_utf8(read_entry(&mut archive, "index.html")).expect("utf8 entry"); - assert!( - entry_html.contains("src=\"assets/main.js\""), - "{entry_html}" - ); - assert!(!entry_html.contains("\"/assets/"), "{entry_html}"); - // `node_modules` 与 `game/` 源码不得随包发布。 - for forbidden in ["node_modules/private.js", "src/app.js", "game/src/app.js"] { - assert!( - archive.by_name(forbidden).is_err(), - "{forbidden} 不应进入发行包" - ); - } - } -} - -#[cfg(test)] -mod publish_export_tests { - use super::*; - - /// 发布前工程栈校验与「已有可玩入口时跳过构建」的回归覆盖。 - /// - /// 这些用例原在 `src/tests/project.rs`,随 master `839051f53` 删除该文件、merge - /// `783ca014e` 采纳 master 侧而丢失,这里按原断言意图重建在现役测试布局内。 - fn publish_project_tempdir(prefix: &str) -> tempfile::TempDir { - crate::tests::canonical_test_tempdir(prefix) - } - - fn write_game_package_json(root: &Path, package: serde_json::Value) { - fs::write( - root.join("game/package.json"), - serde_json::to_string_pretty(&package).expect("serialize package json"), - ) - .expect("write package json"); - } - - #[test] - fn publish_stack_validation_accepts_scaffolded_phaser4_vite_project() { - let temporary = publish_project_tempdir("agc-publish-stack-ok-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-ok", "栈校验通过项目") - .expect("project init"); - - ensure_publish_project_stack(root).expect("Phaser 4 + Vite 脚手架应通过发布前工程栈校验"); - } - - #[test] - fn publish_stack_validation_reports_actionable_error_for_phaser3() { - let temporary = publish_project_tempdir("agc-publish-stack-phaser3-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-phaser3", "Phaser 3 项目") - .expect("project init"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-phaser3", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": "^3.90.0" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - - let error = ensure_publish_project_stack(root).expect_err("Phaser 3 必须被拦下"); - - assert!(error.contains("只支持 Phaser 4"), "{error}"); - assert!(error.contains("3.90.0"), "错误要带上当前声明版本:{error}"); - assert!( - error.contains("npm install phaser@4.2.1"), - "错误要给出可操作修复:{error}" - ); - // 错误文案只带项目内相对路径,不带本地绝对路径。 - assert!( - !error.contains(&root.to_string_lossy().to_string()), - "{error}" - ); - } - - #[test] - fn publish_stack_validation_requires_phaser_dependency_and_vite() { - let temporary = publish_project_tempdir("agc-publish-stack-missing-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-missing", "缺依赖项目") - .expect("project init"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-no-phaser", - "private": true, - "scripts": { "build": "vite build" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - let error = ensure_publish_project_stack(root).expect_err("缺 phaser 必须被拦下"); - assert!(error.contains("未声明 phaser 依赖"), "{error}"); - - // Phaser 4 但没有 Vite(依赖与配置都不存在):错误必须指向 Vite。 - fs::remove_file(root.join("game/vite.config.js")).expect("remove scaffold vite config"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-webpack", - "private": true, - "scripts": { "build": "webpack" }, - "dependencies": { "phaser": "4.2.1" } - }), - ); - let error = ensure_publish_project_stack(root).expect_err("非 Vite 工程必须被拦下"); - assert!(error.contains("vite"), "{error}"); - assert!(error.contains("Vite 构建"), "{error}"); - } - - #[test] - fn publish_stack_validation_reads_semver_ranges_and_lockfile_versions() { - let temporary = publish_project_tempdir("agc-publish-stack-semver-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-semver", "版本区间项目") - .expect("project init"); - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-semver", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": ">=4 <5" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - ensure_publish_project_stack(root).expect(">=4 <5 属于 Phaser 4"); - - // 声明成 `*` 时以 package-lock.json 的解析版本为准。 - write_game_package_json( - root, - serde_json::json!({ - "name": "stack-star", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": "*" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - fs::write( - root.join("game/package-lock.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "stack-star", - "lockfileVersion": 3, - "packages": { "": {}, "node_modules/phaser": { "version": "3.90.0" } } - })) - .expect("serialize lockfile"), - ) - .expect("write lockfile"); - let error = ensure_publish_project_stack(root).expect_err("锁文件里的 Phaser 3 也要拦下"); - assert!(error.contains("只支持 Phaser 4"), "{error}"); - } - - #[test] - fn publish_stack_validation_rejects_projects_without_npm_build_workspace() { - let temporary = publish_project_tempdir("agc-publish-stack-legacy-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-stack-legacy", "无 npm 构建项目") - .expect("project init"); - // 去掉 npm 工作区(没有 package.json 也就没有 build 脚本),单文件项目不属于客户端首版合同。 - fs::remove_file(root.join("game/package.json")).expect("remove game package json"); - - let error = ensure_publish_project_stack(root).expect_err("单文件项目必须失败关闭"); - assert!(error.contains("只支持 Phaser 4"), "{error}"); - } - - #[tokio::test] - async fn publish_export_skips_build_when_phaser_vite_entry_exists() { - let temporary = publish_project_tempdir("agc-publish-skip-build-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-publish-skip", "已构建发布项目") - .expect("project init"); - // 已有可玩构建产物:发布会直接打包,不再触发 npm run build。 - fs::create_dir_all(root.join("game/dist")).expect("create dist directory"); - write_game_package_json( - root, - serde_json::json!({ - "name": "publish-skip-fixture", - "private": true, - "scripts": { "build": "vite build" }, - "dependencies": { "phaser": "4.2.1" }, - "devDependencies": { "vite": "^6.2.0" } - }), - ); - fs::write(root.join("game/vite.config.js"), "export default {};\n") - .expect("write vite config"); - fs::write( - root.join("game/dist/index.html"), - "", - ) - .expect("write playable html"); - - let result = export_local_project_package_for_publish_at(root) - .await - .expect("已有可玩入口且缺少 README 时应自动生成并直接导出"); - - assert!(result.package_relative_path.ends_with(".zip")); - let readme = - fs::read_to_string(root.join("exports/README.md")).expect("read generated readme"); - assert!(readme.contains("# 已构建发布项目 试玩说明"), "{readme}"); - let payload = read_local_project_export_package_at(root, &result.package_relative_path) - .expect("read publishable package"); - assert!(payload.files.iter().any(|file| file.path == "index.html")); - assert!(payload - .files - .iter() - .any(|file| file.path == "exports/README.md")); - let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); - assert!( - !log.contains("project.verify build"), - "已有可玩入口时不应触发构建:{log}" - ); - } - - /// 单文件可玩 HTML:原 `tests/mod.rs::fake_llm_game_draft().game_html` 的最小内联副本, - /// 满足 `validate_game_html_smoke`(画布、主循环、输入、目标/胜负/重开文案、自包含)。 - const PLAYABLE_GAME_HTML: &str = r#" - - 月光弹幕厨房 - - - - -"#; - - /// 单文件 HTML 项目(非构建布局):脚手架提供 manifest 与目录,再去掉 npm 工作区脚手架, - /// 换成可玩入口。等价于已删除的 `tests/mod.rs::init_existing_html_project_at`。 - fn init_existing_html_project(root: &Path, project_id: &str, name: &str) { - init_local_game_project_at(root, project_id, name).expect("project init"); - for relative in [ - "game/package.json", - "game/package-lock.json", - "game/vite.config.js", - "game/style.css", - "game/game.js", - ] { - let _ = fs::remove_file(root.join(relative)); - } - fs::write(root.join("game/index.html"), PLAYABLE_GAME_HTML).expect("write playable html"); - } - - #[test] - fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() { - let temporary = publish_project_tempdir("agc-publish-plan-game-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-plan-game", "Phaser 工程").expect("project init"); - - // 脚手架是 game/ + vite build(Phaser 4 工程):缺依赖时必须先 install。 - let plan = resolve_publish_build_plan(root).expect("解析构建计划"); - assert_eq!(plan.cwd_relative, "game"); - assert_eq!(plan.command, "vite build"); - assert!( - plan.needs_dependency_install, - "缺少 game/node_modules 时应先装依赖" - ); - - fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules"); - let installed = resolve_publish_build_plan(root).expect("解析构建计划"); - assert!( - !installed.needs_dependency_install, - "已有依赖时不应重复 install" - ); - } - - #[test] - fn publish_build_plan_falls_back_to_root_npm_build() { - let temporary = publish_project_tempdir("agc-publish-plan-root-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-plan-root", "根工程构建").expect("project init"); - // 去掉 game 子工程的 build,改用项目根 npm 工程构建。 - write_game_package_json( - root, - serde_json::json!({ - "name": "plan-root-game", - "private": true, - "scripts": { "check": "node -e \"process.exit(0)\"" } - }), - ); - fs::write( - root.join("package.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "plan-root-fixture", - "private": true, - "scripts": { "build": "node build-root.mjs" } - })) - .expect("serialize root package json"), - ) - .expect("write root package json"); - - let plan = resolve_publish_build_plan(root).expect("解析构建计划"); - assert_eq!(plan.cwd_relative, "."); - assert_eq!(plan.command, "node build-root.mjs"); - assert!(!plan.needs_dependency_install); - } - - #[tokio::test] - async fn publish_export_runs_project_build_before_packaging() { - let temporary = publish_project_tempdir("agc-publish-auto-build-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-auto-build", "自动构建发布项目") - .expect("project init"); - // 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。 - fs::write( - root.join("package.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "publish-auto-build-fixture", - "private": true, - "scripts": { "build": "node build-publish.mjs" } - })) - .expect("serialize package json"), - ) - .expect("write package json"); - fs::write( - root.join("build-publish.mjs"), - r#"import { mkdirSync, writeFileSync } from 'node:fs'; -mkdirSync('dist/assets', { recursive: true }); -writeFileSync('dist/index.html', 'Auto Build

AUTO-BUILD

'); -writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";'); -"#, - ) - .expect("write build script"); - update_manifest_task_status_at( - root, - "code-prototype", - GameCreationAppTaskStatus::Completed, - ) - .expect("mark prototype completed"); - write_local_project_file_at(root, "exports/README.md", "publish notes") - .expect("write readme"); - - let result = export_local_project_package_for_publish_at(root) - .await - .expect("发布前构建并导出"); - - assert!(root.join("dist/index.html").is_file()); - assert!(root.join("dist/assets/app.js").is_file()); - assert!(result - .package_relative_path - .starts_with("exports/playtest-package-")); - let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); - assert!( - log.contains("project.verify build"), - "发布前应记录一次 project.verify build:{log}" - ); - } - - #[tokio::test] - async fn publish_export_blocks_build_until_runnable_prototype_is_completed() { - let temporary = publish_project_tempdir("agc-publish-prototype-pending-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-prototype-pending", "原型未完成项目") - .expect("project init"); - fs::write( - root.join("package.json"), - serde_json::to_string_pretty(&serde_json::json!({ - "name": "publish-prototype-pending-fixture", - "private": true, - "scripts": { "build": "node build-should-not-run.mjs" } - })) - .expect("serialize package json"), - ) - .expect("write package json"); - fs::write( - root.join("build-should-not-run.mjs"), - "import { writeFileSync } from 'node:fs'; writeFileSync('build-ran.txt', 'yes');", - ) - .expect("write build marker script"); - - let error = export_local_project_package_for_publish_at(root) - .await - .expect_err("原型未完成时禁止触发用户项目构建"); - - assert!( - error.contains("首个可运行原型尚未完成"), - "错误应说明原型未完成:{error}" - ); - assert!(!root.join("build-ran.txt").exists()); - let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); - assert!( - !log.contains("project.verify build"), - "原型未完成时不应执行项目构建:{log}" - ); - } - - #[tokio::test] - async fn publish_export_reports_actionable_error_without_entry_or_build_script() { - let temporary = publish_project_tempdir("agc-publish-no-entry-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-no-entry", "缺少可玩入口项目") - .expect("project init"); - update_manifest_task_status_at( - root, - "code-prototype", - GameCreationAppTaskStatus::Completed, - ) - .expect("mark prototype completed"); - // 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。 - write_game_package_json( - root, - serde_json::json!({ - "name": "publish-no-entry-fixture", - "private": true, - "scripts": { "check": "node -e \"process.exit(0)\"" } - }), - ); - - let error = export_local_project_package_for_publish_at(root) - .await - .expect_err("缺少入口且没有 build 脚本时必须失败关闭"); - - assert!( - error.contains("还没有可玩入口"), - "错误应说明缺少可玩入口:{error}" - ); - assert!( - error.contains("build 脚本"), - "错误应指向 build 脚本:{error}" - ); - } - - #[test] - fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() { - let temporary = publish_project_tempdir("agc-publish-payload-"); - let root = temporary.path(); - init_existing_html_project(root, "project-publish", "在线试玩项目"); - write_local_project_file_at(root, "exports/README.md", "publish notes") - .expect("write readme"); - - let exported = export_local_project_package_at(root).expect("export package"); - assert_eq!( - fs::read_to_string(root.join("exports/README.md")).unwrap(), - "publish notes" - ); - let payload = read_local_project_export_package_at(root, &exported.package_relative_path) - .expect("read publish payload"); - - assert_eq!( - payload.package_relative_path, - exported.package_relative_path - ); - assert_eq!( - payload.package_size_bytes, - payload.package_bytes.len() as u64 - ); - assert_eq!(payload.files.len(), 2); - assert!(payload.files.iter().any(|file| file.path == "index.html")); - assert!(payload - .files - .iter() - .any(|file| file.path == "exports/README.md")); - let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes)) - .expect("read normalized package"); - let names = (0..archive.len()) - .map(|index| { - archive - .by_index(index) - .expect("normalized entry") - .name() - .to_string() - }) - .collect::>(); - assert!(names.iter().any(|name| name == "index.html")); - assert!(!names.iter().any(|name| name.starts_with("game/"))); - assert_eq!(payload.package_sha256.len(), 64); - assert!(payload - .package_sha256 - .chars() - .all(|value| value.is_ascii_hexdigit())); - } - - #[test] - fn local_project_export_package_list_only_returns_recent_playtest_zips() { - let temporary = publish_project_tempdir("agc-export-list-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("exports/playtest-package-001.zip"), "old").expect("write old zip"); - fs::write(root.join("exports/not-playtest.zip"), "ignore").expect("write ignored zip"); - fs::write(root.join("exports/playtest-package-002.zip"), "newer").expect("write newer zip"); - fs::write(root.join("game/playtest-package-003.zip"), "wrong dir") - .expect("write wrong dir zip"); - - let result = list_local_project_export_packages_at(root).expect("list packages"); - - assert_eq!(result.project_path, root.to_string_lossy().into_owned()); - let paths = result - .packages - .iter() - .map(|package| package.package_relative_path.as_str()) - .collect::>(); - assert_eq!( - paths, - vec![ - "exports/playtest-package-002.zip", - "exports/playtest-package-001.zip" - ] - ); - assert_eq!(result.packages[0].total_bytes, 5); - assert!(result.packages[0].modified_at > 0); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_list_skips_symlink_packages() { - let temporary = publish_project_tempdir("agc-export-list-link-"); - let root = temporary.path(); - init_local_game_project_at(root, "project-1", "像素动作原型").expect("project init"); - fs::write(root.join("exports/playtest-package-real.zip"), "real").expect("write zip"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("exports/playtest-package-link.zip"), - ) - .expect("create package symlink"); - - let result = list_local_project_export_packages_at(root).expect("list packages"); - - assert_eq!(result.packages.len(), 1); - assert_eq!( - result.packages[0].package_relative_path, - "exports/playtest-package-real.zip" - ); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_rejects_symlink_assets() { - let temporary = publish_project_tempdir("agc-export-link-assets-"); - let root = temporary.path(); - init_existing_html_project(root, "project-1", "像素动作原型"); - write_local_project_file_at(root, "exports/README.md", "playtest notes") - .expect("write readme"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("assets/private.md"), - ) - .expect("create symlink asset"); - - let error = export_local_project_package_at(root).expect_err("symlink should be rejected"); - - assert!(error.contains("试玩包不能包含符号链接")); - assert!(!root.join("exports").join("playtest-package-").exists()); - } - - #[cfg(unix)] - #[test] - fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { - let temporary = publish_project_tempdir("agc-export-link-runtime-"); - let root = temporary.path(); - init_existing_html_project(root, "project-1", "像素动作原型"); - write_local_project_file_at(root, "memory/project.md", "private memory") - .expect("write memory"); - std::os::unix::fs::symlink( - root.join("memory/project.md"), - root.join("exports/README.md"), - ) - .expect("create readme symlink"); - - let error = export_local_project_package_at(root).expect_err("readme symlink rejected"); - - assert!(error.contains("符号链接"), "{error}"); - fs::remove_file(root.join("exports/README.md")).expect("remove readme symlink"); - write_local_project_file_at(root, "exports/README.md", "playtest notes") - .expect("write readme"); - fs::remove_dir_all(root.join("assets")).expect("remove assets dir"); - fs::create_dir_all(root.join("memory/assets")).expect("create memory assets"); - fs::write(root.join("memory/assets/hero.txt"), "private asset").expect("write asset"); - std::os::unix::fs::symlink(root.join("memory/assets"), root.join("assets")) - .expect("create assets symlink"); - - let error = export_local_project_package_at(root).expect_err("assets symlink rejected"); - - assert!(error.contains("符号链接"), "{error}"); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 605774f61..5de9bde7b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -898,7 +898,7 @@ fn validate_external_editor_binding_access_shape( fn normalize_external_editor_binding_service_origin(value: &str) -> Result { let value = value.trim().trim_end_matches('/'); let parsed = reqwest::Url::parse(value) - .map_err(|_| "External Editor binding 服务地址无效".to_string())?; + .map_err(|error| format!("External Editor binding 服务地址无效:{error}"))?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() || !parsed.username().is_empty() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index bb5b21789..45e2168ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -397,15 +397,39 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool { || relative_path == PROJECT_INDEX_PATH || relative_path.starts_with(".agent/checkpoints/") || relative_path.starts_with(".agent/runtime/") - || should_skip_project_snapshot_path(relative_path) + || should_skip_project_derived_path(relative_path) } -/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的 -/// 本机控制面,不参与其中。 +/// Agent 视角的排除口径:`.agent` 是本机控制面,写入门禁(`agent/direct_patch.rs`)与 +/// Agent 上下文(prompt / tool bridge)拿它判断「Agent 能不能碰、看不看得到」。 +/// +/// 它**不**包含 `.export`:那个目录必须让 code agent 能写。把项目内容当数据来读的消费者 +/// (索引、checkpoint、git 检查)请改用 `should_skip_project_derived_path`。 pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { project_snapshot_path_is_excluded(relative_path, false) } +/// 索引、checkpoint 与 git 检查共用的排除口径:在本机控制面之外,再排除本机派生的导出工作目录 +/// (`.export`,见 `PROJECT_DERIVED_EXCLUDED_COMPONENTS`)。 +/// +/// 这些文件能从项目源码重跑出来,不该进索引、checkpoint 或版本库视角,也不该被当成项目变更; +/// 但 Agent 必须能写它们,所以不能并进 `should_skip_project_snapshot_path`——那份列表同时是 +/// `agc_apply_patch` 的写入门禁,并进去会让首次适配直接卡死。 +/// +/// `.export` 只认项目根那一层(`EXPORT_WORK_RELATIVE_DIR` 就是 `root/.export`):源码里合法的 +/// 嵌套目录 `game/.export/...` 是用户自己的文件,不能被误伤。 +pub(crate) fn should_skip_project_derived_path(relative_path: &str) -> bool { + project_snapshot_path_is_excluded(relative_path, false) + || relative_path + .split('/') + .find(|component| !component.is_empty()) + .is_some_and(|component| { + PROJECT_DERIVED_EXCLUDED_COMPONENTS + .iter() + .any(|candidate| component.eq_ignore_ascii_case(candidate)) + }) +} + /** * 项目快照同步(上传)的排除口径。 * @@ -416,6 +440,10 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool { * * 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、 * `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。 + * + * 另外,同步还多认 `PROJECT_DERIVED_EXCLUDED_COMPONENTS`(`.export`):本机派生 + * 的导出工作目录不进同步,但 Agent 仍要能读写它。`.export` 同样只认项目根那一层, + * 源码里的嵌套 `game/.export/...` 不在此列。 */ pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool { project_snapshot_path_is_excluded(relative_path, true) @@ -488,6 +516,19 @@ const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[ /// 随快照同步;其它数据库与转储后缀仍然排除。 const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"]; +/** + * 本机派生的导出工作目录(`.export`):Agent 可见,其它消费者一律排除。 + * + * 不能并进上面的通用列表:那份列表同时是 Agent 的写入门禁(`agent/direct_patch.rs` 直接拿它 + * 拒绝路径),而 `.export` 恰恰必须让 code agent 能写——导出注册表、打包脚本拷贝都由它生成。 + * 放进去会让 `agc_apply_patch` 回「不得修改受保护或排除路径」,首次适配直接卡死。 + * + * 也不能只排快照同步:索引会把多 MB 的 zip 整份读进内存算 checksum,checkpoint 会把它复制进 + * `.agent/checkpoints`,git 检查会把它当未跟踪文件放出去,允许提交。所以「把项目内容当数据 + * 来读」的消费者(索引、checkpoint、git 检查)都走 `should_skip_project_derived_path`。 + */ +const PROJECT_DERIVED_EXCLUDED_COMPONENTS: &[&str] = &[".export"]; + fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool { let components = relative_path .split('/') @@ -497,7 +538,11 @@ fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: b if components.iter().any(|component| { PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str()) && !(include_agent_state && component == ".agent") - }) { + }) || (include_agent_state + && components.first().is_some_and(|component| { + PROJECT_DERIVED_EXCLUDED_COMPONENTS.contains(&component.as_str()) + })) + { return true; } let Some(file_name) = components.last() else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 44e55db43..2a4c3e5b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -611,7 +611,7 @@ pub(crate) fn init_local_game_project_at( && !root.join("game/index.html").exists() && !root.join("package.json").exists() && !root.join("game/package.json").exists(); - for relative in ["game", "assets", "memory", "memory/agents", "exports"] { + for relative in ["game", "assets", "memory", "memory/agents"] { let path = root.join(relative); ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs index ab0f99690..05a8417d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -64,7 +64,7 @@ fn imports_godot_project_without_creating_parallel_game_directories() { fs::read_to_string(root.join("scenes/main.tscn")).expect("read preserved scene"), "[gd_scene format=3]\n" ); - for unexpected in ["game", "assets", "memory", "exports"] { + for unexpected in ["game", "assets", "memory"] { assert!( !root.join(unexpected).exists(), "Godot import must not create {unexpected}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs index 024baef1d..4290d6005 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs @@ -111,7 +111,7 @@ fn new_record(context: &ProjectModelUsageContext, source: &str) -> ModelUsageRec fn read_private_bytes(path: &Path, max_bytes: u64) -> Result>, String> { match fs::symlink_metadata(path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(_) => return Err("读取项目模型记录元数据失败".to_string()), + Err(error) => return Err(format!("读取项目模型记录元数据失败:{error}")), Ok(_) => {} } let (file, metadata) = open_project_private_regular_file(path, MODEL_USAGE_LABEL)?; @@ -121,7 +121,7 @@ fn read_private_bytes(path: &Path, max_bytes: u64) -> Result>, St let mut content = Vec::new(); file.take(max_bytes + 1) .read_to_end(&mut content) - .map_err(|_| "读取项目模型记录失败".to_string())?; + .map_err(|error| format!("读取项目模型记录失败:{error}"))?; if content.len() as u64 > max_bytes { return Err("项目模型记录超过读取上限".to_string()); } @@ -145,7 +145,7 @@ fn read_usage_records(path: &Path) -> Result<(Vec, u64), Strin return Err("项目模型记录超过记录上限".to_string()); } let record: ModelUsageRecord = serde_json::from_slice(line) - .map_err(|_| "项目模型记录损坏,已保留原文件".to_string())?; + .map_err(|error| format!("项目模型记录损坏,已保留原文件:{error}"))?; validate_record(&record)?; records.push(record); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 4aa2ec76e..7da3b0e59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1454,8 +1454,8 @@ fn resolve_resource_edit_source( RESOURCE_EDIT_TEXT_MAX_BYTES, "源文本资源", )?; - let text = - String::from_utf8(bytes).map_err(|_| "文本资源必须使用 UTF-8 编码".to_string())?; + let text = String::from_utf8(bytes) + .map_err(|error| format!("文本资源必须使用 UTF-8 编码:{error}"))?; return Ok(ResourceEditSourceSnapshot { canonical_resource_id, source_path: Some(path), @@ -1507,7 +1507,7 @@ fn resolve_resource_edit_source( let (source_width, source_height) = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "生成角色动画前无法解析源图片尺寸".to_string())?; + .map_err(|error| format!("生成角色动画前无法解析源图片尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -1766,39 +1766,38 @@ async fn request_resource_edit_upload_ticket( ) .send() .await - .map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 创建源资源上传凭证请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 创建源资源上传凭证返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "创建源资源上传凭证失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("创建源资源上传凭证失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源上传凭证失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源上传凭证失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -1877,11 +1876,11 @@ async fn upload_resource_edit_source( .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "源资源上传地址不安全".to_string())?; + .map_err(|error| format!("源资源上传地址无效:{error}"))?; access.validate_frozen_session()?; let client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建源资源上传客户端".to_string())?; + .map_err(|error| format!("无法创建源资源上传客户端:{error}"))?; access.validate_frozen_session()?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { @@ -1891,29 +1890,26 @@ async fn upload_resource_edit_source( let part = Part::bytes(bytes.clone()) .file_name(file_name) .mime_str(&source.media_type) - .map_err(|_| "源资源媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("源资源媒体类型不能用于上传:{error}"))?; access.validate_frozen_session()?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "result-unknown: 上传源资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 上传源资源请求失败:{error}"))?; let post_response_session_error = access.validate_frozen_session().err(); if response.status().as_u16() != ticket.success_action_status { + let status = response.status(); if response.status().is_server_error() { - return Err(format!( - "result-unknown: 上传源资源返回 HTTP {}", - response.status().as_u16() - )); + let base = format!("result-unknown: 上传源资源返回 HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } if let Some(error) = post_response_session_error { - return Err(error); + return Err(append_editor_api_reason(error, response).await); } - return Err(format!( - "上传源资源失败:HTTP {}", - response.status().as_u16() - )); + let base = format!("上传源资源失败:HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } Ok(ResourceEditRemoteStage { value: (), @@ -1953,39 +1949,38 @@ async fn confirm_resource_edit_source( ) .send() .await - .map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 确认源资源上传请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 确认源资源上传返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "确认源资源上传失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("确认源资源上传失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源确认响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源确认响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2029,8 +2024,8 @@ async fn register_resource_edit_source_image( .bytes .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; - let decoded = - image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", percent_encode_query_component(remote_project_id) @@ -2065,39 +2060,38 @@ async fn register_resource_edit_source_image( ) .send() .await - .map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 登记源图片项目资源请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 登记源图片项目资源返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "登记源图片项目资源失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("登记源图片项目资源失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源图片项目资源响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源图片项目资源响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2313,7 +2307,7 @@ async fn ensure_resource_edit_source_reference( .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; let decoded = image::load_from_memory(bytes) - .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { resource_id } else { @@ -2657,14 +2651,39 @@ async fn editor_api_rejection_reason(response: reqwest::Response) -> Option chunk, + Err(error) => { + return Some(format!("读取上游错误正文失败:{error}")); + } + }; if body.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES { - return None; + return Some(format!( + "上游错误正文超过 {} 字节上限", + MAX_EDITOR_ERROR_BODY_BYTES + )); } body.extend_from_slice(&chunk); } - let body = String::from_utf8(body).ok()?; - editor_api_rejection_reason_from_body(body.as_str()) + let body = match String::from_utf8(body) { + Ok(body) => body, + Err(error) => return Some(format!("上游错误正文不是 UTF-8:{error}")), + }; + editor_api_rejection_reason_from_body(body.as_str()).or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + MAX_EDITOR_ERROR_DETAIL_CHARS, + ); + (!detail.trim().is_empty()).then_some(detail) + }) +} + +async fn append_editor_api_reason(base: String, response: reqwest::Response) -> String { + match editor_api_rejection_reason(response).await { + Some(reason) => format!("{base}:{reason}"), + None => base, + } } /// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把 @@ -2755,7 +2774,7 @@ async fn submit_resource_edit_remote( .as_deref() .ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?; let mut body_value = serde_json::from_str::(body) - .map_err(|_| "资源编辑账本请求正文无效".to_string())?; + .map_err(|error| format!("资源编辑账本请求正文无效:{error}"))?; if editor_api_mode() == EditorApiMode::PlatformAccount { if let Some(inputs) = body_value.as_object_mut().and_then(|body| { body.entry("generationInputs") @@ -2788,16 +2807,16 @@ async fn submit_resource_edit_remote( let mut post_response_session_error = access.validate_frozen_session().err(); let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authentication_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authorization_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::BAD_REQUEST { ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); @@ -2823,20 +2842,27 @@ async fn submit_resource_edit_remote( if let Some(error) = post_response_session_error.as_ref() { return Err(error.clone()); } - return Err(format!( - "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", - status.as_u16() - )); + let reason = editor_api_rejection_reason(response).await; + return Err(match reason { + Some(reason) => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}:{reason}", + status.as_u16() + ), + None => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", + status.as_u16() + ), + }); } let payload = match response.json::().await { Ok(payload) => payload, - Err(_) => { + Err(error) => { update_resource_edit_phase( root, ledger, ResourceEditLedgerPhase::ReconciliationRequired, )?; - return Err("result-unknown: 资源编辑响应无法解析".to_string()); + return Err(format!("result-unknown: 资源编辑响应无法解析:{error}")); } }; if post_response_session_error.is_none() { @@ -2910,8 +2936,8 @@ async fn wait_for_resource_edit_remote( ) .send() .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 查询资源编辑任务失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!("result-unknown: 查询资源编辑任务请求失败:{error}")) })?; access .validate_frozen_session() @@ -2927,14 +2953,22 @@ async fn wait_for_resource_edit_remote( continue; } if !response.status().is_success() { - return Err(ResourceEditError::Other(format!( + let base = format!( "result-unknown: 查询资源编辑任务返回 HTTP {}", response.status().as_u16() - ))); + ); + return Err(ResourceEditError::Other( + append_editor_api_reason(base, response).await, + )); } - let payload = response.json::().await.map_err(|_| { - ResourceEditError::Other("result-unknown: 资源编辑任务响应无法解析".to_string()) - })?; + let payload = response + .json::() + .await + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 资源编辑任务响应无法解析:{error}" + )) + })?; access .validate_frozen_session() .map_err(ResourceEditError::Other)?; @@ -3153,7 +3187,7 @@ fn validate_downloaded_media( return Err("抠图结果必须是带透明通道的 PNG".to_string()); } let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) - .map_err(|_| "抠图结果不是有效的 PNG".to_string())?; + .map_err(|error| format!("抠图结果不是有效的 PNG:{error}"))?; if !decoded.color().has_alpha() { return Err("抠图结果缺少透明通道".to_string()); } @@ -3435,7 +3469,9 @@ async fn prepare_remote_resource_edit( let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() - .map_err(|_| ResourceEditError::Other("无法创建资源编辑 HTTP 客户端".to_string()))?; + .map_err(|error| { + ResourceEditError::Other(format!("无法创建资源编辑 HTTP 客户端:{error}")) + })?; let download_source = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { if let Some(legacy_public_path) = ledger.remote_legacy_public_path.clone() { serde_json::json!({ "imageSrc": legacy_public_path }) @@ -3597,8 +3633,10 @@ async fn prepare_remote_resource_edit( media_read_limit(&input.edit_kind), ) .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 远端资源编辑结果下载或换签失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 远端资源编辑结果下载或换签失败:{error}" + )) })? .ok_or_else(|| ResourceEditError::Other("远端资源编辑结果缺少可下载媒体".to_string()))?; access @@ -4729,7 +4767,7 @@ fn write_resource_edit_result_binding( | LocalProjectResourceEditKind::BackgroundRemoval ) { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; + .map_err(|error| format!("派生图片 binding 无法解析尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -5862,7 +5900,7 @@ pub(crate) async fn derive_local_project_resource_typed( Err(error) => Err(error), Ok(bytes) => (|| { let content = std::str::from_utf8(&bytes) - .map_err(|_| "派生文本不是 UTF-8".to_string()) + .map_err(|error| format!("派生文本不是 UTF-8:{error}")) .map_err(ResourceEditError::Other)?; let (media_type, extension) = validate_text_derivative( &input.edit_kind, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs index fc66e2b8a..fb90b7ca0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -21,17 +21,19 @@ pub(crate) enum ResourceEditError { } impl ResourceEditError { - /// 面向用户与模型的文案。 - /// - /// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 - /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调; - /// `phase_detail` 只进诊断,不进用户文案。 + /// 面向用户与模型的文案:优先给平台 `error` 原文,缺失时展示同一终态里的 + /// `phaseDetail`,两者都缺失才说明响应没有提供失败正文。前缀由使用者自己加。 pub(crate) fn to_user_msg(&self) -> String { match self { - Self::RemoteGenerationFailed { server_message, .. } => match server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - }, + Self::RemoteGenerationFailed { + server_message, + phase_detail, + } => server_message + .clone() + .or_else(|| phase_detail.clone()) + .unwrap_or_else(|| { + "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string() + }), Self::Other(message) => message.clone(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index 7ec522c4f..e1824f6a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -388,7 +388,9 @@ pub(crate) fn resolve_project_verification_spec_with_cwd_at( .and_then(|scripts| scripts.get(script)) .and_then(serde_json::Value::as_str) .ok_or_else(|| format!("package.json 未定义 {script} 脚本"))?; - if actual_command != expected_command { + // 两边都 trim:调用方(如陶泥儿导出)给出的 expectedCommand 来自剥离空白后的脚本值, + // 而 package.json 里可能带首尾空格/换行;只按原始值比较会把「空白差异」误判成脚本漂移。 + if actual_command.trim() != expected_command.trim() { return Err(format!( "package.json 中的 {script} 脚本已变化,请重新读取后再确认执行" )); @@ -709,6 +711,22 @@ where timeout_seconds, cwd_relative, )?; + run_project_verification_spec_with_commit_at(root, spec, durable_commit).await +} + +/// 跑一份**已解析好**的验证规格。 +/// +/// 从 [`run_project_verification_with_commit_at`] 拆出来,是为了让调用方能先把「项目侧预检」 +/// (package.json / 脚本缺失或漂移 / 包管理器不支持等)与「跑起来之后的宿主侧事实」分开处理: +/// 预检错误由调用方自己决定怎么表达,不必跟沙箱、日志这类宿主故障混成同一个字符串错误。 +pub(crate) async fn run_project_verification_spec_with_commit_at( + root: &Path, + spec: ProjectVerificationSpec, + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ let started_at = std::time::Instant::now(); let mut process = match run_project_verification_process(root, &spec, durable_commit).await { Ok(process) => process, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index 97bd7907b..f83d13bf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -373,6 +373,20 @@ async fn sync_project_snapshot_async( report.failed_files.len(), pending_files ); + for failure in report + .failed_files + .iter() + .chain(report.pending_files.iter()) + { + app_log!( + "project_snapshot.sync.file_detail projectId={} trigger={} path={} code={} detail={}", + report.project_id, + report.trigger, + failure.relative_path, + failure.code, + failure.detail + ); + } Ok(report) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs index 73a7209c2..f344b9e82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -214,6 +214,11 @@ fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() "node_modules/pkg/index.js", "game/dist/bundle.js", "secrets/key.pem", + // 导出工作目录整体不进快照:zip 与脚本拷贝都能从项目源码重新跑出来。 + ".export/xhs-minitool.zip", + ".export/xhs-minitool.json", + ".export/xhs-minitool.pack.mjs", + ".EXPORT/xhs-minitool.zip", "", ] { assert!( @@ -226,6 +231,75 @@ fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() assert!(should_skip_project_snapshot_path(".agent/manifest.json")); assert!(should_skip_project_index_path(".agent/manifest.json")); assert!(should_skip_project_index_path(".agent/agent.db")); + + // `.export/` 不进 Agent 的写入门禁:通用口径同时是 `agc_apply_patch` 的拒路径依据, + // 一并拒掉会让 code agent 写不了适配脚本。 + assert!(!should_skip_project_snapshot_path( + ".export/xhs-minitool.pack.mjs" + )); + assert!(!should_skip_project_snapshot_path( + ".export/xhs-minitool.json" + )); + + // 但「把项目内容当数据来读」的消费者(索引 / checkpoint / git 检查)一律排除它: + // zip 能从项目源码重跑,不该进索引、checkpoint 清单或版本库视角。 + for derived_path in [ + ".export/xhs-minitool.zip", + ".export/xhs-minitool.json", + ".export/xhs-minitool.pack.mjs", + ".EXPORT/xhs-minitool.zip", + ] { + assert!( + should_skip_project_derived_path(derived_path), + "数据视角共用口径应排除本机派生目录:{derived_path}" + ); + assert!( + should_skip_project_index_path(derived_path), + "索引应排除本机派生目录:{derived_path}" + ); + assert!( + should_skip_project_restore_path(derived_path), + "checkpoint 恢复应排除本机派生目录:{derived_path}" + ); + } + // 普通源码目录不受影响,只有 `.export` 这类派生目录被额外排除。 + assert!(!should_skip_project_derived_path("game/main.ts")); + assert!(!should_skip_project_index_path("game/main.ts")); +} + +#[test] +fn derived_export_directory_is_matched_only_at_the_project_root() { + // 项目根的 `.export` 才是本机派生工作目录:数据视角与快照同步都排除。 + for root_export in [ + ".export/xhs-minitool.zip", + ".export/xhs-minitool.json", + ".EXPORT/xhs-minitool.zip", + ] { + assert!( + should_skip_project_derived_path(root_export), + "根目录的 .export 必须排除:{root_export}" + ); + assert!( + should_skip_project_snapshot_sync_path(root_export), + "根目录的 .export 不进快照同步:{root_export}" + ); + } + + // 嵌套在源码目录里的 `.export` 是用户自己的文件,不能按本机派生目录误伤。 + for nested in [ + "game/.export/local.ts", + "game/.export/xhs-minitool.zip", + "src/vendor/.export/keep.json", + ] { + assert!( + !should_skip_project_derived_path(nested), + "嵌套 .export 是用户源码,不该被数据视角排除:{nested}" + ); + assert!( + !should_skip_project_snapshot_sync_path(nested), + "嵌套 .export 是用户源码,不该被排除出快照同步:{nested}" + ); + } } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs index 6ff4c226a..4b9a2901f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs @@ -323,7 +323,13 @@ async fn project_snapshot_response_error( let detail = response .text() .await - .map(|body| body.chars().take(200).collect::()) + .map(|body| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &body, + 200, + ) + }) .unwrap_or_default(); let detail = detail.trim().to_string(); if detail.is_empty() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs index b01f26b76..3d12b17a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs @@ -717,7 +717,7 @@ fn should_ignore_directory(path: &Path) -> bool { return true; } if components.first().is_some_and(|component| { - matches!(component.as_str(), "memory" | "logs" | ".app" | "exports") + matches!(component.as_str(), "memory" | "logs" | ".app" | ".export") }) { return true; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs index 76668d039..f099d34d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs @@ -146,7 +146,7 @@ impl ProjectResourcePreviewReadManager { Ok(result) }) .await - .map_err(|_| "资源预览读取任务异常结束".to_string())?; + .map_err(|error| format!("资源预览读取任务异常结束:{error}"))?; result } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 781a5d4a4..e07f929ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -472,8 +472,8 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( method: method.to_string(), params, }; - let payload = - serde_json::to_vec(&request).map_err(|_| "序列化 Agent Runner 请求失败".to_string())?; + let payload = serde_json::to_vec(&request) + .map_err(|error| format!("序列化 Agent Runner 请求失败:{error}"))?; let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into(); let mut stream = TcpStream::connect_timeout(&address, connect_timeout) .map_err(|error| format!("连接 Agent Runner 失败:{error}"))?; @@ -489,7 +489,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( let response_payload = read_external_agent_runner_frame(&mut stream) .map_err(|error| format!("读取 Agent Runner 响应失败:{error}"))?; let response = serde_json::from_slice::(&response_payload) - .map_err(|_| "解析 Agent Runner 响应失败".to_string())?; + .map_err(|error| format!("解析 Agent Runner 响应失败:{error}"))?; if response.protocol_version != protocol_version { return Err("Agent Runner 响应协议版本不兼容".to_string()); } @@ -1708,7 +1708,7 @@ mod diagnostic_log_tests { "agent.runner.failed: request failed https://example.invalid/api?value=1", config_dir, ), - "agent.runner.failed: request failed https://example.invalid/api?" + "agent.runner.failed: request failed " ); assert_eq!( sanitize_agent_runner_output("error password=hunter2", config_dir), diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 65265250e..571a1e3da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -42,7 +42,7 @@ async fn template_library_access_for_session( .timeout(Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?; + .map_err(|error| format!("template-library-unavailable: 无法检查模板库权限:{error}"))?; let response = client .get(format!( "{}/api/runtime/frontend-config", @@ -51,12 +51,19 @@ async fn template_library_access_for_session( .bearer_auth(&session.access_token) .send() .await - .map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?; + .map_err(|error| { + format!("template-library-unavailable: 检查模板库权限失败,请重试:{error}") + })?; validate_platform_session_identity(&session.identity())?; - if !response.status().is_success() { + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(format!( - "template-library-unavailable: 检查模板库权限返回 HTTP {}", - response.status().as_u16() + "template-library-unavailable: {}", + crate::assets::external_asset_http_failure("检查模板库权限", status, &body,) )); } const MAX_BYTES: usize = 64 * 1024; @@ -69,8 +76,9 @@ async fn template_library_access_for_session( let mut bytes = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = - chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?; + let chunk = chunk.map_err(|error| { + format!("template-library-unavailable: 读取模板库权限失败:{error}") + })?; if bytes.len() + chunk.len() > MAX_BYTES { return Err("template-library-unavailable: 模板库权限响应无效".to_string()); } @@ -78,7 +86,7 @@ async fn template_library_access_for_session( } validate_platform_session_identity(&session.identity())?; let payload: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?; + .map_err(|error| format!("template-library-unavailable: 模板库权限响应无效:{error}"))?; Ok(payload .get("agcTemplateLibraryEnabled") .and_then(|value| value.as_bool()) @@ -572,7 +580,16 @@ async fn fetch_limited_bytes( .await .map_err(|error| format!("请求模板库失败:{error}"))?; if !response.status().is_success() { - return Err(format!("模板库返回 HTTP {}", response.status().as_u16())); + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "模板库请求", + status, + &body, + )); } if response .content_length() @@ -1496,7 +1513,7 @@ mod tests { assert!(created.manifest.godot_project_root.is_none()); assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join(".agent/agent.db").is_file()); - for unexpected in ["game", "memory", "exports", TEMPLATE_INSTALLED_MARKER_FILE] { + for unexpected in ["game", "memory", TEMPLATE_INSTALLED_MARKER_FILE] { assert!(!root.join(unexpected).exists(), "unexpected {unexpected}"); } let package: serde_json::Value = serde_json::from_slice( diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs index 9477e2a37..fff16eb3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs @@ -118,11 +118,20 @@ async fn raw_extract_inner( format!("图片分离请求失败:{error}") })?; if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); app_log!( "ui_separation.error stage=image_edit reason=http_status status={}", - response.status() + status ); - return Err(format!("图片分离请求失败(HTTP {})", response.status())); + return Err(crate::assets::external_asset_http_failure( + "图片分离请求", + status, + &body, + )); } let payload = response.json::().await.map_err(|error| { app_log!("ui_separation.error stage=image_edit reason=parse_response error={error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs index 34ba1be97..d13c0995f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs @@ -153,20 +153,22 @@ fn file_from_snapshot(snapshot: WorkspacePreferencesSnapshot) -> WorkspacePrefer } } -fn read_preferences_at(path: &Path) -> WorkspacePreferencesSnapshot { - let Ok(metadata) = fs::symlink_metadata(path) else { - return WorkspacePreferencesSnapshot::default(); +fn read_preferences_at(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(WorkspacePreferencesSnapshot::default()) + } + Err(error) => return Err(format!("读取工作区偏好元数据失败:{error}")), }; if metadata.file_type().is_symlink() || !metadata.is_file() { - return WorkspacePreferencesSnapshot::default(); + return Err("工作区偏好文件必须是普通文件".to_string()); } - let Ok(content) = fs::read_to_string(path) else { - return WorkspacePreferencesSnapshot::default(); - }; - let Ok(file) = serde_json::from_str::(&content) else { - return WorkspacePreferencesSnapshot::default(); - }; - snapshot_from_file(file) + let content = + fs::read_to_string(path).map_err(|error| format!("读取工作区偏好失败:{error}"))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析工作区偏好失败:{error}"))?; + Ok(snapshot_from_file(file)) } fn write_preferences_at( @@ -240,7 +242,7 @@ fn read_preferences_for_app( app: &tauri::AppHandle, ) -> Result { let path = workspace_preferences_path(app)?; - with_preferences_lock(|| Ok(read_preferences_at(&path))) + with_preferences_lock(|| read_preferences_at(&path)) } fn update_preferences_for_app( @@ -249,21 +251,23 @@ fn update_preferences_for_app( ) -> Result { let path = workspace_preferences_path(app)?; let snapshot = with_preferences_lock(|| { - let current = read_preferences_at(&path); + let current = read_preferences_at(&path)?; let next = update(current.clone()); if next == current { return Ok(current); } write_preferences_at(&path, next) })?; - let _ = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone()); + if let Err(error) = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone()) { + app_log!("workspace_preferences.emit_failed detail={error}"); + } Ok(snapshot) } -pub(crate) fn stored_project_creation_directory(app: &tauri::AppHandle) -> Option { - read_preferences_for_app(app) - .ok() - .and_then(|snapshot| snapshot.project_creation_directory) +pub(crate) fn stored_project_creation_directory( + app: &tauri::AppHandle, +) -> Result, String> { + Ok(read_preferences_for_app(app)?.project_creation_directory) } #[tauri::command] @@ -407,14 +411,13 @@ mod tests { } #[test] - fn malformed_preference_file_falls_back_to_empty_snapshot() { + fn malformed_preference_file_returns_parse_error_instead_of_silent_default() { let directory = tempfile::tempdir().expect("preference fixture"); let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME); fs::write(&path, b"{not json").expect("malformed preference fixture"); - assert_eq!( - read_preferences_at(&path), - WorkspacePreferencesSnapshot::default() - ); + assert!(read_preferences_at(&path) + .unwrap_err() + .contains("解析工作区偏好失败")); } #[test] @@ -435,13 +438,17 @@ mod tests { }), }; write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); fs::write( &path, br#"{"schemaVersion":"agc-workspace-preferences.v1"}"#, ) .expect("legacy file without the new field"); - assert!(!read_preferences_at(&path).chat_prompt_polish_reminder_disabled); + assert!( + !read_preferences_at(&path) + .unwrap() + .chat_prompt_polish_reminder_disabled + ); } #[test] @@ -467,7 +474,7 @@ mod tests { Some(("custom", "http://127.0.0.1:10001")) ); write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); // 非法预设与非法地址都不落盘。 assert!( @@ -513,7 +520,7 @@ mod tests { client_server_selection: None, }; write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); let content = fs::read_to_string(path).expect("read snapshot"); assert!(content.contains(WORKSPACE_PREFERENCES_SCHEMA_VERSION)); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 145a3fb0a..790fe9af7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -38,18 +38,11 @@ import type { LocalGameProjectRevisionStatus, LocalPreviewResult, LocalPreviewStatus, - LocalProjectExportPackageResult, LocalProjectKind, PendingUiConfirmation, ProjectPermissionPolicyView, TauriInvoke, } from './app/types'; -import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel'; -import { GamePublishBlockedDialog } from './components/game-distribution/GamePublishBlockedDialog'; -import { - GamePublishProgressDialog, - type GamePublishProgressState, -} from './components/game-distribution/GamePublishProgressDialog'; import { agentConversationId, createAgentChatRunId, @@ -88,6 +81,10 @@ import { type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { + appendWebviewLog, + visibleClientErrorMessage, +} from './services/errorReporting'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -107,6 +104,7 @@ import { DirectProjectChatView, type DirectProjectInitialTurn, } from './view/project-development/chat/DirectProjectChatView'; +import ArtifactsPanel from './view/project-development/export'; import { PlanningChatView } from './view/project-development/planning/PlanningChatView'; import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; @@ -247,15 +245,18 @@ export function App({ return; } setWorkspaceStatus( - `编辑器插件未就绪:${ - error instanceof Error ? error.message : String(error) - }`, + `编辑器插件未就绪:${visibleClientErrorMessage(error)}`, ); }); return () => { active = false; if (nextProjectPath || previousProjectPath) { - void setAgcPluginProjectPath(null).catch(() => undefined); + void setAgcPluginProjectPath(null).catch((error) => { + appendWebviewLog('error', [ + '[AGC] 编辑器插件项目路径清理失败', + visibleClientErrorMessage(error), + ]); + }); } localProjectPathRef.current = null; }; @@ -324,15 +325,8 @@ export function App({ const chatComposerRef = useRef(null); const [chatAgentBusy, setChatAgentBusy] = useState(false); - // 发布到游戏广场:试玩包导出结果与面板开关由工作台壳持有,聊天容器只负责触发。 - const [publishPackageResult, setPublishPackageResult] = - useState(null); - const [publishPanelOpen, setPublishPanelOpen] = useState(false); - const [publishProgress, setPublishProgress] = - useState(null); - const [publishBlockedMessage, setPublishBlockedMessage] = useState< - string | null - >(null); + // 导出产物:全屏面板由工作台壳按需挂载,关闭即卸载,面板状态不留在壳上。 + const [exportPanelOpen, setExportPanelOpen] = useState(false); const [projectChatError, setProjectChatError] = useState(''); const designReplyAnimation = useDesignReplyAnimation(); const [designAgentStatus, setDesignAgentStatus] = useState(''); @@ -444,7 +438,12 @@ export function App({ } cleanup = unlisten; }) - .catch(() => { + .catch((error) => { + if (!disposed) { + setProjectChatError( + `策划 Agent 事件订阅失败:${visibleClientErrorMessage(error)}`, + ); + } resolveDesignAgentEventSubscriptionReady(); }); return () => { @@ -502,7 +501,11 @@ export function App({ setDesignAgentView(view); designAgentActiveRef.current = true; setDesignAgentActive(true); - setProjectChatError(view.session.lastError ?? ''); + setProjectChatError( + view.session.lastError + ? visibleClientErrorMessage(view.session.lastError) + : '', + ); setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); @@ -564,8 +567,9 @@ export function App({ if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { + const rawMessage = visibleClientErrorMessage(error); + const message = visibleClientErrorMessage(error); + if (isRuntimeConfigMissingError(rawMessage)) { requestRuntimeConfigOpen(); } setProjectChatError(message); @@ -776,7 +780,12 @@ export function App({ event, commandId, }), - ).catch(() => undefined); + ).catch((error) => { + appendWebviewLog('error', [ + '[AGC] 本地权限审计日志写入失败', + visibleClientErrorMessage(error), + ]); + }); } catch { // 权限日志只是审计旁路,写失败不能阻塞对话。 } @@ -858,66 +867,13 @@ export function App({ ]); directProjectChatRef.current?.announce(message); return true; - } catch { - return false; - } - } - - /** - * 导出试玩包并打开发布面板。 - * - * 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区, - * 导出失败在弹窗内回显;成功后才切换到发布资料面板。 - */ - async function requestGamePublish() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setPublishBlockedMessage('需要在 Tauri App 内发布'); - return; - } - const nextProjectPath = - resolveChatProjectPath(localProject) ?? projectPath.trim(); - if (!nextProjectPath) { - setPublishBlockedMessage('先打开一个项目再发布'); - return; - } - const publishManifest = manifestRef.current; - const hasCompletedPrototype = publishManifest.tasks.some( - (task) => task.id === 'code-prototype' && task.status === 'completed', - ); - const hasRunningPreview = - publishManifest.preview?.status === 'running' && - Boolean(publishManifest.preview.url?.trim()); - if (!hasCompletedPrototype && !hasRunningPreview) { - setPublishBlockedMessage( - '首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。', - ); - return; - } - setPublishProgress({ - phase: 'running', - message: '正在构建并打包试玩包,请稍候…', - step: 'build', - }); - try { - const result = await invoke( - 'export_local_project_package', - { projectPath: nextProjectPath }, - ); - setPublishProgress(null); - setPublishPackageResult(result); - setPublishPanelOpen(true); - appendLocalPermissionLog( - nextProjectPath, - 'command.auto', - 'project.export_package', - ); } catch (error) { - setPublishProgress({ - phase: 'failed', - message: '发布失败', - detail: error instanceof Error ? error.message : String(error), - }); + if (localProjectPathRef.current === projectPath) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setProjectChatError(message); + setWorkspaceStatus(message); + } + return false; } } @@ -1050,12 +1006,14 @@ export function App({ * 切换项目作用域时把这条链路自己的聊天状态清干净。 * * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 + * 导出面板同样按项目打开,切换项目时必须一起关掉:它不带 `key`,留着会直接重绑到新项目。 */ function resetChatState() { setChatAgentBusy(false); setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); + setExportPanelOpen(false); designReplyAnimation.reset(); setDesignAgentStatus(''); setDesignAgentReasoning(''); @@ -1091,7 +1049,12 @@ export function App({ setWorkspaceStatus('等待确认'); return; } - } catch { + } catch (error) { + if (localProjectPathRef.current === nextProjectPath) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setProjectChatError(message); + setWorkspaceStatus(message); + } return; } } @@ -1111,7 +1074,7 @@ export function App({ } catch (error) { if (planningStartMode) { setProjectChatError( - `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, + `策划会话无法恢复:${visibleClientErrorMessage(error)}`, ); } } @@ -1226,8 +1189,13 @@ export function App({ setManifest(openedProject.manifest); setMessages(conversationMessages); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - void rememberRecentWorkspace(openedProject.projectPath).catch(() => { - // 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚。 + void rememberRecentWorkspace(openedProject.projectPath).catch((error) => { + // 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚, + // 但失败正文仍须进入诊断,不能静默丢掉本地 I/O 事实。 + appendWebviewLog('error', [ + '[AGC] 最近项目偏好写入失败', + visibleClientErrorMessage(error), + ]); }); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); appendLocalPermissionLog( @@ -1249,7 +1217,7 @@ export function App({ if (projectScopeVersionRef.current !== projectScopeVersion) { return; } - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); setWorkspaceStatus(message); if (announceToChat) { setMessages((current) => [ @@ -1358,7 +1326,10 @@ export function App({ ); setWorkspaceStatus('等待确认'); return false; - } catch { + } catch (error) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setWorkspaceStatus(message); + setProjectChatError(message); return false; } } @@ -1413,9 +1384,7 @@ export function App({ } catch (error) { if (localProjectPathRef.current === input.projectPath) { setProjectChatError( - `DirectProject 对话权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, + `DirectProject 对话权限检查失败:${visibleClientErrorMessage(error)}`, ); } return false; @@ -1506,24 +1475,19 @@ export function App({ invoke: TauriInvoke, nextProjectPath: string, ): Promise { - try { - const status = await invoke( - 'activate_local_game_preview', - { projectPath: nextProjectPath }, - ); - if ( - status.status !== 'running' || - !status.url || - !status.port || - !status.root - ) { - return null; - } - return { url: status.url, port: status.port, root: status.root }; - } catch { - // 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。 + const status = await invoke( + 'activate_local_game_preview', + { projectPath: nextProjectPath }, + ); + if ( + status.status !== 'running' || + !status.url || + !status.port || + !status.root + ) { return null; } + return { url: status.url, port: status.port, root: status.root }; } async function executeRunLocal() { @@ -1541,11 +1505,14 @@ export function App({ return; } + let previewActivationError: string | null = null; try { - const activePreview = await activateRunningPreview( - invoke, - nextProjectPath, - ); + let activePreview: LocalPreviewResult | null = null; + try { + activePreview = await activateRunningPreview(invoke, nextProjectPath); + } catch (error) { + previewActivationError = visibleClientErrorMessage(error); + } if (activePreview) { updateClientPreview(activePreview); // 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里 @@ -1564,13 +1531,18 @@ export function App({ * `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、 * 恒为 true(见 `executeRunLocalRef.current(true)`)。 */ - onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); + onRunNotice?.({ + message: previewActivationError + ? `已有预览复用失败,已重新启动运行视图:${previewActivationError}` + : '运行通过,已载入客户端运行视图', + }); } catch (error) { + const startError = visibleClientErrorMessage(error); onRunNotice?.({ tone: 'error', - message: `运行游戏失败:${ - error instanceof Error ? error.message : String(error) - }`, + message: previewActivationError + ? `已有预览复用失败:${previewActivationError};重新启动运行也失败:${startError}` + : `运行游戏失败:${startError}`, }); } } @@ -1620,7 +1592,13 @@ export function App({ } return; } - })().catch(() => undefined); + })().catch((error) => { + if (!cancelled && localProjectPathRef.current === nextProjectPath) { + setProjectChatError( + `项目清单同步失败:${visibleClientErrorMessage(error)}`, + ); + } + }); return () => { cancelled = true; }; @@ -1675,7 +1653,12 @@ export function App({ updatedAt: finalMessage.updatedAt, } satisfies ProjectAgentResultSummary, }; - } catch { + } catch (error) { + if (!disposed && localProjectPathRef.current === nextProjectPath) { + setProjectChatError( + `读取${candidate.label}结果失败:${visibleClientErrorMessage(error)}`, + ); + } return { agentId: candidate.agentId, status: 'failed' as const, @@ -1825,39 +1808,35 @@ export function App({ if (directProjectMode) { // 普通项目固定走 DirectProject 自己的聊天容器:订阅、历史、发送、队列和附件都由 // 容器持有,工作台壳只提供项目身份、入口首轮需求和两条权限门。 + // + // 导出入口与面板共用**同一个已裁剪**的路径:`localProject?.projectPath || projectPath` + // 只判真值,纯空白的路径会照开门;面板下游又都用 `Boolean(projectPath)` 当有效判据。 + // 这里统一裁一次,空白即视作「没有项目」。 + const exportProjectPath = + (localProject?.projectPath || projectPath || '').trim() || null; return ( <> setExportPanelOpen(true) : undefined + } onCancelConfirmation={cancelUiCommandConfirmation} onConfirmConfirmation={confirmUiCommand} pendingConfirmation={pendingUiConfirmation} projectPath={localProject?.projectPath ?? projectPath ?? null} ref={directProjectChatRef} /> - setPublishPanelOpen(false)} - onPublished={(published) => { - // 发布成功会把平台作品绑定写回本地清单;用原生返回的整份 manifest 覆盖 - // 工作台状态,两处面板挂载点(普通项目 / DirectProject)共享同一份状态。 - if (published.manifest) setManifest(published.manifest); - }} - /> - setPublishProgress(null)} - /> - setPublishBlockedMessage(null)} - /> + {exportPanelOpen ? ( + setExportPanelOpen(false)} + /> + ) : null} ); } @@ -1922,7 +1901,7 @@ export function App({ .catch((error) => { if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - setProjectChatError(String(error)); + setProjectChatError(visibleClientErrorMessage(error)); designReplyAnimation.discardUnpersisted(); }) .finally(() => { @@ -1974,24 +1953,6 @@ export function App({ onClose={() => setRuntimeConfigOpen(false)} /> ) : null} - setPublishPanelOpen(false)} - onPublished={(published) => { - if (published.manifest) setManifest(published.manifest); - }} - /> - setPublishProgress(null)} - /> - setPublishBlockedMessage(null)} - /> ); } diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 96d8b5c54..c2f3b4394 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -34,6 +34,7 @@ import { import { captureClientError, installWebviewLogBridge, + visibleClientErrorMessage, } from '../services/errorReporting'; import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; @@ -101,7 +102,7 @@ export class ClientRuntimeErrorBoundary extends Component< return { errorMessage: error instanceof Error && error.message.trim() - ? error.message + ? visibleClientErrorMessage(error) : '登录后页面发生未知错误', }; } @@ -179,7 +180,7 @@ export function AuthenticatedClient({ setServerSelection(next); return true; } catch (error) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); return false; } } @@ -196,20 +197,6 @@ export function AuthenticatedClient({ setServerSelection(next); setLoginStatus(`已选择 ${preset} 服务器`); } - // 服务器选择是 Rust 客户端偏好:本地 debug 启动时读一次,打包产物始终用渠道默认值。 - useEffect(() => { - if (!clientServerSelectionEnabled) return; - let disposed = false; - void hydrateClientServerSelection().then((selection) => { - if (disposed) return; - setServerSelection(selection); - setCustomServerUrl(selection.customBaseUrl); - }); - return () => { - disposed = true; - }; - }, []); - useEffect(() => { const uninstallWebviewLogBridge = installWebviewLogBridge(); const handleError = (event: ErrorEvent) => { @@ -238,8 +225,20 @@ export function AuthenticatedClient({ setAuthCheckError(''); setAuthCheckStage('refresh'); try { + // 服务器选择先于认证读取落盘值:Rust 按 `expectedApiBaseUrl` 判定凭据归属,竞态下用 + // 渠道默认值会被当成「凭据属于另一个 origin」而误报未登录,自定义服务器每次启动都要重登。 + // 读服务器偏好同样是一次 IPC:不设围栏时,它卡住会让界面永远停在「刷新登录状态」, + // 既没有超时提示也没有重试路径(`hydrateClientServerSelection` 自己吞错、不会 reject)。 + const selection = await withAuthCheckTimeout( + hydrateClientServerSelection(), + AUTH_CHECK_REQUEST_TIMEOUT_MS, + '读取服务器偏好超时,请重试或重启客户端', + ); + if (!isActiveRun()) return; + setServerSelection(selection); + setCustomServerUrl(selection.customBaseUrl); const state = await withAuthCheckTimeout( - readClientAuthState(getClientServerBaseUrl()), + readClientAuthState(getClientServerBaseUrl(selection)), AUTH_CHECK_REQUEST_TIMEOUT_MS, '检查登录状态超时,请检查服务器地址和网络后重试', ); @@ -266,9 +265,10 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - // 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。 + // 启动检查超时、IPC 或网络失败都必须保留脱敏正文,并给出可重复的重试入口。 + setAuthCheckError(visibleClientErrorMessage(error)); if (!(error instanceof ClientAuthErrorWrapper)) { - const text = error instanceof Error ? error.message : String(error); + const text = visibleClientErrorMessage(error); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -378,7 +378,9 @@ export function AuthenticatedClient({ } case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; - const text = `登录服务暂时不可用(HTTP ${payload.status})`; + const text = payload.serverMessage + ? `登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录服务暂时不可用(HTTP ${payload.status})`; setAuthCheckError(text); setLoginStatus(text); throw error; @@ -443,7 +445,10 @@ export function AuthenticatedClient({ default: { expectNever(failure); // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 - const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; + const text = visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -489,7 +494,11 @@ export function AuthenticatedClient({ if (disposed) release(); else unsubscribe = release; }) - .catch(() => undefined); + .catch((error) => { + if (disposed) return; + setAuthStatus('unauthenticated'); + setAuthCheckError(visibleClientErrorMessage(error)); + }); return () => { disposed = true; unsubscribe?.(); @@ -543,7 +552,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -644,7 +653,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } @@ -700,7 +711,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -770,7 +786,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -871,7 +887,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `登录失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } @@ -927,7 +945,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -1029,11 +1052,7 @@ export function AuthenticatedClient({ normalizeClientServerBaseUrl(customServerUrl); persistServerSelection(); } catch (error) { - setLoginStatus( - error instanceof Error - ? error.message - : String(error), - ); + setLoginStatus(visibleClientErrorMessage(error)); } }} /> diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 9263f8367..3fe5183e5 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -479,19 +479,6 @@ export interface LocalProjectCheckpointResult { totalBytes: number; } -export interface LocalProjectExportPackageResult { - packagePath: string; - packageRelativePath: string; - fileCount: number; - totalBytes: number; -} - -export interface LocalProjectExportPackageFileDigest { - path: string; - sizeBytes: number; - sha256: string; -} - /** * 已暂存的归一化发行包:发布链路只传递这个摘要与路径,整包字节留在原生进程里, * 不再经过 WebView IPC。 @@ -509,18 +496,6 @@ export interface GamePackageUploadOutcome { uploadedBytes: number; } -export interface LocalProjectExportPackageSummary { - packagePath: string; - packageRelativePath: string; - totalBytes: number; - modifiedAt: number; -} - -export interface LocalProjectExportPackagesResult { - projectPath: string; - packages: LocalProjectExportPackageSummary[]; -} - export interface LocalProjectDiffResult { checkpointId: string; added: Array<{ path: string; status: string }>; diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 27ce8c6e2..b7fe11562 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -9,6 +9,7 @@ import { installAppUpdate, subscribeToAppUpdate, } from '../services/appUpdate'; +import { visibleClientErrorMessage } from '../services/errorReporting'; type DownloadState = 'idle' | 'downloading' | 'completed' | 'error'; @@ -60,7 +61,7 @@ export function AppUpdateNotice() { await installAppUpdate(setDownloadProgress); setDownloadState('completed'); } catch (error) { - setDownloadError(error instanceof Error ? error.message : String(error)); + setDownloadError(visibleClientErrorMessage(error, '下载更新失败')); setDownloadState('error'); } } @@ -157,7 +158,7 @@ export function AppUpdateNotice() { ) : ( <> -

{downloadError || '下载更新失败,请稍后重试。'}

+

{downloadError || '下载更新失败:未提供具体错误正文。'}

) : null} + {loadError && notificationEvents.length === 0 ? ( + + ) : null} PANEL_IMAGE_MAX_BYTES) { - return `${label}过大,请压缩后再上传(最多 6MB)`; - } - const contentType = file.type.trim(); - if (contentType && !contentType.startsWith('image/')) { - return `${label}必须是图片文件`; - } - return ''; -} - -function buildPanelImageSignature(file: File, kind: PanelImageKind) { - return `${kind}:${file.name}:${file.size}:${file.lastModified}`; -} - -/** 预览只在 WebView 支持 object URL 时生成;否则退回文字占位,不影响上传。 */ -function buildPanelImagePreviewUrl(file: File) { - if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') { - return ''; - } - try { - return URL.createObjectURL(file); - } catch { - return ''; - } -} - -function createPanelScreenshotId() { - const randomUuid = - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `screenshot-${randomUuid}`; -} - -/** 版本状态到中文标签的静态映射;未知状态原样展示,不臆造。 */ -const PUBLICATION_VERSION_STATUS_LABELS: Record = { - awaiting_upload: '待上传', - uploaded: '已上传', - validating: '校验中', - pending_review: '审核中', - published: '已公开', - upload_failed: '上传失败', - validation_failed: '校验失败', - rejected: '已驳回', - cancelled: '已撤回', - revoked: '已撤销', -}; - -function publicationVersionStatusLabel(status: string | null | undefined) { - const normalized = status?.trim() ?? ''; - if (!normalized) return '未知状态'; - return PUBLICATION_VERSION_STATUS_LABELS[normalized] ?? normalized; -} - -/** 已发布作品的封面恢复:预览用换签地址,提交仍复用冻结素材 ID。 */ -function buildRestoredCover( - cover: GameDistributionPublicationImageDraft, -): PanelImageAsset { - const assetId = cover.assetId?.trim() ?? ''; - return { - signature: `existing:${assetId || cover.previewUrl?.trim() || 'cover'}`, - name: '当前发布封面', - assetObjectId: assetId, - previewUrl: cover.previewUrl?.trim() ?? '', - }; -} - -/** 已发布作品的截图恢复;历史版本缺少冻结素材 ID 时标记为失败,要求作者重新选择。 */ -function buildRestoredScreenshot( - screenshot: GameDistributionPublicationImageDraft, - index: number, -): PanelScreenshotEntry { - const assetId = screenshot.assetId?.trim() ?? ''; - return { - id: `restored-screenshot-${index}-${assetId || 'missing'}`, - name: `当前截图 ${index + 1}`, - previewUrl: screenshot.previewUrl?.trim() ?? '', - assetObjectId: assetId || null, - status: assetId ? 'ready' : 'failed', - error: assetId ? null : '该截图缺少平台素材,请删除后重新选择', - file: null, - }; -} - -const COVER_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; -const COVER_GENERATION_ASPECT_RATIO = '16:9'; -const COVER_GENERATION_IMAGE_SIZE = '2K'; - -function buildPublishProjectContext(manifest: GameCreationAppManifest) { - const taskSummary = (manifest.tasks ?? []) - .slice(0, 16) - .map((task) => `${task.title}(${task.status})`) - .join('、'); - const assetSummary = (manifest.assets ?? []) - .slice(0, 24) - .map((asset) => `${asset.kind}:${contextSafeAssetLabel(asset.localPath)}`) - .join('、'); - const versionSummary = (manifest.versions ?? []) - .slice(-3) - .map((version) => version.editPrompt?.trim()) - .filter(Boolean) - .join(';'); - return [ - `当前任务与状态:${taskSummary || '暂无'}`, - `现有素材:${assetSummary || '暂无'}`, - manifest.preview?.status - ? `运行视图:${manifest.preview.status}` - : '运行视图:未运行', - versionSummary ? `最近编辑:${versionSummary}` : '', - ] - .filter(Boolean) - .join('\n') - .slice(0, 6_000); -} - -/** - * 送进模型的项目上下文必须脱敏:素材只带文件名,不带目录片段。 - * - * 素材清单里的 `localPath` 正常是项目内相对路径,但登记来源不受本地约束时也可能是 - * 绝对路径(含盘符或用户目录)——`C:\Users\<用户名>\...\assets\x.png` 这种一旦进提示词 - * 就等于把本地目录结构发给了平台模型。这里统一取最后一段并限长。 - */ -function contextSafeAssetLabel(value: string) { - const normalized = value.trim().replaceAll('\\', '/'); - const base = normalized.split('/').filter(Boolean).pop() ?? ''; - return base.slice(0, 80); -} - -function buildCoverGenerationPrompt( - manifest: GameCreationAppManifest, - summary: string, -) { - const goal = manifest.goal?.trim() || summary.trim(); - return [ - `为游戏《${manifest.name.trim() || '未命名游戏'}》生成一张游戏广场发行封面。`, - goal ? `创作目标与核心体验:${goal}` : '', - '主体画面需要体现游戏的核心玩法、题材氛围与代表性元素,构图清晰,适合作为游戏卡片和详情页首图。', - '16:9 横版构图,主体居中偏上,边缘保留安全区;不要生成文字、水印、平台标识、边框或界面截图。', - ] - .filter(Boolean) - .join('\n'); -} - -export function GameDistributionPublishPanel({ - open, - projectPath, - manifest, - packageResult, - onClose, - onPublished, -}: { - open: boolean; - projectPath: string; - manifest: GameCreationAppManifest; - packageResult: LocalProjectExportPackageResult | null; - onClose: () => void; - onPublished?: (result: GameDistributionPublishResult) => void; -}) { - const [title, setTitle] = useState(''); - const [summary, setSummary] = useState(''); - const [category, setCategory] = - useState('其他'); - /** 付费方式与买断价输入;校验口径与网页、服务端一致。 */ - const [priceMode, setPriceMode] = useState('free'); - const [priceInput, setPriceInput] = useState(''); - const [priceError, setPriceError] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); - const [result, setResult] = useState( - null, - ); - const [cover, setCover] = useState(null); - const [screenshots, setScreenshots] = useState([]); - const [previewScreenshot, setPreviewScreenshot] = - useState(null); - const [uploadingLabel, setUploadingLabel] = useState(''); - const [coverConfirmOpen, setCoverConfirmOpen] = useState(false); - const [generatingCover, setGeneratingCover] = useState(false); - const [coverGenerationPrice, setCoverGenerationPrice] = useState< - number | null - >(null); - const [coverGenerationDetail, setCoverGenerationDetail] = useState(''); - /** 发布进行中的阶段与百分比;来自 `game-package-upload-progress`。 */ - const [publishPhase, setPublishPhase] = useState<{ - phase: GamePublishRuntimePhase; - message: string; - percent: number | null; - } | null>(null); - /** 发布绑定状态:检查中 / 首次发布 / 更新 / 不可用。 */ - const [publicationState, setPublicationState] = - useState('checking'); - const [publicationBinding, setPublicationBinding] = useState< - GameDistributionPublicationReadResult['binding'] | null - >(null); - const [publicationRefreshed, setPublicationRefreshed] = useState(false); - const [publicationMessage, setPublicationMessage] = useState(''); - /** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */ - const [latestVersionNumber, setLatestVersionNumber] = useState(0); - /** - * 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1` - * (首次发布为 `1`),由原生回读下发。 - * - * `null` = 还没拿到平台数据(恢复失败、或更新模式没能刷新线上状态):这时不允许提交, - * 也不回落到 AGC 工程内部版本 `versions[]` 的条数——那是本地迭代计数,会提交重复 / - * 回退的平台版本号。面板展示的值与提交的值都只取这一个来源。 - */ - const [nextVersionNumber, setNextVersionNumber] = useState( - null, - ); - const metadataSuggestionRequestRef = useRef(0); - const publicationRequestRef = useRef(0); - const publicationDraftAppliedRef = useRef(false); - /** 回读冻结资料里的设备/输入/方向声明:面板不编辑,但更新发布必须原样沿用。 */ - const restoredDeclarationsRef = useRef<{ - deviceSupport?: GameDistributionPublishMetadata['deviceSupport']; - inputModes?: GameDistributionPublishMetadata['inputModes']; - orientation?: GameDistributionPublishMetadata['orientation']; - } | null>(null); - const summaryTouchedRef = useRef(false); - const categoryTouchedRef = useRef(false); - const coverInputRef = useRef(null); - const screenshotInputRef = useRef(null); - // 文件签名 → 素材 ID:同一次打开面板重复提交不会重复上传同一张图。 - const uploadedAssetCacheRef = useRef(new Map()); - const objectUrlsRef = useRef(new Set()); - - useEffect(() => { - if (!open) return; - const fallbackSummary = (manifest.goal ?? '由陶泥儿创作的可在线游玩游戏') - .trim() - .slice(0, 120); - setTitle(manifest.name.trim()); - setSummary(fallbackSummary); - setCategory('其他'); - setPriceMode('free'); - setPriceInput(''); - setPriceError(''); - setCover(null); - setScreenshots([]); - setBusy(false); - setError(''); - setResult(null); - setCoverConfirmOpen(false); - setGeneratingCover(false); - setCoverGenerationPrice(null); - setCoverGenerationDetail(''); - setPreviewScreenshot(null); - setPublishPhase(null); - setPublicationState('checking'); - setPublicationBinding(null); - setPublicationRefreshed(false); - setPublicationMessage(''); - setLatestVersionNumber(0); - setNextVersionNumber(null); - summaryTouchedRef.current = false; - categoryTouchedRef.current = false; - publicationDraftAppliedRef.current = false; - restoredDeclarationsRef.current = null; - uploadedAssetCacheRef.current.clear(); - - const suggestionRequestId = ++metadataSuggestionRequestRef.current; - void suggestGameDistributionPublishMetadata({ - name: manifest.name, - goal: manifest.goal, - context: buildPublishProjectContext(manifest), - }) - .then((suggestion) => { - if (suggestionRequestId !== metadataSuggestionRequestRef.current) - return; - // 更新发布以线上冻结资料为准:已有草稿回填时不再用 AI 建议覆盖。 - if ( - !publicationDraftAppliedRef.current && - !summaryTouchedRef.current && - suggestion.summary.trim() - ) { - setSummary(suggestion.summary.trim().slice(0, 120)); - } - if ( - !publicationDraftAppliedRef.current && - !categoryTouchedRef.current && - suggestion.category - ) { - setCategory(suggestion.category); - } - }) - .catch(() => { - // 免费增强能力失败时保留本地兜底,不阻断发布。 - }); - - // 绑定恢复:原生按当前会话(账号 + origin)隔离;网络/401/5xx 返回 unavailable, - // 面板据此禁用提交而不是退化成首次发布。 - const invoke = resolveTauriInvoke(); - if (!invoke) { - // 非 Tauri 宿主:没有可恢复的原生状态,也就拿不到平台版本号——按首次发布展示表单, - // 提交按钮因为版本号未知保持禁用(发布本身也需要原生宿主)。 - setPublicationState('first-publish'); - return; - } - const publicationRequestId = ++publicationRequestRef.current; - void readGameDistributionPublication({ projectPath }) - .then((recovery) => { - if (publicationRequestId !== publicationRequestRef.current) return; - if (recovery.state === 'unavailable') { - setPublicationState('unavailable'); - setPublicationMessage( - recovery.message?.trim() || - '无法确认该项目的线上发布状态,请稍后重试', - ); - return; - } - // 版本号只认原生按平台作者视图推出来的值;缺字段(旧原生端 / 没刷新成功)就保持未知。 - const platformNextVersion = recovery.nextVersionNumber; - setNextVersionNumber( - typeof platformNextVersion === 'number' && platformNextVersion > 0 - ? platformNextVersion - : null, - ); - if (recovery.state === 'update') { - // 线上最近提交的版本号只用于展示平台侧事实,与这次要用的版本号无关。 - const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0; - setPublicationState('update'); - setPublicationBinding(recovery.binding); - setPublicationRefreshed(recovery.refreshed); - setPublicationMessage(recovery.message?.trim() ?? ''); - setLatestVersionNumber(latestVersionNumber); - if (recovery.draft) { - applyPublicationDraft(recovery.draft); - } - return; - } - setPublicationState('first-publish'); - setPublicationMessage(recovery.message?.trim() ?? ''); - }) - .catch((recoveryError) => { - if (publicationRequestId !== publicationRequestRef.current) return; - setPublicationState('unavailable'); - setPublicationMessage( - recoveryError instanceof Error - ? recoveryError.message - : '无法确认该项目的线上发布状态,请稍后重试', - ); - }); - - function applyPublicationDraft( - draft: NonNullable< - Extract< - GameDistributionPublicationReadResult, - { state: 'update' } - >['draft'] - >, - ) { - publicationDraftAppliedRef.current = true; - restoredDeclarationsRef.current = { - deviceSupport: draft.deviceSupport ?? undefined, - inputModes: draft.inputModes?.length ? draft.inputModes : undefined, - orientation: draft.orientation ?? undefined, - }; - if (draft.title.trim()) setTitle(draft.title.trim().slice(0, 40)); - if (!summaryTouchedRef.current && draft.summary.trim()) { - setSummary(draft.summary.trim().slice(0, 120)); - } - if (!categoryTouchedRef.current && draft.category) { - setCategory(draft.category); - } - // 线上价格预填:付费作品进面板默认免费会在送审后把作品改成免费,这里按回读的 - // 当前价预填,作者不改就不变。历史响应缺该字段时按免费兜底。 - const existingPriceMudPoints = draft.priceMudPoints ?? 0; - if ( - typeof existingPriceMudPoints === 'number' && - existingPriceMudPoints > 0 - ) { - setPriceMode('paid'); - setPriceInput(String(existingPriceMudPoints)); - } else { - setPriceMode('free'); - setPriceInput(''); - } - setPriceError(''); - if (draft.cover && (draft.cover.assetId || draft.cover.previewUrl)) { - setCover(buildRestoredCover(draft.cover)); - } - if (draft.screenshots.length > 0) { - setScreenshots( - draft.screenshots.map((screenshot, index) => - buildRestoredScreenshot(screenshot, index), - ), - ); - } - } - }, [manifest, open, packageResult?.packageRelativePath, projectPath]); - - useEffect(() => { - if (!open) return; - let cancelled = false; - void readGameCoverGenerationPrice({ - model: COVER_GENERATION_MODEL, - imageSize: COVER_GENERATION_IMAGE_SIZE, - }) - .then((price) => { - if (!cancelled) setCoverGenerationPrice(price); - }) - .catch(() => { - if (!cancelled) setCoverGenerationPrice(null); - }); - return () => { - cancelled = true; - }; - }, [open]); - - useEffect(() => { - const objectUrls = objectUrlsRef.current; - return () => { - objectUrls.forEach((url) => { - try { - URL.revokeObjectURL(url); - } catch { - // 预览地址释放失败不影响关闭面板。 - } - }); - objectUrls.clear(); - }; - }, []); - - async function resolveAssetObjectId(file: File, kind: PanelImageKind) { - const signature = buildPanelImageSignature(file, kind); - const cached = uploadedAssetCacheRef.current.get(signature); - if (cached) return cached; - const uploaded = await uploadPlatformMediaAsset({ - file, - assetKind: - kind === 'cover' - ? 'game_distribution_cover' - : 'game_distribution_screenshot', - pathSegments: ['game-distribution', kind, `${Date.now()}`], - entityId: `game-distribution-${kind}`, - metadata: { game_distribution_media: kind }, - }); - uploadedAssetCacheRef.current.set(signature, uploaded.assetObjectId); - return uploaded.assetObjectId; - } - - function buildPanelImageAsset( - file: File, - kind: PanelImageKind, - assetObjectId: string, - ) { - const previewUrl = buildPanelImagePreviewUrl(file); - if (previewUrl) objectUrlsRef.current.add(previewUrl); - return { - signature: buildPanelImageSignature(file, kind), - name: file.name.trim() || resolvePanelImageLabel(kind), - assetObjectId, - previewUrl, - }; - } - - async function handleCoverSelected(file: File | null) { - if (!file) return; - setError(''); - const fileError = resolvePanelImageFileError(file, 'cover'); - if (fileError) { - setError(fileError); - return; - } - setUploadingLabel('正在上传封面…'); - try { - const assetObjectId = await resolveAssetObjectId(file, 'cover'); - setCover(buildPanelImageAsset(file, 'cover', assetObjectId)); - } catch (uploadError) { - setError( - uploadError instanceof Error - ? uploadError.message - : '封面上传失败,请重试', - ); - } finally { - setUploadingLabel(''); - if (coverInputRef.current) coverInputRef.current.value = ''; - } - } - - async function handleGenerateCover() { - setCoverConfirmOpen(false); - setError(''); - setCoverGenerationDetail('正在根据项目内容生成封面…'); - setGeneratingCover(true); - try { - const generated = await generateGameDistributionCover({ - prompt: buildCoverGenerationPrompt(manifest, summary), - model: COVER_GENERATION_MODEL, - aspectRatio: COVER_GENERATION_ASPECT_RATIO, - imageSize: COVER_GENERATION_IMAGE_SIZE, - assetLabel: `${manifest.name.trim() || '游戏'}封面`, - }); - setCover({ - signature: generated.taskId - ? `generated:${generated.taskId}` - : `generated:${generated.assetObjectId}`, - name: 'AI 生成封面', - assetObjectId: generated.assetObjectId, - previewUrl: generated.previewUrl, - }); - setCoverGenerationDetail( - '封面已生成并自动设为发布封面;重新生成会再次消耗泥点。', - ); - } catch (generationError) { - setCoverGenerationDetail(''); - setError( - generationError instanceof Error - ? generationError.message - : '生成游戏封面失败,请重试', - ); - } finally { - setGeneratingCover(false); - } - } - - function createPanelScreenshotEntry(file: File): PanelScreenshotEntry { - const fileError = resolvePanelImageFileError(file, 'screenshot'); - return { - id: createPanelScreenshotId(), - name: file.name.trim() || '游戏截图', - previewUrl: buildPanelImagePreviewUrl(file), - assetObjectId: null, - status: fileError ? 'failed' : 'uploading', - error: fileError || null, - file, - }; - } - - function removeScreenshot(id: string) { - setScreenshots((current) => { - const target = current.find((item) => item.id === id); - if (target?.previewUrl && objectUrlsRef.current.has(target.previewUrl)) { - try { - URL.revokeObjectURL(target.previewUrl); - } catch { - // 预览地址释放失败不影响移除截图。 - } - objectUrlsRef.current.delete(target.previewUrl); - } - return current.filter((item) => item.id !== id); - }); - setPreviewScreenshot((current) => (current?.id === id ? null : current)); - } - - async function handleScreenshotsSelected(files: File[]) { - if (files.length === 0) return; - const counted = screenshots.filter( - (item) => item.status !== 'failed', - ).length; - const remaining = MAX_AGC_GAME_SCREENSHOTS - counted; - if (files.length > remaining) { - setError( - remaining > 0 - ? `游戏截图最多 6 张,还可以再选 ${remaining} 张` - : '游戏截图最多 6 张', - ); - if (screenshotInputRef.current) screenshotInputRef.current.value = ''; - return; - } - setError(''); - const entries = files.map(createPanelScreenshotEntry); - setScreenshots((current) => [...current, ...entries]); - - // 每张截图独立上传、独立失败;某一张失败只标记该张,不打断同批其它截图。 - await Promise.all( - entries.map(async (entry) => { - if (entry.status === 'failed' || !entry.file) return; - try { - const assetObjectId = await resolveAssetObjectId( - entry.file, - 'screenshot', - ); - setScreenshots((current) => - current.map((item) => - item.id === entry.id - ? { - ...item, - status: 'ready', - assetObjectId, - error: null, - } - : item, - ), - ); - } catch (uploadError) { - setScreenshots((current) => - current.map((item) => - item.id === entry.id - ? { - ...item, - status: 'failed', - assetObjectId: null, - error: - uploadError instanceof Error - ? uploadError.message - : '截图上传失败,请删除后重试', - } - : item, - ), - ); - } - }), - ); - if (screenshotInputRef.current) screenshotInputRef.current.value = ''; - } - - async function handleSubmit() { - if (!packageResult || !projectPath.trim()) { - setError('请先导出有效的试玩包'); - return; - } - // 版本号必须来自平台作者视图;拿不到就不发请求,也不猜本地工程迭代计数。 - if (nextVersionNumber === null) { - setError('无法确认项目版本,请重新打开发布面板后再试'); - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - setError('需要在 Tauri App 内发布'); - return; - } - if (publicationState === 'checking') { - setError('正在确认项目发布状态,请稍候再试'); - return; - } - if (publicationState === 'unavailable') { - setError( - publicationMessage || '无法确认该项目的线上发布状态,请稍后重试', - ); - return; - } - if (!cover) { - setError('请先选择游戏封面(JPG/PNG/WebP)'); - return; - } - if (!cover.assetObjectId.trim()) { - setError('当前发布封面缺少平台素材,请重新选择封面后再发布'); - return; - } - if ( - uploadingLabel || - screenshots.some((item) => item.status === 'uploading') - ) { - setError('素材还在上传中,请稍候再发布'); - return; - } - const price = resolvePlatformGamePriceMudPoints(priceMode, priceInput); - if (price.error) { - setPriceError(price.error); - return; - } - setPriceError(''); - setBusy(true); - setError(''); - setPublishPhase({ - phase: 'prepare', - message: '正在准备发布…', - percent: null, - }); - // 先订阅再发起发布:Rust 的「准备 / 上传 / 校验 / 送审」事件都在命令执行期间回传, - // 订阅是异步的,晚订阅会丢掉开头几个阶段。事件的 versionId 在命令返回前对渲染层 - // 不可知,所以只按「本次订阅窗口」收敛——同一窗口同一时刻只有一次发布在跑。 - let unsubscribe: TauriEventUnsubscribe = () => undefined; - try { - unsubscribe = await subscribeGamePackageUploadProgress((progress) => { - setPublishPhase({ - phase: progress.phase, - message: progress.message || '正在发布…', - percent: - progress.phase === 'upload' - ? computeGamePackageUploadPercent( - progress.receivedBytes, - progress.totalBytes, - ) - : null, - }); - }); - } catch { - // 订阅失败只影响阶段条:发布本身照常进行,按钮上仍显示「正在发布…」。 - } - try { - const next = await publishLocalProjectGame({ - invoke, - projectPath, - packageRelativePath: packageResult.packageRelativePath, - manifest, - metadata: { - title, - summary, - category, - coverAssetId: cover.assetObjectId, - screenshots: screenshots - .filter( - ( - item, - ): item is PanelScreenshotEntry & { - assetObjectId: string; - } => item.status === 'ready' && Boolean(item.assetObjectId), - ) - .map((item) => item.assetObjectId), - // 更新发布沿用线上冻结的设备/输入/方向声明;首次发布没有这些值,服务端默认。 - ...(restoredDeclarationsRef.current ?? {}), - }, - gameId: - publicationState === 'update' - ? (publicationBinding?.gameId ?? null) - : null, - // 展示与提交同一个平台版本号(作者视图最大版本号 + 1,首次发布为 1)。 - versionNumber: nextVersionNumber, - priceMudPoints: price.priceMudPoints, - expectedPublicationRevision: - publicationState === 'update' - ? (publicationBinding?.revision ?? null) - : null, - }); - setResult(next); - onPublished?.(next); - } catch (nextError) { - setError( - nextError instanceof Error ? nextError.message : String(nextError), - ); - } finally { - unsubscribe(); - setBusy(false); - setPublishPhase(null); - } - } - - const activeScreenshotCount = screenshots.filter( - (item) => item.status !== 'failed', - ).length; - const screenshotsUploading = screenshots.some( - (item) => item.status === 'uploading', - ); - const coverPriceLabel = - coverGenerationPrice === null ? '消耗泥点' : `${coverGenerationPrice} 泥点`; - - return ( - <> - undefined : onClose} - panelClassName="game-distribution-publish-panel" - closeOnBackdrop={!busy && !generatingCover} - closeOnEscape={!busy && !generatingCover} - > -
-
- - 发布到游戏广场 - -

让玩家现在就能试玩

-
- -
- {result ? ( -
- 已提交审核 -

版本已进入审核队列,审核通过后才会在游戏广场公开展示。

-

- 项目版本 v{result.versionNumber} ·{' '} - {result.packageSizeBytes.toLocaleString()} B ·{' '} - {result.packageSha256.slice(0, 16)}… -

-
- -
-
- ) : ( - <> -
- - {publicationState === 'update' - ? '更新游戏' - : publicationState === 'unavailable' - ? '无法确认发布状态' - : publicationState === 'checking' - ? '正在检查发布绑定…' - : '首次发布'} - - {publicationState === 'update' && publicationBinding ? ( - - 线上最近提交{' '} - {latestVersionNumber > 0 ? `v${latestVersionNumber}` : '—'} ·{' '} - {publicationVersionStatusLabel(publicationBinding.status)} - {publicationRefreshed - ? '' - : '(未能刷新线上状态,发布以服务端校验为准)'} - - ) : publicationState === 'first-publish' ? ( - 这个项目还没有发布到游戏广场。 - ) : publicationState === 'checking' ? ( - 正在读取本地绑定与线上作品状态。 - ) : ( - - {publicationMessage || - '无法确认该项目的线上发布状态,请稍后重试。'} - - )} - {publicationState === 'update' && publicationMessage ? ( - {publicationMessage} - ) : null} -
-
- -