From e1780b21b05321b0a4299772ed1bc9e8af8ac825 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 16:59:40 +0800 Subject: [PATCH 01/26] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20dev=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=20sccache=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start-tauri-dev.mjs 启动 Tauri CLI 前复用 scripts/dev.mjs 的 buildLocalRustProcessEnv,本地 Rust wrapper 由脚本决定而不是本机 Cargo 配置 - 新增 buildTauriDevProcessEnv 组装 dev 端点环境并导出,供测试直接断言 - 补 3 条用例:配置为 sccache 时改用 /usr/bin/env 直连 rustc、未配置时清空两个 wrapper 变量、自定义 wrapper 保留且不改写调用方 env - pitfalls 与开发运维文档补记 AGC dev 入口的 wrapper 口径 --- .../scripts/start-tauri-dev.mjs | 18 +++++-- .../tests/start-tauri-dev.test.ts | 52 +++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index e619c7123..3d72403ae 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -1,6 +1,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { defaultEditorFeatures, withDefaultCargoFeatures, @@ -75,6 +76,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) { }); } +/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的 +/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态 +/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用 +/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。 +function buildTauriDevProcessEnv(endpoint, env = process.env) { + return buildLocalRustProcessEnv({ + ...withAgcDevEndpointEnv(endpoint, env), + [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, + }); +} + async function runTauriDev( argv = process.argv.slice(2), { @@ -135,10 +147,7 @@ async function runTauriDev( endpoint.url, ); child = spawnCli(tauriArguments, { - env: { - ...withAgcDevEndpointEnv(endpoint), - [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, - }, + env: buildTauriDevProcessEnv(endpoint), }); const childResult = waitForCli(child); const outcome = await Promise.race([ @@ -225,6 +234,7 @@ function isDirectModuleExecution() { export { buildTauriArguments, + buildTauriDevProcessEnv, isDirectModuleExecution, runTauriDev, spawnTauriCli, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 7b8308410..dd83b82a8 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -12,6 +12,7 @@ import { } from '../scripts/start-dev-stack.mjs'; import { buildTauriArguments, + buildTauriDevProcessEnv, runTauriDev as runTauriDevImpl, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; @@ -300,3 +301,54 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { } }); }); + +describe('AI 游戏创作 Tauri dev 进程环境', () => { + const posixTest = process.platform === 'win32' ? test.skip : test; + + // 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量 + // 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。 + posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + RUSTC_WRAPPER: 'sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }); + + expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('未显式配置 wrapper 时清空两个变量', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const env = buildTauriDevProcessEnv(testEndpoint, { + CARGO_TERM_COLOR: 'never', + }); + + expect(env.RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(''); + expect(env.CARGO_TERM_COLOR).toBe('never'); + } finally { + warn.mockRestore(); + } + }); + + posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }; + const env = buildTauriDevProcessEnv(testEndpoint, input); + + expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper'); + expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port)); + expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' }); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 20e41dd15..712b30ac6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2973,9 +2973,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。 - 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。 -- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 +- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。`npm run agc` 的 Tauri Cargo 原先直接继承启动器环境,用户级 `~/.cargo/config.toml` 的 `rustc-wrapper` 会在这里生效并复现同一故障(表现为 `failed to run rustc to learn about target-specific information`,AGC 前端与配套后端已经起来、只有 Tauri 客户端退出);现在 `start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 的 `buildLocalRustProcessEnv`,把两个 wrapper 变量显式写进子进程环境——空环境变量同样能覆盖 Cargo 配置文件里的 wrapper,不能只依赖「本机没配 sccache」。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 - 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。 -- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 2bf1b55cf..0d39c0ee9 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -80,10 +80,12 @@ AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creato AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。 -Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示。 +`npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 + ### 本地 Rust 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 From e02811588c3b2de758829878f59b821dc1beca64 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 16:59:42 +0800 Subject: [PATCH 02/26] =?UTF-8?q?=E8=A1=A5=E5=85=85=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=20staging=20=E5=BD=92=E4=BD=8D?= =?UTF-8?q?=E6=8A=80=E6=9C=AF=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md:记录根因证据(tauri-build 把随包资源登记成 build script 输入)、准备步骤合同、构建脚本退化边界、入口接线、验收判据、风险与里程碑拆分 - docs/README.md 挂入文档索引 --- docs/README.md | 1 + ...术方案】AGC随包资源staging归位-2026-09-26.md | 194 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md diff --git a/docs/README.md b/docs/README.md index 20a519f46..6ea1a2b7e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [AGC 后端框架整理与演进路线](./technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md):共享 Runtime、本地执行宿主、云端控制面、领域/平台适配器及分阶段收口边界。 +- [AGC 随包资源 staging 归位](./technical/【技术方案】AGC随包资源staging归位-2026-09-26.md):随包资源改由准备步骤在 `tauri dev|build` 之前一次性生成、`build.rs` 退化为校验者;含缓存与原子性合同、入口接线、验收判据与里程碑拆分。 - [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md new file mode 100644 index 000000000..0ec2225b0 --- /dev/null +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -0,0 +1,194 @@ +# AGC 随包资源 staging 归位技术方案 + +状态:待评审(方案草案,评审通过前不进入实现) +日期:2026-09-26 +范围:AGC 客户端(`apps/ai-game-creator-shell`)随包资源的生成、校验与打包链路 +关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md` + +## 1. 一句话目标 + +把「随包资源」从 build script 的**产物**改回它的**输入**:由准备步骤在 `tauri dev|build` 之前一次性 staging,`build.rs` 退化为校验者,构建期不再向 `src-tauri/resources/**` 写任何文件。 + +## 2. 目标与非目标 + +### 2.1 目标 + +1. `build.rs` 的输入集合里不再包含任何「本次构建会写」的文件,`cargo` 在源码不变时稳定 fresh。 +2. Tauri dev 的文件监听不再因为 staging 变更重启 `cargo run`(macOS 与 Windows 一致,不依赖 `.taurignore` 兜)。 +3. staging 与上游版本的绑定可验证:版本、平台、逐文件摘要由校验器 fail closed 检出,不会静默发旧二进制。 +4. 打包产物内容与当前口径一致(三份 tauri 配置的 `resources` 映射与包内资源门禁不变)。 +5. 删除症状层补丁(整目录重建的规避、`prune_staging`、`STAGED_*` 幂等判断、`.taurignore` 的 staging 条目)。 + +### 2.2 非目标 + +1. 不改发布渠道、版本号机制、签名与上传流程。 +2. 不改运行时资源解析顺序与完整性校验语义(`codex_cli.rs`、`plugin_host.rs`、`environment_check.rs`、`editor_adapters.rs` 保持行为)。 +3. 不统一 `server-rs` 与 `src-tauri` 两个 workspace,不改 target 布局。 +4. 不为 Linux 增加客户端产物(Linux 上五条 staging 全为 no-op,见 §3.6)。 +5. 不改 Windows/macOS 之外的平台支持面。 + +## 3. 现状与证据 + +### 3.1 build script 目前负责五条 staging + +`apps/ai-game-creator-shell/src-tauri/build.rs` 的 `main()` 前段依次执行(`build.rs:225-229`): + +| # | 步骤 | 动作类型 | 平台门槛 | 目标路径 | +|---|---|---|---|---| +| 1 | `stage_bundled_codex_cli` | 纯复制(内容+权限比对) | Windows / macOS | `resources/codex/**` | +| 2 | `prepare_unity_editor_helper` | **外部工具链**(`powershell -File build.ps1`,需 .NET 10 SDK + VS C++ x64) | Windows + unity feature | `resources/plugins/agc-unity-editor/**` | +| 3 | `prepare_godot_editor_extension` | **外部工具链**(`powershell -File build.ps1`,需 CMake ≥ 3.25 + VS17 2022 + Python 3) | Windows + godot feature | `resources/plugins/agc-godot-editor/native/gdextension/**` | +| 4 | `stage_plugin_workspace` | 复制 + 清残留 | Windows / macOS | `resources/plugins/**` | +| 5 | `stage_cocos_editor_payload` | 复制(同一 cargo 构建产出的 cdylib) | Windows | `resources/plugins/agc-cocos-editor/native/payload/**` | + +(Prompt Bundle 生成代码写 `OUT_DIR`,属正确形态;Node 运行时已由 `scripts/stage-node-runtime.mjs` 在发布前一次性 staging,是本次改造的既有先例。) + +### 3.2 这些写入为什么会让 build script 永远失效 + +`tauri-build` 会对 `bundle.resources` 里的**每个文件**发 `cargo:rerun-if-changed`,并把它拷进 target(`tauri-build-2.6.3/src/lib.rs:88-93`)。我们的三份 tauri 配置把 `resources/codex/**` 与 `resources/plugins` 列进了 `bundle.resources`(`tauri.windows.conf.json:7-15`、`tauri.macos.conf.json:8-22`;基线配置故意不含,见 `check-config.mjs:1377-1383`)。 + +于是「构建期写的文件」与「build script 声明的输入」是同一批: + +```text +staging 写 resources/** → tauri-build 把同一批文件登记成输入 → mtime 变化 + ↑ ↓ + └────────────── cargo 判 build script stale,重跑脚本 ←──────┘ +``` + +判据是「输入文件 mtime 比 build script 的输出新」,与目录位置无关:把 staging 搬到 `target/` 也躲不开——登记的是配置里写的那些路径,只要构建期写它们,照样自触发。所以关键不是「产物放哪」,而是「**构建期有没有人写这些被登记的文件**」。 + +### 3.3 已发生的故障 + +| 症状 | 范围 | 观察证据 | +|---|---|---| +| 每次构建都重编主 crate(41–87 秒,从不变 fresh) | Windows + macOS(Linux 上五条 staging 全为 no-op,不受影响) | `CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build --no-default-features` 打印 `stale: changed "resources/codex/mac-native/darwin-arm64/NOTICE.md"`,且 `.fingerprint/**/run-build-script-build-script-build.json` 的 `RerunIfChanged.paths` 含 staging 目标路径 | +| `npm run agc` 反复 `Rebuilding application`,客户端起不来 | 仅 macOS | dev 日志一轮 28 次以上不收敛;原因是被重写的 `resources/codex/mac-native/**` 落在监听范围内且未被忽略 | +| `remove_dir_all` 抛 `DirectoryNotEmpty`,整次启动中断 | 并发重建时 | `清理 macOS Codex staging 失败` | + +### 3.4 三轮症状层修复都没有收口 + +| commit | 日期 | 修的是什么 | 结果 | +|---|---|---|---| +| `299877b19` | 2026-09-11 | 插件资源改成「内容变化才落盘」+ 引入 `.taurignore` | 只覆盖插件路径,且挡不住 cargo stale | +| `710d6dddf` | 2026-09-23(PR #487) | Windows 上「权限一致就不写元数据」 | 只减少一类写入,未解决整目录重建 | +| 本次未合入的 B 改动 | 2026-09-26 | 全部写入改幂等 + 按布局清残留 + 补 `.taurignore` | 实测可行(第二次 `cargo build` 0.25 秒 fresh),但规则靠人守:新增一条写 `resources/**` 的步骤漏改即回退(godot/cocos/plugin.json 正是三个漏点) | + +结论:**只要 build script 继续写这些受跟踪路径,就必须持续为每一处写入维护「无改动不写」,成本随写入点增长**;这是结构问题,不是疏漏问题。 + +## 4. 方案设计 + +### 4.1 原则 + +build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要「由本仓库生成、再随包分发」的内容,都由 `tauri dev|build` 之前的**准备步骤**生成,build script 只校验。 + +### 4.2 目标形态 + +```text +准备步骤(新,唯一写入方) build.rs(退化为校验者) + fetch/校验上游 → 生成到 staging 目录 只读 resources/** → 校验 manifest/hash/版本/平台 + → 原子替换到 resources/** → 不写任何随包资源 + → 写 manifest.json(schema 化) + ↑ ↑ + dev: start-tauri-dev 内、spawn tauri 之前 release: build-release/build-macos-ci 内 +``` + +### 4.3 准备步骤的合同(必须成立的行为) + +1. **调用时机**:`tauri dev` / `tauri build` 之前完成;任何入口都不得依赖 build script 兜底生成。 +2. **缓存与幂等**:以「上游 lockfile `resolved` + `integrity` + 布局版本 + 目标三元」为 key;命中且 manifest 校验通过的 staging 目录不重写任何文件(避免把「产物」变成「每次构建都变」的新源头)。 +3. **原子性**:写入 staging 临时目录后 rename 替换;不得出现半成品目录(杜绝并发下的 `DirectoryNotEmpty`)。 +4. **所有权**:只允许替换由本工具创建并带 manifest 的目录;遇到非本工具目录、符号链接、越界路径必须 fail closed(沿用 `stage-node-runtime.mjs` 与 `prepare-macos-codex.mjs` 既有判定)。 +5. **清理语义**:准备步骤负责删除本轮布局不再产出的残留(否则旧组件会继续被打包),删除范围限于自己的 staging 目录,不得触碰受版本控制的文件(例如 `resources/codex/win-x64/NOTICE.md`)。 +6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 +7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 +8. **并发安全**:同一 staging 目录的并发调用必须串行化或幂等收敛(dev 与 IDE 各自触发时不能互相破坏)。 + +### 4.4 build.rs 退化后的职责 + +保留: + +1. 读取 `TARGET` 并写 `cargo:rustc-env=AGC_BUILD_TARGET`(运行时定位随包目录依赖它)。 +2. 校验 `resources/**` 与清单一致:平台目录存在、manifest schema/平台/版本、逐文件 sha256、必需组件齐全(缺一即 fail closed)。 +3. 声明**真实输入**:上游源文件、布局表、受版本控制资源(含 macOS 声明文件)的 `rerun-if-changed`;不得声明任何由本脚本或准备步骤生成的文件。 +4. `tauri_build::build()` 与既有 Prompt Bundle、能力与配置校验。 + +删除: + +1. 五条 staging 的写入逻辑、`prune_staging`、`STAGED_*` 幂等判断、针对大目录的整目录删除。 +2. 为绕开自触发而加的 `.taurignore` staging 条目与说明(准备步骤在监听启动前完成,不再需要)。 + +### 4.5 资源清单(谁生成、谁消费) + +| 资源 | 生成方式 | 运行时消费方 | dev 是否需要 | +|---|---|---|---| +| `resources/codex/**` | 纯复制(上游平台包 `vendor/`) | `codex_cli.rs`(内置 sidecar 优先,其次 npm 目录、PATH) | macOS dev 只接受真 `.app` 的 `Contents/Resources`,非 `.app` 场景走 npm/PATH 回退;Windows dev 从 exe 同级读取 | +| `resources/plugins/**` | 复制 + 三个编辑器分支的产物 | `plugin_host.rs`(`AGC_PLUGIN_WORKSPACE` → `resource_dir/plugins` → dev 回退仓库 `plugins/`)、`editor_adapters.rs` | dev 有仓库回退,但 cocos/unity/godot payload 仍以随包路径为准 | +| `resources/plugins/agc-unity-editor/**`、`agc-godot-editor/native/gdextension/**` | 外部工具链(Windows 专属) | `editor_adapters.rs` 候选链 | 仅 Windows | +| `resources/node-runtime/**` | 已有:`stage-node-runtime.mjs` | `environment_check.rs`(`agc-node-runtime.v1` 全量 sha256) | 发布与需要随包 Node 的 dev | +| `design-agent`、`vendor/*` 许可 | 受版本控制 | `design_tools.rs` 等 | 无需 staging | + +### 4.6 入口接线 + +| 入口 | 位置 | 现状 | 改造后 | +|---|---|---|---| +| AGC dev | `start-tauri-dev.mjs`(`runTauriDev` → 预检 → 前端 → `spawnCli`,准备点在前端就绪之后、`spawnCli` 之前) | 无准备步骤,依赖 build script | 在 `spawnCli` 之前调用准备步骤(命中缓存时秒退) | +| Windows 发布 | `build-release.mjs`(`runTauriBuild`,现有 `stageRuntime(target)` 紧邻 spawn tauri) | 只有 Node 运行时走准备步骤 | 同一挂点串上全部 staging | +| macOS 发布 | `build-macos-ci.mjs`(复用 `runTauriBuild`)→ `check-macos-bundle.mjs` | 同上 | 同上 | +| 本机 Rust 门禁 | `ai-game-creator-shell:check:rust:shell`(Windows 上 `cargo test --no-run` 会跑 build script) | 依赖 build script 生成资源 | 校验器在该场景必须能只读通过;需要真实资源的用例沿用既有 fixture,不得依赖本机 staging 产物 | +| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 保持「不打包就不强制 staging」的口径,但校验器要按「未打包」放行 | +| CI(Linux) | `.gitea/workflows/project-ci.yml` 的 AGC 分组 | 五条 staging 全为 no-op | 不需要新增准备步骤;分片与 smoke 命令不变 | + +### 4.7 与现有机制的关系 + +1. **已有先例**:`stage-node-runtime.mjs` 已实现 schema 常量、目标平台失败关闭、staging 目录 + rename 原子替换、拒绝覆盖非本工具目录;`prepare-macos-codex.mjs` 已实现 lockfile `integrity` 驱动的下载、缓存与原子替换。准备步骤应复用这两套范式而不是另起一套。 +2. **打包侧不变**:三份 tauri 配置的 `resources` 映射与 `check-config.mjs:1356-1424` 的逐字断言保持不变;`check-macos-bundle.mjs` 对包内 `coding-agent/mac-native`、`game-runtime/node`、`plugins/agc-cocos-editor` 的存在性、架构与 sha256 断言继续作为发布后门禁。 +3. **fail closed 已有兜底**:`tauri-build` 在资源缺失时以 `ResourcePathNotFound` 直接失败;校验器应比它更早、更明确地报错。 + +## 5. 兼容与迁移 + +1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 +2. **过渡期**:改造期间 `resources/**` 仍由 build script 生成(当前主线状态),准备步骤上线后先并存(生成结果与校验一致),再删除 build script 的写入分支——删除与新增不得跨里程碑混在一起。 +3. **本机残留**:改造后 `.taurignore` 的 staging 条目、`prune_staging` 及相关注释一并删除;`resources/**` 仍保持 gitignored。 +4. **回滚**:准备步骤与校验器保持独立可关闭(例如校验器只读、不写),回滚只需恢复 build script 的写入分支,不涉及数据迁移。 + +## 6. 验收标准与证据 + +| 项 | 判据 | +|---|---| +| 构建新鲜度 | 源码不变时连续两次 `cargo build --no-default-features` 第二次为秒级 `Finished`;IDE 的 `cargo check --all-targets` 同样不重复构建 | +| 无自触发 | `cargo:rerun-if-changed` 输出与 fingerprint 记录里不出现任何 `src-tauri/resources/**` 路径 | +| dev 可用 | macOS/Windows `npm run agc` 在准备步骤后一次成功:`Rebuilding application` 为 0 次、`Running DevCommand` 为 1 次,客户端与 Runner 进程稳定存活 | +| 包内容 | `check-macos-bundle.mjs` 全绿;Windows 安装包内 `coding-agent`、`plugins`、`game-runtime/node` 与改造前逐项一致 | +| 失败关闭 | 上游缺失 / integrity 不匹配 / 清单缺组件 / 目标平台不支持 四类场景各自返回明确错误且不产出包 | +| 幂等 | 准备步骤连续执行两次,staging 目录内容与 mtime 不变(不改动受跟踪输入) | +| 门禁 | `check-config.mjs`、`check:encoding`、`check:doc-index`、`git diff --check`、AGC 相关 vitest 与 Rust 测试全绿 | + +未验证项必须在交付记录中标注(例如 Windows 真机行为、真实上游包下载在受限网络下的表现)。 + +## 7. 风险与回滚 + +| 风险 | 影响 | 措施 | +|---|---|---| +| 忘记调用准备步骤(dev 或某个发布入口) | 资源缺失,打包或启动失败 | 校验器 fail closed + 入口测试断言「spawn tauri 前已调用准备步骤」 | +| staging 缓存 key 不覆盖上游变化 | 静默发旧二进制 | key 含 lockfile `resolved`+`integrity`+布局版本+三元;manifest 校验作为第二道闸 | +| 外部工具链步骤(unity/godot)搬出后顺序变化 | Windows 打包失败 | 准备步骤显式声明工具链前置检查;先在 Windows 上单独验证再合入 | +| 并发调用(dev 与 IDE 同时触发) | staging 目录损坏 | 原子替换 + 所有权校验 + 有界重试 | +| 迁移期两套生成并存 | 结果漂移 | 并存阶段以「准备步骤生成结果 == build script 生成结果」逐文件比对作为过渡判据 | + +回滚点:准备步骤上线但校验器未启用前,任一步失败都可直接恢复 build script 写入分支,无需数据迁移。 + +## 8. 里程碑拆分(建议) + +| 里程碑 | 交付 | 停止条件 | +|---|---|---| +| M1 校验器化 | `build.rs` 增加只读校验路径、准备步骤脚本骨架(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿,且不改变现有构建行为 | +| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | macOS 与 Windows 均达到 §6 的前三行判据 | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `prune_staging`、`STAGED_*`、`.taurignore` staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿 | + +里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 + +## 9. 未决问题 + +1. 准备步骤用 Rust bin(可复用 `codex_bundle.rs` 的布局表与校验)还是 Node 脚本(与 `stage-node-runtime.mjs` 同构)?倾向前者以复用布局表与 sha256 校验,避免第二份白名单。 +2. unity/godot 的外部工具链步骤是否值得搬出 build script(它们本身是构建动作,搬出后需要显式前置顺序)——需在 Windows 上确认收益与风险。 +3. `resources/**` 是否需要继续保留在 crate 内(`bundle.resources` 相对路径解析要求),还是改用生成式配置指向 `target/` 下的 staging:前者改动小、后者更彻底,需与 Tauri 的资源解析规则一起评估。 From 756297d2df90d5f99e021463ea880de9d6a57176 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 16:59:42 +0800 Subject: [PATCH 03/26] =?UTF-8?q?=E8=A1=A5=E5=85=85=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=20staging=20=E5=BD=92=E4=BD=8D?= =?UTF-8?q?=E9=87=8C=E7=A8=8B=E7=A2=91=E4=B8=8E=E9=A6=96=E6=AE=B5=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增三份里程碑规范:资源改由校验器读入、生成接入 dev 与发布入口、编辑器分支产物归位与症状层补丁清理 - 新增首个里程碑实施计划:修改边界、实现顺序、验证命令、风险与回滚点、待确认的实现形态 --- ...计划】AGC随包资源改由校验器读入-2026-09-26.md | 62 +++++++++++++++++++ ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 44 +++++++++++++ ...¨‹碑】AGC随包资源改由校验器读入-2026-09-26.md | 44 +++++++++++++ ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 46 ++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md create mode 100644 docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md create mode 100644 docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md create mode 100644 docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..f609c1c72 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,62 @@ +# 【实施计划】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| --------- | --------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md` | +| Status | ready(待里程碑规范评审通过后开工) | +| Owner | suzmii / Agent | + +## 修改边界 + +允许修改: + +- `apps/ai-game-creator-shell/src-tauri/build.rs`:新增只读校验调用点;本里程碑内保持现有写入分支不变(不改变既有构建行为)。 +- `apps/ai-game-creator-shell/src-tauri/build_support/**`:把平台布局、组件白名单、摘要校验整理为可被构建脚本之外的独立工具复用的一处声明。 +- 新增随包资源准备工具及其测试(位置见「待确认决策」)。 +- 需要时扩展 `apps/ai-game-creator-shell/scripts/check-config.mjs` 的断言。 +- 文档:主规范未决问题收口、开发运维文档对应段落。 + +明确不修改: + +- 三份 tauri 配置的 `resources` 映射、包内路径与安装包形态。 +- 运行时资源解析与完整性校验(`codex_cli.rs`、`plugin_host.rs`、`editor_adapters.rs`、`environment_check.rs`)。 +- 发布脚本流程、版本号机制、签名与上传。 +- dev 启动器与发布入口的接线(下一里程碑)。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式(最后一个里程碑)。 + +## 实现顺序 + +1. **共用能力可复用**:确认 `build_support` 内的平台布局与组件白名单能被独立工具引用(现状先例:`src/agent/codex_cli.rs` 与 `main.rs` 已通过 `#[path]` 复用同一模块),把「布局 + 白名单 + 摘要校验」收敛为单一入口,避免准备工具另写一份清单。 +2. **准备工具骨架**:目标目录与清单写出、缓存 key(上游 lockfile 的 `resolved` + `integrity` + 布局版本 + 目标三元)、临时目录 + 原子替换、所有权与符号链接校验、并发串行化、单行汇总日志。先实现纯复制两条路径(随包组件、插件工作区),编辑器分支产物本轮仍由构建脚本生成。 +3. **幂等与失败关闭**:重复执行不改变内容与时间戳;上游缺失、摘要不匹配、目录被非本工具占用、目标平台不支持四类场景各自失败并给出可定位原因。 +4. **校验路径上线**:构建脚本在既有产物上执行只读校验(默认不影响现有写入行为),校验失败以明确原因中止。 +5. **测试与证据**:按里程碑「证据要求」补齐用例与运行记录。 + +## 验证命令 + +1. 布局与白名单唯一性:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features codex_bundle` +2. 校验路径用例:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features <新增校验用例过滤名>` +3. 幂等:连续两次运行准备工具,比对目标目录内容与时间戳快照(内容与 mtime 均不变) +4. 失败关闭:对上游缺失、摘要篡改、非本工具目录、非目标平台四种场景各跑一次,记录错误输出 +5. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线) +6. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试 + +## 风险与回滚点 + +| 风险 | 影响 | 处理 | +| --- | --- | --- | +| 校验器误判把构建卡死 | 影响所有本机构建 | 只读校验先以「不影响写入行为」的方式接入;出现误判可先关闭校验调用点回滚 | +| 准备工具与构建脚本并存产生双写 | 两处结果漂移、时间戳变化 | 并存期以「准备工具产物 == 构建脚本产物」逐文件比对作为过渡判据;不一致视为失败 | +| 缓存 key 漏掉上游变化 | 静默用旧组件 | key 含 lockfile `resolved` + `integrity` + 布局版本 + 三元;清单校验作为第二道闸 | +| 准备工具实现形态选错 | 返工 | 见「待确认决策」,评审时一次定清 | + +回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。 + +## 待确认决策 + +准备工具的实现形态(主规范未决问题 1)建议为**混合**: + +- 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具调用或由校验路径承担。 + +理由:避免出现第二份组件白名单;同时不必重写 registry 下载、`integrity` 与 tar 安全校验。若评审倾向单一语言实现,需接受「另写一份白名单」或「把 Node 侧下载能力用 Rust 重写」二者之一。 diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md new file mode 100644 index 000000000..8fcc64b1e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -0,0 +1,44 @@ +# 【里程碑】AGC 编辑器分支产物归位与症状层补丁清理 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。 + +## 范围 + +- 三个编辑器分支产物的生成职责迁出构建脚本,包括需要外部工具链的两条路径。 +- 构建脚本内与资源生成相关的规避手段删除:残留清理逻辑、为幂等而设的辅助常量与判断、开发监听忽略条目中与随包资源相关的部分。 +- 平台与特性开关(哪些平台、哪些特性才需要这些产物)在新形态下保持既有语义。 +- 与发布打包、包内资源门禁、运行时解析的一致性核对。 + +## 不在范围内 + +- 编辑器分支本身的接入协议、宿主能力与运行时行为。 +- 外部工具链版本管理与安装流程(沿用现状)。 +- 随包组件与插件工作区的生成形态(上一里程碑已完成)。 + +## 依赖与前置条件 + +- 前两个里程碑验收通过。 +- Windows 环境具备 Unity/Godot 分支所需的工具链(.NET 与 CMake 等),以便验证产物生成与打包。 + +## 验收标准 + +- [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。 +- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。 +- [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。 +- [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 +- [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 + +## 证据要求 + +- 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。 +- 运行时:Windows 上一次完整打包与一次客户端启动,确认编辑器分支产物被读取。 +- 边界:缺少外部工具链、缺少组件、非目标平台三种情形下的失败与跳过语义。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md new file mode 100644 index 000000000..57c6d8e2a --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -0,0 +1,44 @@ +# 【里程碑】AGC 随包资源改由校验器读入 + +| 字段 | 值 | +| ----------- | ----------------------------------------------------------- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。 + +## 范围 + +- 随包资源的合规性判定:平台目录存在、清单 schema 与平台一致、逐文件摘要一致、必需组件齐全、版本与上游锁定一致。 +- 资源生成能力的可复用化:同一份布局与摘要校验能力既能被构建期校验使用,也能被准备步骤使用,不得出现第二份组件白名单。 +- 生成结果的稳定性要求:同一输入重复生成时,产物内容与文件时间戳不发生变化。 + +## 不在范围内 + +- 接入 dev 与发布入口(下一里程碑)。 +- 移除构建脚本里的资源写入分支。 +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用。 +- 运行时资源解析顺序、完整性校验语义与打包配置里的资源映射。 +- Linux 产物支持。 + +## 依赖与前置条件 + +- 主规范第 4.3 与第 4.4 节的合同(准备步骤合同、构建脚本退化后的职责边界)。 +- 现有随包资源与清单已由当前实现产出,可用于校验回归。 + +## 验收标准 + +- [ ] 资源合规时,校验路径可独立运行并通过,不依赖构建脚本的写入分支。 +- [ ] 上游锁定版本、平台、逐文件摘要、必需组件四类不一致各自被拒绝,并给出可定位的原因。 +- [ ] 重复执行资源生成,产物内容与文件时间戳不变(幂等)。 +- [ ] 同一份布局与组件白名单只有一处声明,构建期校验与准备步骤共用。 + +## 证据要求 + +- 自动化:资源校验的通过/拒绝用例;同输入重复生成后目录快照对比(内容 + 时间戳)。 +- 运行时:本机在既有随包资源上运行一次校验与一次构建,确认资源被正常读取且构建行为与改造前一致。 +- 边界:目标平台不支持、上游缺失、摘要不匹配、目录被非本工具内容占用四种场景各自的失败输出。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md new file mode 100644 index 000000000..1f70aa288 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -0,0 +1,46 @@ +# 【里程碑】AGC 随包资源生成接入 dev 与发布入口 + +| 字段 | 值 | +| ----------- | --------------------------------------------------------------- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-26 | +| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | + +## 目标 + +随包资源在客户端开发与发布两条链上,都由启动/打包之前的准备步骤一次性生成;构建脚本不再承担生成职责,源码不变时构建不再重复编译。 + +## 范围 + +- 客户端开发的启动流程:在拉起客户端之前完成资源准备,命中缓存时不重写任何文件。 +- 发布打包流程:Windows 与 macOS 两条链在拉起打包工具之前完成资源准备,包括既有的运行时资源准备点。 +- 纯复制型资源(随包组件与插件工作区)的生成职责从构建脚本迁出。 +- 不打包场景(仅校验、不产包)的放行口径。 + +## 不在范围内 + +- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用(下一里程碑)。 +- 打包配置里的资源映射、包内资源门禁与安装包形态。 +- 运行时资源解析与完整性校验语义。 +- 构建脚本中与资源无关的既有职责(配置能力、提示词产物、元数据)。 + +## 依赖与前置条件 + +- 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。 +- 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。 +- Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。 + +## 验收标准 + +- [ ] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。 +- [ ] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。 +- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。 +- [ ] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。 +- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。 + +## 证据要求 + +- 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。 +- 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。 +- 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。 From 21c3bd7a2b39e05bc8d786be42ce9767b24a831e Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:35:57 +0800 Subject: [PATCH 04/26] =?UTF-8?q?=E5=BC=95=E5=85=A5=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=E7=9A=84=E5=8D=95=E4=B8=80=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E4=B8=8E=E7=94=9F=E6=88=90=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 build_support/package-layout.json:Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛的唯一人工声明 - 新增 scripts/check-package-layout.mjs:由声明生成 build_support/package-layout.generated.rs,并校验目标唯一、可执行组件属于白名单、每个目标恰有一条第三方声明来源、codex.version 与应用锁定的 @openai/codex 一致 - 新增 build_support/package_layout.rs:声明类型、插件与平台门槛判定、逐块 sha256、随包 Codex 目录只读校验及其单测 - codex_bundle.rs 的布局与版本常量改由声明提供,公开接口与取值不变;src/main.rs 在测试期引入该模块以复用既有单测 - package.json 新增 bundled-resources:check/sync 并接进 typecheck 链,根 package.json 提供 agc:* 别名 --- apps/ai-game-creator-shell/package.json | 6 +- .../scripts/check-package-layout.mjs | 477 ++++++++++++ .../src-tauri/build_support/codex_bundle.rs | 68 +- .../build_support/package-layout.generated.rs | 115 +++ .../build_support/package-layout.json | 110 +++ .../src-tauri/build_support/package_layout.rs | 684 ++++++++++++++++++ .../src-tauri/src/main.rs | 5 + package.json | 4 + 8 files changed, 1426 insertions(+), 43 deletions(-) create mode 100755 apps/ai-game-creator-shell/scripts/check-package-layout.mjs create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json create mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c9c7a2d3a..894ae2ffc 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -13,6 +13,10 @@ "skill-pack:check": "node scripts/check-skill-pack.mjs", "skill-pack:sync": "node scripts/check-skill-pack.mjs --write", "skill-pack:test": "node --test scripts/check-skill-pack.test.mjs", + "bundled-resources:check": "node scripts/check-package-layout.mjs", + "bundled-resources:sync": "node scripts/check-package-layout.mjs --write", + "bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs", + "bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "config": "node scripts/game-creator-config-wizard.mjs", @@ -24,7 +28,7 @@ "agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", - "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs" + "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs" }, "dependencies": { "@cubone/react-file-manager": "^1.35.0", diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs new file mode 100755 index 000000000..d3a5e1237 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -0,0 +1,477 @@ +#!/usr/bin/env node +// 随包资源声明门禁:把 build_support/package-layout.json(唯一人工声明)渲染成 +// build_support/package-layout.generated.rs(Rust 编译期常量),并校验声明结构与不变量。 +// +// 用法: +// node scripts/check-package-layout.mjs 校验生成结果是否与声明一致(不一致 exit 1) +// node scripts/check-package-layout.mjs --write 重新生成 +// +// 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量; +// Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。 + +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const SRC_TAURI = path.join(APP_ROOT, 'src-tauri'); +const DECLARATION_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.json', +); +const GENERATED_PATH = path.join( + SRC_TAURI, + 'build_support/package-layout.generated.rs', +); + +const EXPECTED_SCHEMA = 'agc-package-layout.v1'; + +class DeclarationError extends Error {} + +function fail(message) { + throw new DeclarationError(message); +} + +function expectObject(value, at) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${at} 必须是对象`); + } + return value; +} + +function expectArray(value, at) { + if (!Array.isArray(value)) { + fail(`${at} 必须是数组`); + } + return value; +} + +function expectString(value, at) { + if (typeof value !== 'string' || value.length === 0) { + fail(`${at} 必须是非空字符串`); + } + return value; +} + +function expectBoolean(value, at) { + if (typeof value !== 'boolean') { + fail(`${at} 必须是布尔值`); + } + return value; +} + +function expectStringArray(value, at) { + return expectArray(value, at).map((item, index) => + expectString(item, `${at}[${index}]`), + ); +} + +function optionalStringArray(source, key, at) { + if (source[key] === undefined) { + return []; + } + return expectStringArray(source[key], `${at}.${key}`); +} + +function expectInteger(value, at) { + if (!Number.isInteger(value) || value < 0) { + fail(`${at} 必须是非负整数`); + } + return value; +} + +// JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。 +function rustString(value) { + return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}'); +} + +function rustStrings(values) { + return `&[${values.map(rustString).join(', ')}]`; +} + +function indent(level) { + return ' '.repeat(level); +} + +function renderStruct(name, fields, level = 0) { + const body = fields + .map(([key, rendered]) => `${indent(level + 1)}${key}: ${rendered},`) + .join('\n'); + return `${name} {\n${body}\n${indent(level)}}`; +} + +function parseDeclaration(raw) { + const root = expectObject(JSON.parse(raw), 'root'); + if (root.schema !== EXPECTED_SCHEMA) { + fail( + `不支持的声明 schema:${String(root.schema)}(期望 ${EXPECTED_SCHEMA})`, + ); + } + const layoutVersion = expectInteger(root.layoutVersion, 'layoutVersion'); + + const codex = expectObject(root.codex, 'codex'); + const targets = expectArray(codex.targets, 'codex.targets').map( + (entry, index) => { + const at = `codex.targets[${index}]`; + const parsed = expectObject(entry, at); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } + const executable = expectString(parsed.executable, `${at}.executable`); + if (!files.includes(executable)) { + fail(`${at}.executable 必须属于组件白名单:${executable}`); + } + return { + target: expectString(parsed.target, `${at}.target`), + platform: expectString(parsed.platform, `${at}.platform`), + directory: expectString(parsed.directory, `${at}.directory`), + executable, + files, + }; + }, + ); + const seenTargets = new Set(); + for (const entry of targets) { + if (seenTargets.has(entry.target)) { + fail(`codex.targets 重复声明目标 ${entry.target}`); + } + seenTargets.add(entry.target); + } + + const noticeSources = expectArray( + codex.noticeSources, + 'codex.noticeSources', + ).map((entry, index) => { + const at = `codex.noticeSources[${index}]`; + const parsed = expectObject(entry, at); + return { + targets: expectStringArray(parsed.targets, `${at}.targets`), + source: expectString(parsed.source, `${at}.source`), + preserve: expectBoolean(parsed.preserve, `${at}.preserve`), + }; + }); + for (const entry of targets) { + const covered = noticeSources.filter((notice) => + notice.targets.includes(entry.target), + ); + if (covered.length !== 1) { + fail( + `目标 ${entry.target} 必须且只能有一条第三方声明来源(实际 ${covered.length} 条)`, + ); + } + } + + const universalGroups = expectArray( + codex.universalGroups, + 'codex.universalGroups', + ).map((entry, index) => { + const at = `codex.universalGroups[${index}]`; + const parsed = expectObject(entry, at); + const groupTargets = expectStringArray(parsed.targets, `${at}.targets`); + for (const target of groupTargets) { + if (!seenTargets.has(target)) { + fail(`${at}.targets 含未声明目标 ${target}`); + } + } + return { + name: expectString(parsed.name, `${at}.name`), + directory: expectString(parsed.directory, `${at}.directory`), + targets: groupTargets, + }; + }); + + const plugins = expectObject(root.plugins, 'plugins'); + const subdirectories = expectArray( + plugins.subdirectories, + 'plugins.subdirectories', + ).map((entry, index) => { + const at = `plugins.subdirectories[${index}]`; + const parsed = expectObject(entry, at); + return { + path: expectString(parsed.path, `${at}.path`), + targetContains: optionalStringArray(parsed, 'targetContains', at), + targets: optionalStringArray(parsed, 'targets', at), + features: optionalStringArray(parsed, 'features', at), + }; + }); + const libraryStaging = expectArray( + plugins.libraryStaging, + 'plugins.libraryStaging', + ).map((entry, index) => { + const at = `plugins.libraryStaging[${index}]`; + const parsed = expectObject(entry, at); + return { + plugin: expectString(parsed.plugin, `${at}.plugin`), + sourceSubdirectory: expectString( + parsed.sourceSubdirectory, + `${at}.sourceSubdirectory`, + ), + targets: expectStringArray(parsed.targets, `${at}.targets`), + features: expectStringArray(parsed.features, `${at}.features`), + layout: expectString(parsed.layout, `${at}.layout`), + }; + }); + + return { + layoutVersion, + codex: { + version: expectString(codex.version, 'codex.version'), + cliVersionPrefix: expectString( + codex.cliVersionPrefix, + 'codex.cliVersionPrefix', + ), + manifestSchema: expectString( + codex.manifestSchema, + 'codex.manifestSchema', + ), + resourceDirectory: expectString( + codex.resourceDirectory, + 'codex.resourceDirectory', + ), + manifestFileName: expectString( + codex.manifestFileName, + 'codex.manifestFileName', + ), + packageMetadataFileName: expectString( + codex.packageMetadataFileName, + 'codex.packageMetadataFileName', + ), + noticeFileName: expectString( + codex.noticeFileName, + 'codex.noticeFileName', + ), + sourceRoots: expectStringArray(codex.sourceRoots, 'codex.sourceRoots'), + sourceRelativePaths: expectStringArray( + codex.sourceRelativePaths, + 'codex.sourceRelativePaths', + ), + noticeSources, + universalGroups, + targets, + }, + plugins: { + sourceDirectory: expectString( + plugins.sourceDirectory, + 'plugins.sourceDirectory', + ), + destinationDirectory: expectString( + plugins.destinationDirectory, + 'plugins.destinationDirectory', + ), + manifestFileName: expectString( + plugins.manifestFileName, + 'plugins.manifestFileName', + ), + targetContainsAny: expectStringArray( + plugins.targetContainsAny, + 'plugins.targetContainsAny', + ), + subdirectories, + libraryStaging, + skipDirectoryNames: expectStringArray( + plugins.skipDirectoryNames, + 'plugins.skipDirectoryNames', + ), + skipDirectoryNamePrefixes: expectStringArray( + plugins.skipDirectoryNamePrefixes, + 'plugins.skipDirectoryNamePrefixes', + ), + skipFileNamePrefixes: expectStringArray( + plugins.skipFileNamePrefixes, + 'plugins.skipFileNamePrefixes', + ), + skipFileNameFragments: expectStringArray( + plugins.skipFileNameFragments, + 'plugins.skipFileNameFragments', + ), + }, + }; +} + +function renderCodexTarget(entry) { + return renderStruct( + 'CodexTarget', + [ + ['target', rustString(entry.target)], + ['platform', rustString(entry.platform)], + ['directory', rustString(entry.directory)], + ['executable', rustString(entry.executable)], + ['files', rustStrings(entry.files)], + ], + 1, + ); +} + +function renderNoticeSource(entry) { + return renderStruct( + 'NoticeSource', + [ + ['targets', rustStrings(entry.targets)], + ['source', rustString(entry.source)], + ['preserve', entry.preserve ? 'true' : 'false'], + ], + 1, + ); +} + +function renderUniversalGroup(entry) { + return renderStruct( + 'UniversalGroup', + [ + ['name', rustString(entry.name)], + ['directory', rustString(entry.directory)], + ['targets', rustStrings(entry.targets)], + ], + 1, + ); +} + +function renderSubdirectory(entry) { + return renderStruct( + 'Subdirectory', + [ + ['path', rustString(entry.path)], + ['target_contains', rustStrings(entry.targetContains)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ], + 1, + ); +} + +function renderLibraryStaging(entry) { + return renderStruct( + 'LibraryStaging', + [ + ['plugin', rustString(entry.plugin)], + ['source_subdirectory', rustString(entry.sourceSubdirectory)], + ['targets', rustStrings(entry.targets)], + ['features', rustStrings(entry.features)], + ['layout', rustString(entry.layout)], + ], + 1, + ); +} + +function renderGenerated(declaration) { + const codex = declaration.codex; + const plugins = declaration.plugins; + const codexStruct = renderStruct('Codex', [ + ['resource_directory', rustString(codex.resourceDirectory)], + ['manifest_file_name', rustString(codex.manifestFileName)], + ['package_metadata_file_name', rustString(codex.packageMetadataFileName)], + ['notice_file_name', rustString(codex.noticeFileName)], + ['source_roots', rustStrings(codex.sourceRoots)], + ['source_relative_paths', rustStrings(codex.sourceRelativePaths)], + [ + 'notice_sources', + `&[\n${codex.noticeSources.map(renderNoticeSource).join(',\n')}\n]`, + ], + [ + 'universal_groups', + `&[\n${codex.universalGroups.map(renderUniversalGroup).join(',\n')}\n]`, + ], + ['targets', `&[\n${codex.targets.map(renderCodexTarget).join(',\n')}\n]`], + ]); + const pluginsStruct = renderStruct('Plugins', [ + ['source_directory', rustString(plugins.sourceDirectory)], + ['destination_directory', rustString(plugins.destinationDirectory)], + ['manifest_file_name', rustString(plugins.manifestFileName)], + ['target_contains_any', rustStrings(plugins.targetContainsAny)], + [ + 'subdirectories', + `&[\n${plugins.subdirectories.map(renderSubdirectory).join(',\n')}\n]`, + ], + [ + 'library_staging', + `&[\n${plugins.libraryStaging.map(renderLibraryStaging).join(',\n')}\n]`, + ], + ['skip_directory_names', rustStrings(plugins.skipDirectoryNames)], + [ + 'skip_directory_name_prefixes', + rustStrings(plugins.skipDirectoryNamePrefixes), + ], + ['skip_file_name_prefixes', rustStrings(plugins.skipFileNamePrefixes)], + ['skip_file_name_fragments', rustStrings(plugins.skipFileNameFragments)], + ]); + + return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:package-layout:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)}; +pub const LAYOUT_VERSION: u64 = ${declaration.layoutVersion}; + +pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; +pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; +pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; + +pub const CODEX: Codex = ${codexStruct}; + +pub const PLUGINS: Plugins = ${pluginsStruct}; +`; +} + +function appLockedCodexVersion() { + const appPackage = expectObject( + JSON.parse(readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')), + 'apps/ai-game-creator-shell/package.json', + ); + const declared = + appPackage.dependencies?.['@openai/codex'] ?? + appPackage.devDependencies?.['@openai/codex']; + if (typeof declared !== 'string' || declared.length === 0) { + fail( + '应用 package.json 未声明 @openai/codex,无法校验声明的 codex.version', + ); + } + return declared.replace(/^[\^~]/, ''); +} + +function main() { + const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8')); + const lockedVersion = appLockedCodexVersion(); + if (lockedVersion !== declaration.codex.version) { + fail( + `声明 codex.version(${declaration.codex.version})与应用锁定的 @openai/codex(${lockedVersion})不一致;请同步更新 build_support/package-layout.json`, + ); + } + const rendered = renderGenerated(declaration); + const write = process.argv.includes('--write'); + if (write) { + writeFileSync(GENERATED_PATH, rendered); + console.log( + `随包资源声明已生成:${path.relative(process.cwd(), GENERATED_PATH)}(layoutVersion ${declaration.layoutVersion})`, + ); + return; + } + const current = readFileSync(GENERATED_PATH, 'utf8'); + if (current !== rendered) { + console.error( + [ + `随包资源声明与 Rust 常量不一致:`, + ` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`, + ` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`, + '请运行:npm run agc:package-layout:sync', + ].join('\n'), + ); + process.exit(1); + } + console.log( + `随包资源声明一致(layoutVersion ${declaration.layoutVersion},codex ${declaration.codex.version},目标 ${declaration.codex.targets.length},插件子目录 ${declaration.plugins.subdirectories.length})`, + ); +} + +try { + main(); +} catch (error) { + if (error instanceof DeclarationError) { + console.error(`随包资源声明无效:${error.message}`); + process.exit(1); + } + throw error; +} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs index e2064d28d..d802866df 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -1,8 +1,18 @@ //! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。 +//! +//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json` +//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量, +//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。 +//! 本模块只读声明,不写任何随包资源。 -pub const VERSION: &str = "0.155.1"; -pub const CLI_VERSION: &str = "codex-cli 0.155.1"; -pub const SCHEMA: &str = "genarrative-codex-sidecar.v2"; +// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +pub(crate) mod package_layout; + +pub const VERSION: &str = package_layout::CODEX_VERSION; +pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION; +pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA; #[derive(Clone, Copy, Debug)] pub struct Layout { @@ -12,46 +22,13 @@ pub struct Layout { pub files: &'static [&'static str], } -const WINDOWS_FILES: &[&str] = &[ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; -const MAC_FILES: &[&str] = &[ - "bin/codex", - "bin/codex-code-mode-host", - "codex-path/rg", - "codex-resources/zsh/bin/zsh", - "codex-package.json", -]; - pub fn for_target(target: &str) -> Option { - match target { - "x86_64-pc-windows-msvc" => Some(Layout { - platform: "win32-x64", - directory: "win-x64", - executable: "bin/codex.exe", - files: WINDOWS_FILES, - }), - "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout { - platform: if target.starts_with("aarch64") { - "darwin-arm64" - } else { - "darwin-x64" - }, - directory: if target.starts_with("aarch64") { - "mac-native/darwin-arm64" - } else { - "mac-native/darwin-x64" - }, - executable: "bin/codex", - files: MAC_FILES, - }), - _ => None, - } + package_layout::codex_target(target).map(|declared| Layout { + platform: declared.platform, + directory: declared.directory, + executable: declared.executable, + files: declared.files, + }) } #[cfg(test)] @@ -80,4 +57,11 @@ mod tests { assert!(for_target("aarch64-pc-windows-msvc").is_none()); assert!(for_target("x86_64-unknown-linux-gnu").is_none()); } + + #[test] + fn constants_come_from_the_shared_declaration() { + assert_eq!(VERSION, "0.155.1"); + assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}")); + assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs new file mode 100644 index 000000000..ebfe90b06 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -0,0 +1,115 @@ +// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs +// 来源:build_support/package-layout.json。不要手工编辑本文件。 +// 修改随包资源布局请编辑声明文件,然后运行 +// npm run agc:package-layout:sync(在仓库根目录) +// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 + +pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1"; +pub const LAYOUT_VERSION: u64 = 1; + +pub const CODEX_VERSION: &str = "0.155.1"; +pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; +pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; + +pub const CODEX: Codex = Codex { + resource_directory: "resources/codex", + manifest_file_name: "manifest.json", + package_metadata_file_name: "codex-package.json", + notice_file_name: "NOTICE.md", + source_roots: &["app", "repo"], + source_relative_paths: &["node_modules/@openai/codex-/vendor/", "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/"], + notice_sources: &[ +NoticeSource { + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + preserve: false, + }, +NoticeSource { + targets: &["x86_64-pc-windows-msvc"], + source: "resources/codex/win-x64/NOTICE.md", + preserve: true, + } +], + universal_groups: &[ +UniversalGroup { + name: "mac-native", + directory: "mac-native", + targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"], + } +], + targets: &[ +CodexTarget { + target: "x86_64-pc-windows-msvc", + platform: "win32-x64", + directory: "win-x64", + executable: "bin/codex.exe", + files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"], + }, +CodexTarget { + target: "aarch64-apple-darwin", + platform: "darwin-arm64", + directory: "mac-native/darwin-arm64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + }, +CodexTarget { + target: "x86_64-apple-darwin", + platform: "darwin-x64", + directory: "mac-native/darwin-x64", + executable: "bin/codex", + files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"], + } +], +}; + +pub const PLUGINS: Plugins = Plugins { + source_directory: "plugins", + destination_directory: "resources/plugins", + manifest_file_name: "plugin.json", + target_contains_any: &["windows", "apple-darwin"], + subdirectories: &[ +Subdirectory { + path: "src", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "panels", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "skills", + target_contains: &[], + targets: &[], + features: &[], + }, +Subdirectory { + path: "native/payload", + target_contains: &["windows"], + targets: &[], + features: &[], + }, +Subdirectory { + path: "dotnet/publish/win-x64", + target_contains: &[], + targets: &["x86_64-pc-windows-msvc"], + features: &["unity-editor-execute"], + } +], + library_staging: &[ +LibraryStaging { + plugin: "agc-godot-editor", + source_subdirectory: "native/gdextension", + targets: &["x86_64-pc-windows-msvc"], + features: &["godot-editor-execute"], + layout: "godot-bundle", + } +], + skip_directory_names: &["target", "node_modules"], + skip_directory_name_prefixes: &["."], + skip_file_name_prefixes: &["."], + skip_file_name_fragments: &[".test."], +}; diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json new file mode 100644 index 000000000..21ac80bbe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -0,0 +1,110 @@ +{ + "schema": "agc-package-layout.v1", + "layoutVersion": 1, + "description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 、 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。", + "codex": { + "version": "0.155.1", + "cliVersionPrefix": "codex-cli ", + "manifestSchema": "genarrative-codex-sidecar.v2", + "resourceDirectory": "resources/codex", + "manifestFileName": "manifest.json", + "packageMetadataFileName": "codex-package.json", + "noticeFileName": "NOTICE.md", + "sourceRoots": ["app", "repo"], + "sourceRelativePaths": [ + "node_modules/@openai/codex-/vendor/", + "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/" + ], + "noticeSources": [ + { + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"], + "source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", + "preserve": false + }, + { + "targets": ["x86_64-pc-windows-msvc"], + "source": "resources/codex/win-x64/NOTICE.md", + "preserve": true + } + ], + "universalGroups": [ + { + "name": "mac-native", + "directory": "mac-native", + "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"] + } + ], + "targets": [ + { + "target": "x86_64-pc-windows-msvc", + "platform": "win32-x64", + "directory": "win-x64", + "executable": "bin/codex.exe", + "files": [ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json" + ] + }, + { + "target": "aarch64-apple-darwin", + "platform": "darwin-arm64", + "directory": "mac-native/darwin-arm64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + }, + { + "target": "x86_64-apple-darwin", + "platform": "darwin-x64", + "directory": "mac-native/darwin-x64", + "executable": "bin/codex", + "files": [ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json" + ] + } + ] + }, + "plugins": { + "sourceDirectory": "plugins", + "destinationDirectory": "resources/plugins", + "manifestFileName": "plugin.json", + "targetContainsAny": ["windows", "apple-darwin"], + "subdirectories": [ + { "path": "src" }, + { "path": "panels" }, + { "path": "skills" }, + { "path": "native/payload", "targetContains": ["windows"] }, + { + "path": "dotnet/publish/win-x64", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["unity-editor-execute"] + } + ], + "libraryStaging": [ + { + "plugin": "agc-godot-editor", + "sourceSubdirectory": "native/gdextension", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["godot-editor-execute"], + "layout": "godot-bundle" + } + ], + "skipDirectoryNames": ["target", "node_modules"], + "skipDirectoryNamePrefixes": ["."], + "skipFileNamePrefixes": ["."], + "skipFileNameFragments": [".test."] + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs new file mode 100644 index 000000000..28ae352b6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -0,0 +1,684 @@ +//! 随包资源布局与复制规则的单一声明读取层,以及随包产物的只读校验。 +//! +//! 人工声明只有一处:`build_support/package-layout.json`。Rust 侧使用由 +//! `scripts/check-package-layout.mjs` 从该声明生成的编译期常量 +//! (`package-layout.generated.rs`,门禁校验两者一致),Node 侧准备步骤直接读声明本身。 +//! 本模块**只读**:既不解析 JSON,也不写任何随包资源。 + +use std::collections::BTreeSet; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// 目标三元 → 平台包与组件白名单。 +#[derive(Debug)] +pub struct CodexTarget { + pub target: &'static str, + pub platform: &'static str, + pub directory: &'static str, + pub executable: &'static str, + pub files: &'static [&'static str], +} + +/// 一次构建共用的整目录(例如 macOS 的 `mac-native` 双架构)。 +#[derive(Debug)] +pub struct UniversalGroup { + pub name: &'static str, + pub directory: &'static str, + pub targets: &'static [&'static str], +} + +/// 第三方声明的来源;`preserve` 表示该文件受版本控制、只允许原地保留。 +#[derive(Debug)] +pub struct NoticeSource { + pub targets: &'static [&'static str], + pub source: &'static str, + pub preserve: bool, +} + +/// 插件工作区的随包子目录及其生效条件。 +#[derive(Debug)] +pub struct Subdirectory { + pub path: &'static str, + pub target_contains: &'static [&'static str], + pub targets: &'static [&'static str], + pub features: &'static [&'static str], +} + +/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。 +#[derive(Debug)] +pub struct LibraryStaging { + pub plugin: &'static str, + pub source_subdirectory: &'static str, + pub targets: &'static [&'static str], + pub features: &'static [&'static str], + pub layout: &'static str, +} + +#[derive(Debug)] +pub struct Codex { + pub resource_directory: &'static str, + pub manifest_file_name: &'static str, + pub package_metadata_file_name: &'static str, + pub notice_file_name: &'static str, + pub source_roots: &'static [&'static str], + pub source_relative_paths: &'static [&'static str], + pub notice_sources: &'static [NoticeSource], + pub universal_groups: &'static [UniversalGroup], + pub targets: &'static [CodexTarget], +} + +#[derive(Debug)] +pub struct Plugins { + pub source_directory: &'static str, + pub destination_directory: &'static str, + pub manifest_file_name: &'static str, + pub target_contains_any: &'static [&'static str], + pub subdirectories: &'static [Subdirectory], + pub library_staging: &'static [LibraryStaging], + pub skip_directory_names: &'static [&'static str], + pub skip_directory_name_prefixes: &'static [&'static str], + pub skip_file_name_prefixes: &'static [&'static str], + pub skip_file_name_fragments: &'static [&'static str], +} + +include!("package-layout.generated.rs"); + +const PLATFORM_PLACEHOLDER: &str = ""; +const TARGET_PLACEHOLDER: &str = ""; + +pub fn codex() -> &'static Codex { + &CODEX +} + +pub fn plugins() -> &'static Plugins { + &PLUGINS +} + +/// 声明里的目标布局;未声明的目标返回 `None`。 +pub fn codex_target(target: &str) -> Option<&'static CodexTarget> { + CODEX.targets.iter().find(|entry| entry.target == target) +} + +/// 目标所属的整目录分组(macOS 双架构共用 `mac-native`)。 +pub fn codex_universal_group(target: &str) -> Option<&'static UniversalGroup> { + CODEX + .universal_groups + .iter() + .find(|group| group.targets.contains(&target)) +} + +/// 目标对应的第三方声明来源。 +pub fn codex_notice_source(target: &str) -> Option<&'static NoticeSource> { + CODEX + .notice_sources + .iter() + .find(|entry| entry.targets.contains(&target)) +} + +/// 单次构建需要校验的平台目录:属于整目录分组时是该组全部三元,否则是自身;不支持的目标为空。 +pub fn staged_targets(target: &str) -> Vec<&'static str> { + if let Some(group) = codex_universal_group(target) { + return group.targets.to_vec(); + } + codex_target(target) + .map(|declared| vec![declared.target]) + .unwrap_or_default() +} + +/// 上游平台包的候选路径,顺序与声明一致(先 app 目录后仓库根,各自按声明顺序)。 +pub fn codex_source_candidates( + app_root: &Path, + repo_root: &Path, + target: &str, +) -> Result, String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let mut candidates = Vec::new(); + for root in CODEX.source_roots { + let base = match *root { + "app" => app_root, + "repo" => repo_root, + other => return Err(format!("声明中的 sourceRoots 取值无效:{other}")), + }; + for relative in CODEX.source_relative_paths { + let expanded = relative + .replace(PLATFORM_PLACEHOLDER, layout.platform) + .replace(TARGET_PLACEHOLDER, target); + candidates.push(base.join(expanded)); + } + } + Ok(candidates) +} + +/// 插件随包 staging 是否适用于该目标(与声明里的平台门槛一致)。 +pub fn plugin_staging_applies(target: &str) -> bool { + PLUGINS + .target_contains_any + .iter() + .any(|needle| target.contains(needle)) +} + +/// 子目录在当前目标与已启用 feature 下是否随包。 +pub fn subdirectory_enabled( + subdirectory: &Subdirectory, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (subdirectory.target_contains.is_empty() + || subdirectory + .target_contains + .iter() + .any(|needle| target.contains(needle))) + && (subdirectory.targets.is_empty() || subdirectory.targets.contains(&target)) + && subdirectory + .features + .iter() + .all(|name| feature_enabled(name)) +} + +/// 随包库归位在当前目标与已启用 feature 下是否生效。 +pub fn library_staging_enabled( + staging: &LibraryStaging, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> bool { + (staging.targets.is_empty() || staging.targets.contains(&target)) + && staging.features.iter().all(|name| feature_enabled(name)) +} + +/// Cargo build script 上下文里该 feature 是否启用(`CARGO_FEATURE_`)。 +pub fn cargo_feature_enabled(name: &str) -> bool { + let variable = format!("CARGO_FEATURE_{}", name.to_uppercase().replace('-', "_")); + std::env::var_os(variable).is_some() +} + +/// 目录是否被跳过(构建产物目录与隐藏目录)。 +pub fn skip_directory(name: &str) -> bool { + PLUGINS.skip_directory_names.contains(&name) + || PLUGINS + .skip_directory_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// 文件是否被跳过(测试文件与隐藏文件)。 +pub fn skip_file_name(name: &str) -> bool { + PLUGINS + .skip_file_name_prefixes + .iter() + .any(|prefix| name.starts_with(prefix)) + || PLUGINS + .skip_file_name_fragments + .iter() + .any(|fragment| name.contains(fragment)) +} + +/// 逐块 sha256;构建期校验与 staging 比对共用同一实现。 +pub fn sha256_file(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// 声明里的相对路径一律使用 `/`,落盘时转换为平台分隔符。 +pub fn declared_relative_path(relative: &str) -> PathBuf { + relative.split('/').collect() +} + +/// 只读校验一份已经落盘的 Codex 随包目录(本函数不写任何文件)。 +/// +/// 校验项:目录存在且非链接、清单存在且 schema/平台/版本一致、清单文件集合与组件白名单完全一致、 +/// 每个组件存在且摘要一致、第三方声明存在、可执行组件具备可执行位、目录内不存在白名单外的文件。 +pub fn validate_staged_codex_bundle(target_dir: &Path, target: &str) -> Result<(), String> { + let layout = + codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?; + let metadata = std::fs::symlink_metadata(target_dir) + .map_err(|error| format!("随包目录不可读 {}:{error}", target_dir.display()))?; + if !metadata.is_dir() { + return Err(format!("随包目录不是目录:{}", target_dir.display())); + } + if metadata.file_type().is_symlink() { + return Err(format!("随包目录不允许符号链接:{}", target_dir.display())); + } + + let manifest_path = target_dir.join(CODEX.manifest_file_name); + let manifest_raw = std::fs::read_to_string(&manifest_path) + .map_err(|error| format!("随包清单不可读 {}:{error}", manifest_path.display()))?; + let manifest: serde_json::Value = serde_json::from_str(&manifest_raw) + .map_err(|error| format!("随包清单不是合法 JSON {}:{error}", manifest_path.display()))?; + let schema = manifest["schemaVersion"].as_str().unwrap_or_default(); + if schema != CODEX_MANIFEST_SCHEMA { + return Err(format!( + "随包清单 schema 不受支持:{schema}(期望 {CODEX_MANIFEST_SCHEMA})" + )); + } + let platform = manifest["platform"].as_str().unwrap_or_default(); + if platform != layout.platform { + return Err(format!( + "随包清单平台与目标不一致:{platform}(目标 {target} 期望 {})", + layout.platform + )); + } + let version = manifest["version"].as_str().unwrap_or_default(); + if version != CODEX_CLI_VERSION { + return Err(format!( + "随包清单版本与声明不一致:{version}(期望 {CODEX_CLI_VERSION})" + )); + } + let hashes = manifest["files"] + .as_object() + .ok_or_else(|| format!("随包清单缺少 files 映射:{}", manifest_path.display()))?; + let declared = layout + .files + .iter() + .map(|relative| (*relative).to_string()) + .collect::>(); + let listed = hashes.keys().cloned().collect::>(); + if declared != listed { + let missing = declared.difference(&listed).cloned().collect::>(); + let unexpected = listed.difference(&declared).cloned().collect::>(); + return Err(format!( + "随包清单文件集合与组件白名单不一致(缺少 {missing:?},多出 {unexpected:?})" + )); + } + + for relative in layout.files { + let path = target_dir.join(declared_relative_path(relative)); + let file_metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("随包组件缺失 {relative}:{error}"))?; + if file_metadata.file_type().is_symlink() { + return Err(format!("随包组件不允许符号链接:{relative}")); + } + if !file_metadata.is_file() { + return Err(format!("随包组件不是普通文件:{relative}")); + } + if file_metadata.len() == 0 { + return Err(format!("随包组件为空:{relative}")); + } + let expected = hashes + .get(*relative) + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("随包清单摘要格式无效:{relative} = {expected}")); + } + let actual = + sha256_file(&path).map_err(|error| format!("读取随包组件失败 {relative}:{error}"))?; + if !actual.eq_ignore_ascii_case(expected) { + return Err(format!( + "随包组件摘要与清单不一致:{relative}(清单 {expected},实际 {actual})" + )); + } + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + if file_metadata.permissions().mode() & 0o111 == 0 { + return Err(format!("随包可执行组件缺少可执行位:{relative}")); + } + } + } + + let notice = target_dir.join(CODEX.notice_file_name); + if !notice.is_file() { + return Err(format!("随包第三方声明缺失:{}", notice.display())); + } + + let mut allowed = declared.clone(); + allowed.insert(CODEX.notice_file_name.to_string()); + allowed.insert(CODEX.manifest_file_name.to_string()); + let actual = collect_tree_files(target_dir)?; + let unexpected = actual.difference(&allowed).cloned().collect::>(); + if !unexpected.is_empty() { + return Err(format!( + "随包目录存在白名单外的文件:{unexpected:?}({})", + target_dir.display() + )); + } + Ok(()) +} + +/// 递归收集目录下的普通文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_tree_files(root: &Path) -> Result, String> { + let mut files = BTreeSet::new(); + let mut stack = vec![(root.to_path_buf(), String::new())]; + while let Some((directory, prefix)) = stack.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("随包目录不可读 {}:{error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("随包目录项类型不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if file_type.is_symlink() { + return Err(format!("随包资源不允许符号链接:{relative}")); + } + if file_type.is_dir() { + stack.push((entry.path(), relative)); + } else { + files.insert(relative); + } + } + } + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn declared_target() -> &'static CodexTarget { + codex_target("aarch64-apple-darwin").expect("mac layout") + } + + /// 在临时目录里生成一份合规的 Codex 随包目录。 + fn write_bundle(root: &Path, target: &str) -> serde_json::Value { + let layout = codex_target(target).expect("layout"); + let mut hashes = serde_json::Map::new(); + for relative in layout.files { + let path = root.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + fs::write(&path, format!("component {relative}")).expect("write component"); + #[cfg(unix)] + if *relative == layout.executable { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod"); + } + hashes.insert( + relative.to_string(), + serde_json::Value::String(sha256_file(&path).expect("hash")), + ); + } + fs::write(root.join(CODEX.notice_file_name), "notice").expect("write notice"); + let manifest = serde_json::json!({ + "schemaVersion": CODEX_MANIFEST_SCHEMA, + "platform": layout.platform, + "version": CODEX_CLI_VERSION, + "files": hashes, + }); + fs::write( + root.join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + manifest + } + + #[test] + fn valid_bundle_passes() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin").expect("valid bundle"); + } + + #[test] + fn rejects_manifest_schema_platform_and_version_drift() { + for (key, value) in [ + ("schemaVersion", serde_json::json!("other-sidecar.v9")), + ("platform", serde_json::json!("darwin-x64")), + ("version", serde_json::json!("codex-cli 0.0.0")), + ] { + let temp = tempfile::tempdir().expect("tempdir"); + let mut manifest = write_bundle(temp.path(), "aarch64-apple-darwin"); + manifest[key] = value; + fs::write( + temp.path().join(CODEX.manifest_file_name), + serde_json::to_string_pretty(&manifest).expect("serialize"), + ) + .expect("write manifest"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject"); + assert!(error.contains("清单"), "{key}: {error}"); + } + } + + #[test] + fn rejects_missing_component_and_digest_drift() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = declared_target().executable; + fs::remove_file(temp.path().join(declared_relative_path(executable))).expect("remove"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing component"); + assert!(error.contains(executable), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let path = temp.path().join("codex-package.json"); + fs::write(&path, "tampered").expect("tamper"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject digest drift"); + assert!(error.contains("摘要"), "{error}"); + } + + #[test] + fn rejects_undeclared_file_and_missing_notice() { + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::write(temp.path().join("stray.bin"), "stray").expect("write stray"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject stray file"); + assert!(error.contains("白名单外"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + fs::remove_file(temp.path().join(CODEX.notice_file_name)).expect("remove notice"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing notice"); + assert!(error.contains("第三方声明"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_component_and_missing_executable_bit() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::remove_file(&executable).expect("remove"); + symlink("codex-code-mode-host", &executable).expect("symlink"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + + let temp = tempfile::tempdir().expect("tempdir"); + write_bundle(temp.path(), "aarch64-apple-darwin"); + let executable = temp.path().join("bin/codex"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o644)).expect("chmod"); + let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin") + .expect_err("must reject missing executable bit"); + assert!(error.contains("可执行位"), "{error}"); + } + + #[test] + fn unsupported_target_is_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate_staged_codex_bundle(temp.path(), "x86_64-unknown-linux-gnu") + .expect_err("must reject unsupported target"); + assert!(error.contains("声明不含目标"), "{error}"); + } + + #[test] + fn declaration_pins_codex_layout() { + assert_eq!(DECLARATION_SCHEMA, "agc-package-layout.v1"); + assert_eq!(LAYOUT_VERSION, 1); + assert_eq!(CODEX_VERSION, "0.155.1"); + assert_eq!(CODEX_CLI_VERSION, "codex-cli 0.155.1"); + assert_eq!(CODEX.targets.len(), 3); + let windows = codex_target("x86_64-pc-windows-msvc").expect("windows layout"); + assert_eq!(windows.platform, "win32-x64"); + assert_eq!(windows.directory, "win-x64"); + assert_eq!(windows.executable, "bin/codex.exe"); + assert_eq!(windows.files.len(), 6); + assert_eq!(declared_target().files.len(), 5); + assert!(declared_target() + .files + .contains(&"codex-resources/zsh/bin/zsh")); + assert!(codex_target("universal-apple-darwin").is_none()); + assert!(codex_target("x86_64-unknown-linux-gnu").is_none()); + } + + #[test] + fn universal_group_covers_both_darwin_targets() { + let group = codex_universal_group("aarch64-apple-darwin").expect("darwin group"); + assert_eq!(group.directory, "mac-native"); + assert_eq!( + group.targets, + ["aarch64-apple-darwin", "x86_64-apple-darwin"] + ); + assert_eq!(staged_targets("aarch64-apple-darwin").len(), 2); + assert_eq!( + staged_targets("x86_64-pc-windows-msvc"), + ["x86_64-pc-windows-msvc"] + ); + assert!(staged_targets("x86_64-unknown-linux-gnu").is_empty()); + } + + #[test] + fn notice_sources_cover_every_declared_target() { + for target in ["x86_64-pc-windows-msvc", "aarch64-apple-darwin"] { + assert!( + codex_notice_source(target).is_some(), + "缺少声明来源:{target}" + ); + } + assert!( + codex_notice_source("x86_64-pc-windows-msvc") + .expect("windows notice") + .preserve + ); + assert!( + !codex_notice_source("x86_64-apple-darwin") + .expect("mac notice") + .preserve + ); + } + + #[test] + fn source_candidates_follow_declared_order() { + let rendered = codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "aarch64-apple-darwin", + ) + .expect("candidates") + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + assert_eq!( + rendered, + [ + "/app/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/app/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/repo/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + "/repo/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + ] + ); + assert!(codex_source_candidates( + Path::new("/app"), + Path::new("/repo"), + "x86_64-unknown-linux-gnu" + ) + .is_err()); + } + + #[test] + fn plugin_rules_match_declared_whitelist() { + assert_eq!(PLUGINS.subdirectories.len(), 5); + assert_eq!(PLUGINS.subdirectories[0].path, "src"); + let payload = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "native/payload") + .expect("native/payload"); + assert!(subdirectory_enabled( + payload, + "x86_64-pc-windows-msvc", + |_| true + )); + assert!(!subdirectory_enabled( + payload, + "aarch64-apple-darwin", + |_| true + )); + let unity = PLUGINS + .subdirectories + .iter() + .find(|entry| entry.path == "dotnet/publish/win-x64") + .expect("unity publish"); + assert!(subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |name| name == "unity-editor-execute" + )); + assert!(!subdirectory_enabled( + unity, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!subdirectory_enabled(unity, "aarch64-apple-darwin", |_| { + true + })); + assert!(plugin_staging_applies("x86_64-pc-windows-msvc")); + assert!(plugin_staging_applies("aarch64-apple-darwin")); + assert!(!plugin_staging_applies("x86_64-unknown-linux-gnu")); + assert_eq!(PLUGINS.source_directory, "plugins"); + assert_eq!(PLUGINS.destination_directory, "resources/plugins"); + assert_eq!(PLUGINS.manifest_file_name, "plugin.json"); + } + + #[test] + fn library_staging_rules_are_declared() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + assert_eq!(staging.plugin, "agc-godot-editor"); + assert_eq!(staging.source_subdirectory, "native/gdextension"); + assert!(library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute" + )); + assert!(!library_staging_enabled( + staging, + "x86_64-pc-windows-msvc", + |_| false + )); + assert!(!library_staging_enabled( + staging, + "aarch64-apple-darwin", + |_| true + )); + } + + #[test] + fn skip_rules_match_copy_semantics() { + assert!(skip_directory("target")); + assert!(skip_directory("node_modules")); + assert!(skip_directory(".git")); + assert!(!skip_directory("src")); + assert!(skip_file_name(".env")); + assert!(skip_file_name("runner.test.mjs")); + assert!(!skip_file_name("runner.mjs")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e0ceeb6da..a53d8da4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -4,6 +4,11 @@ #[path = "../build_support/godot_bundle.rs"] mod godot_bundle; +// 复用随包资源声明的既有单测(校验通过/拒绝用例),生产运行时只经 codex_bundle 使用布局。 +#[cfg(test)] +#[path = "../build_support/package_layout.rs"] +mod package_layout; + use std::collections::BTreeMap; use std::fs; use std::fs::{File, OpenOptions}; diff --git a/package.json b/package.json index 87d45e193..2c852e22f 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,10 @@ "agc:build": "npm --prefix apps/ai-game-creator-shell run build --", "agc:skill-pack:check": "npm --prefix apps/ai-game-creator-shell run skill-pack:check", "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", + "agc:bundled-resources:check": "npm --prefix apps/ai-game-creator-shell run bundled-resources:check", + "agc:bundled-resources:sync": "npm --prefix apps/ai-game-creator-shell run bundled-resources:sync", + "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare", + "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test", "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs", "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test", From d8b37e0da909068602ae27673d0b9b881138328e Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:36:03 +0800 Subject: [PATCH 05/26] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=E5=87=86=E5=A4=87=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=EF=BC=88Codex=20=E4=B8=8E=E6=8F=92=E4=BB=B6=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E7=BA=AF=E5=A4=8D=E5=88=B6=E8=B7=AF=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/prepare-bundled-resources.mjs:按声明解析目标与替换单位,用上游 package-lock 的 resolved/integrity 组成缓存 key,写临时目录后原子替换,命中缓存不重写任何文件,只替换本工具产物,失败即退出并给出可执行提示 - Codex 路径按声明复制组件、保留受版本控制的第三方声明、按现有字节格式生成 manifest.json;macOS 双架构整体 staging - 插件路径按声明的子目录白名单与跳过规则复制,顶层出现非本工具条目即失败 - 新增 scripts/prepare-bundled-resources.test.mjs:9 条用例覆盖 staging 结果、幂等(内容与时间戳)、上游缺失、目标不支持、目录被非本工具占用、dry-run 不落盘与白名单过滤 - .gitignore 忽略准备步骤产生的 staging 临时目录 --- .gitignore | 2 + .../scripts/prepare-bundled-resources.mjs | 870 ++++++++++++++++++ .../prepare-bundled-resources.test.mjs | 401 ++++++++ 3 files changed, 1273 insertions(+) create mode 100755 apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs create mode 100644 apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs diff --git a/.gitignore b/.gitignore index 5529773cd..6c7fc1d6c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,8 @@ temp*build*/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs new file mode 100755 index 000000000..7c44d30d4 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -0,0 +1,870 @@ +#!/usr/bin/env node +// AGC 随包资源准备步骤:在 `tauri dev` / `tauri build` 之前把随包资源一次性 staging 到位。 +// +// 合同见 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md §4.3: +// 只替换本工具产物、写入临时目录后原子替换、命中缓存不重写任何文件、失败即关闭并给出可执行提示。 +// 布局与组件白名单来自唯一声明 src-tauri/build_support/package-layout.json(Node 与 Rust 共用)。 +// +// 本里程碑(M1)覆盖两条纯复制路径:随包 Codex CLI 与插件工作区。 +// 编辑器分支产物(Unity/Godot/Cocos)仍由构建脚本生成,归位在 M3。 +// +// 用法(在 apps/ai-game-creator-shell 下): +// node scripts/prepare-bundled-resources.mjs [--target ] [--dry-run] + +import { createHash, randomBytes } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + createReadStream, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defaultEditorFeatures } from './cargo-features.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +/** 应用 src-tauri 目录(随包资源的落点)。 */ +export const SRC_TAURI_DIR = path.join(APP_ROOT, 'src-tauri'); +/** 唯一的随包资源声明文件。 */ +export const DECLARATION_PATH = path.join( + SRC_TAURI_DIR, + 'build_support/package-layout.json', +); +/** 缓存记录(放在 gitignored 的 target 下,丢失只多一次哈希)。 */ +export const RECORD_PATH = path.join( + SRC_TAURI_DIR, + 'target/agc-resource-staging.json', +); + +const HOST_TRIPLES = new Map([ + ['darwin:arm64', 'aarch64-apple-darwin'], + ['darwin:x64', 'x86_64-apple-darwin'], + ['win32:x64', 'x86_64-pc-windows-msvc'], +]); + +class PrepareError extends Error {} + +function fail(message) { + throw new PrepareError(message); +} + +export function resolveHostTarget( + platform = process.platform, + arch = process.arch, +) { + const triple = HOST_TRIPLES.get(`${platform}:${arch}`); + if (!triple) { + fail( + `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`, + ); + } + return triple; +} + +export function readDeclaration(declarationPath = DECLARATION_PATH) { + const declaration = JSON.parse(readFileSync(declarationPath, 'utf8')); + if (declaration.schema !== 'agc-package-layout.v1') { + fail(`不支持的随包资源声明 schema:${String(declaration.schema)}`); + } + return declaration; +} + +function codexLayout(declaration, target) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + if (!layout) { + fail(`随包资源声明不含目标 ${target} 的 Codex 布局`); + } + return layout; +} + +/// staging 的替换单位:属于整目录分组时替换整个分组目录(macOS 双架构共用),否则替换自身目录。 +export function stagingUnit(declaration, target) { + const layout = codexLayout(declaration, target); + const group = declaration.codex.universalGroups.find((entry) => + entry.targets.includes(target), + ); + const directory = group ? group.directory : layout.directory; + return { + directory, + targets: (group ? group.targets : [target]).map((member) => ({ + target: member, + layout: codexLayout(declaration, member), + })), + }; +} + +function sha256File(file) { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + createReadStream(file) + .on('data', (chunk) => hash.update(chunk)) + .on('end', () => resolve(hash.digest('hex'))) + .on('error', reject); + }); +} + +function sha256Text(text) { + return createHash('sha256').update(text).digest('hex'); +} + +function copyFilePreservingMode(source, destination) { + const info = statSync(source); + if (!info.isFile()) { + fail(`随包资源来源不是普通文件:${source}`); + } + mkdirSync(path.dirname(destination), { recursive: true }); + copyFileSync(source, destination); + chmodSync(destination, info.mode & 0o777); +} + +export function readRecord(recordPath = RECORD_PATH) { + if (!existsSync(recordPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(recordPath, 'utf8')); + } catch { + return {}; + } +} + +function writeRecord(record, recordPath) { + mkdirSync(path.dirname(recordPath), { recursive: true }); + writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`); +} + +/// 上游平台包目录:按声明顺序取第一个「声明白名单文件齐全」的候选。 +export function findCodexSource(declaration, target, roots) { + const layout = codexLayout(declaration, target); + const candidates = []; + for (const rootName of declaration.codex.sourceRoots) { + const root = roots[rootName]; + if (!root) { + fail(`未知的声明 sourceRoots 取值:${rootName}`); + } + for (const relative of declaration.codex.sourceRelativePaths) { + candidates.push( + path.join( + root, + relative + .replaceAll('', layout.platform) + .replaceAll('', target), + ), + ); + } + } + const source = candidates.find((candidate) => + layout.files.every((entry) => existsSync(path.join(candidate, entry))), + ); + if (!source) { + fail( + `内置 Codex CLI 上游包缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`, + ); + } + return source; +} + +/// 缓存 key 的锁定信息:上游 package-lock 的 resolved + integrity。 +export function readLockedUpstream( + declaration, + target, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), +) { + const layout = codexLayout(declaration, target); + const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8')); + const entry = + lockfile.packages?.[`node_modules/@openai/codex-${layout.platform}`]; + if (!entry?.resolved || !entry?.integrity) { + fail( + `package-lock.json 缺少 @openai/codex-${layout.platform} 的 resolved/integrity;请先在仓库根目录执行 npm ci`, + ); + } + return { resolved: entry.resolved, integrity: entry.integrity }; +} + +function serializeManifest(declaration, layout, hashes) { + const files = {}; + for (const relative of [...layout.files].sort()) { + files[relative] = hashes.get(relative); + } + return `${JSON.stringify( + { + files, + platform: layout.platform, + schemaVersion: declaration.codex.manifestSchema, + version: `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + }, + null, + 2, + )}\n`; +} + +function noticeSourceFor(declaration, target) { + const notice = declaration.codex.noticeSources.find((entry) => + entry.targets.includes(target), + ); + if (!notice) { + fail(`随包资源声明缺少目标 ${target} 的第三方声明来源`); + } + return notice; +} + +function unitPathOf(destinationRoot, declaration, unit) { + return path.join( + destinationRoot, + declaration.codex.resourceDirectory, + unit.directory, + ); +} + +function targetPathWithin(unitPath, declaration, unit, target) { + const layout = codexLayout(declaration, target); + const relative = path.relative(unit.directory, layout.directory); + return relative ? path.join(unitPath, relative) : unitPath; +} + +/// 该替换单位允许出现的顶层条目:整目录分组下是各架构子目录,单目标下是组件首段路径 + 第三方声明 + 清单。 +function allowedUnitEntries(declaration, unit) { + const allowed = new Set(); + for (const member of unit.targets) { + const relativeDirectory = path.relative( + unit.directory, + member.layout.directory, + ); + if (relativeDirectory) { + allowed.add(relativeDirectory.split(path.sep)[0]); + continue; + } + for (const relative of member.layout.files) { + allowed.add(relative.split('/')[0]); + } + allowed.add(declaration.codex.noticeFileName); + allowed.add(declaration.codex.manifestFileName); + } + return allowed; +} + +function assertOwnedUnit(unitPath, declaration, unit) { + if (!existsSync(unitPath)) { + return; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + const entryPath = path.join(unitPath, entry); + if (!allowed.has(entry)) { + fail( + `随包资源目录被非本工具内容占用:${entryPath};请人工确认后删除该目录再重试`, + ); + } + } +} + +/// 期望产物:每个目标的组件摘要与清单文本。 +async function desiredCodex(declaration, unit, roots) { + const desired = new Map(); + for (const member of unit.targets) { + const source = findCodexSource(declaration, member.target, roots); + const hashes = new Map(); + for (const relative of member.layout.files) { + hashes.set(relative, await sha256File(path.join(source, relative))); + } + desired.set(member.target, { + source, + files: new Map( + [...hashes].map(([relative, digest]) => [ + relative, + { sha256: digest, size: statSync(path.join(source, relative)).size }, + ]), + ), + manifest: serializeManifest(declaration, member.layout, hashes), + }); + } + return desired; +} + +async function unitMatchesExisting(unitPath, declaration, unit, desired) { + if (!existsSync(unitPath)) { + return false; + } + const allowed = allowedUnitEntries(declaration, unit); + for (const entry of readdirSync(unitPath)) { + if (!allowed.has(entry)) { + return false; + } + } + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if ( + !existsSync(manifestPath) || + readFileSync(manifestPath, 'utf8') !== expected.manifest + ) { + return false; + } + for (const [relative, file] of expected.files) { + const filePath = path.join(targetDir, relative); + if (!existsSync(filePath)) { + return false; + } + const info = statSync(filePath); + if ( + info.size !== file.size || + (await sha256File(filePath)) !== file.sha256 + ) { + return false; + } + if (relative === member.layout.executable && (info.mode & 0o111) === 0) { + return false; + } + } + } + return true; +} + +function recordEntryFor(unitPath, declaration, unit, desired) { + const manifests = {}; + const sizes = {}; + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + manifests[member.target] = sha256Text(expected.manifest); + sizes[member.layout.directory] = Object.fromEntries( + [...expected.files].map(([relative, file]) => [relative, file.size]), + ); + void targetDir; + } + return { manifests, sizes }; +} + +async function unitRecordMatches(unitPath, declaration, unit, recorded) { + if (!recorded) { + return false; + } + for (const member of unit.targets) { + const targetDir = targetPathWithin( + unitPath, + declaration, + unit, + member.target, + ); + const manifestPath = path.join( + targetDir, + declaration.codex.manifestFileName, + ); + if (!existsSync(manifestPath)) { + return false; + } + if ( + sha256Text(readFileSync(manifestPath, 'utf8')) !== + recorded.manifests?.[member.target] + ) { + return false; + } + const expectedSizes = recorded.sizes?.[member.layout.directory]; + if (!expectedSizes) { + return false; + } + for (const relative of member.layout.files) { + const file = path.join(targetDir, relative); + if ( + !existsSync(file) || + statSync(file).size !== expectedSizes[relative] + ) { + return false; + } + } + } + return true; +} + +function buildUnitInto( + stagingPath, + declaration, + unit, + desired, + unitPath, + destinationRoot, +) { + for (const member of unit.targets) { + const expected = desired.get(member.target); + const targetDir = targetPathWithin( + stagingPath, + declaration, + unit, + member.target, + ); + for (const relative of member.layout.files) { + copyFilePreservingMode( + path.join(expected.source, relative), + path.join(targetDir, relative), + ); + } + const notice = noticeSourceFor(declaration, member.target); + const noticeDestination = path.join( + targetDir, + declaration.codex.noticeFileName, + ); + const noticeSourcePath = notice.preserve + ? path.join( + targetPathWithin(unitPath, declaration, unit, member.target), + declaration.codex.noticeFileName, + ) + : path.join(destinationRoot, notice.source); + if (!existsSync(noticeSourcePath)) { + fail(`第三方声明来源缺失:${noticeSourcePath}`); + } + copyFilePreservingMode(noticeSourcePath, noticeDestination); + writeFileSync( + path.join(targetDir, declaration.codex.manifestFileName), + expected.manifest, + ); + } +} + +function stageAtomically(unitPath, builder) { + const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`; + rmSync(stagingPath, { recursive: true, force: true }); + mkdirSync(stagingPath, { recursive: true }); + try { + builder(stagingPath); + } catch (error) { + rmSync(stagingPath, { recursive: true, force: true }); + throw error; + } + rmSync(unitPath, { recursive: true, force: true }); + renameSync(stagingPath, unitPath); +} + +async function prepareCodex({ + declaration, + target, + destinationRoot, + roots, + lockfilePath, + record, + dryRun, +}) { + const unit = stagingUnit(declaration, target); + const label = `codex ${unit.targets.map((member) => member.target).join('+')}`; + const unitPath = unitPathOf(destinationRoot, declaration, unit); + const key = [ + `layoutVersion=${declaration.layoutVersion}`, + ...unit.targets.map( + (member) => + `${member.target}:${readLockedUpstream(declaration, member.target, lockfilePath).integrity}`, + ), + ].join('|'); + const recorded = record.codex?.[unit.directory]; + if ( + recorded?.key === key && + (await unitRecordMatches(unitPath, declaration, unit, recorded)) + ) { + return { summary: `${label} 命中缓存(未写入)`, record: undefined }; + } + + const desired = await desiredCodex(declaration, unit, roots); + const entry = { + key, + ...recordEntryFor(unitPath, declaration, unit, desired), + }; + if (await unitMatchesExisting(unitPath, declaration, unit, desired)) { + return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry }; + } + if (dryRun) { + return { + summary: `${label} 需要重新生成(dry-run 未写入)`, + record: undefined, + }; + } + assertOwnedUnit(unitPath, declaration, unit); + stageAtomically(unitPath, (staging) => + buildUnitInto( + staging, + declaration, + unit, + desired, + unitPath, + destinationRoot, + ), + ); + return { + summary: `${label} 重新生成(${unit.targets.length} 个架构,写入 ${declaration.codex.resourceDirectory}/${unit.directory})`, + record: entry, + }; +} + +export function pluginDirectories(declaration, repoRoot) { + const root = path.join(repoRoot, declaration.plugins.sourceDirectory); + if (!existsSync(root)) { + fail(`插件工作区缺失:${root}`); + } + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => ({ name: entry.name, root: path.join(root, entry.name) })) + .filter((plugin) => + existsSync(path.join(plugin.root, declaration.plugins.manifestFileName)), + ); +} + +function subdirectoryEnabled(subdirectory, target, features) { + const matchesContains = + !subdirectory.targetContains?.length || + subdirectory.targetContains.some((needle) => target.includes(needle)); + const matchesTarget = + !subdirectory.targets?.length || subdirectory.targets.includes(target); + const matchesFeatures = (subdirectory.features ?? []).every((name) => + features.has(name), + ); + return matchesContains && matchesTarget && matchesFeatures; +} + +function skipDirectory(declaration, name) { + return ( + declaration.plugins.skipDirectoryNames.includes(name) || + declaration.plugins.skipDirectoryNamePrefixes.some((prefix) => + name.startsWith(prefix), + ) + ); +} + +function skipFileName(declaration, name) { + return ( + declaration.plugins.skipFileNamePrefixes.some((prefix) => + name.startsWith(prefix), + ) || + declaration.plugins.skipFileNameFragments.some((fragment) => + name.includes(fragment), + ) + ); +} + +function walkFiles(declaration, root) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + if (!existsSync(directory)) { + continue; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`插件资源不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(declaration, entry.name)) { + stack.push([relative, entryPath]); + } + } else if (entry.isFile() && !skipFileName(declaration, entry.name)) { + files.push(relative); + } + } + } + return files.sort(); +} + +function copyPluginSubdirectory(declaration, source, destination) { + if (!existsSync(source)) { + return; + } + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source, { withFileTypes: true })) { + const entrySource = path.join(source, entry.name); + const entryDestination = path.join(destination, entry.name); + if (entry.isSymbolicLink()) { + fail(`插件资源不允许符号链接:${entrySource}`); + } + if (entry.isDirectory()) { + if (!skipDirectory(declaration, entry.name)) { + copyPluginSubdirectory(declaration, entrySource, entryDestination); + } + } else if (entry.isFile() && !skipFileName(declaration, entry.name)) { + copyFilePreservingMode(entrySource, entryDestination); + } + } +} + +function pluginSourceFingerprint(declaration, plugins, target, features) { + const lines = []; + for (const plugin of plugins) { + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + const root = path.join(plugin.root, subdirectory.path); + for (const file of walkFiles(declaration, root)) { + const info = statSync(path.join(root, file)); + lines.push( + `${plugin.name}/${subdirectory.path}/${file}:${info.size}:${Math.round(info.mtimeMs)}`, + ); + } + } + const manifest = path.join( + plugin.root, + declaration.plugins.manifestFileName, + ); + const info = statSync(manifest); + lines.push( + `${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`, + ); + } + return sha256Text(lines.join('\n')); +} + +async function pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, +) { + if (!existsSync(destination)) { + return false; + } + for (const plugin of plugins) { + const pluginDestination = path.join(destination, plugin.name); + if ( + !existsSync( + path.join(pluginDestination, declaration.plugins.manifestFileName), + ) + ) { + return false; + } + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + const sourceRoot = path.join(plugin.root, subdirectory.path); + for (const relative of walkFiles(declaration, sourceRoot)) { + const source = path.join(sourceRoot, relative); + const staged = path.join( + pluginDestination, + subdirectory.path, + relative, + ); + if (!existsSync(staged)) { + return false; + } + if (statSync(source).size !== statSync(staged).size) { + return false; + } + if ((await sha256File(source)) !== (await sha256File(staged))) { + return false; + } + } + } + } + return true; +} + +function assertOwnedPluginRoot(destination, plugins) { + if (!existsSync(destination)) { + return; + } + const known = new Set(plugins.map((plugin) => plugin.name)); + for (const entry of readdirSync(destination)) { + if (!known.has(entry)) { + fail( + `插件随包目录被非本工具内容占用:${path.join(destination, entry)};请人工确认后删除该目录再重试`, + ); + } + } +} + +async function preparePlugins({ + declaration, + target, + destinationRoot, + features, + plugins, + record, + dryRun, +}) { + const destination = path.join( + destinationRoot, + declaration.plugins.destinationDirectory, + ); + const fingerprint = pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ); + if ( + record.plugins?.fingerprint === fingerprint && + (await pluginTreeMatches( + declaration, + plugins, + target, + features, + destination, + )) + ) { + return { + summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + if (dryRun) { + return { + summary: `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + assertOwnedPluginRoot(destination, plugins); + stageAtomically(destination, (staging) => { + for (const plugin of plugins) { + const pluginDestination = path.join(staging, plugin.name); + copyFilePreservingMode( + path.join(plugin.root, declaration.plugins.manifestFileName), + path.join(pluginDestination, declaration.plugins.manifestFileName), + ); + for (const subdirectory of declaration.plugins.subdirectories) { + if (!subdirectoryEnabled(subdirectory, target, features)) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(pluginDestination, subdirectory.path), + ); + } + } + }); + return { + summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, + record: { fingerprint }, + }; +} + +/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 +export async function prepareBundledResources({ + target = resolveHostTarget(), + destinationRoot = SRC_TAURI_DIR, + declarationPath = DECLARATION_PATH, + features = new Set(defaultEditorFeatures(target)), + recordPath = RECORD_PATH, + lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), + dryRun = false, + repoRoot = REPO_ROOT, + appRoot = path.dirname(destinationRoot), +} = {}) { + const declaration = readDeclaration(declarationPath); + const record = readRecord(recordPath); + const summaries = []; + let changed = false; + const codex = await prepareCodex({ + declaration, + target, + destinationRoot, + roots: { app: appRoot, repo: repoRoot }, + lockfilePath, + record, + dryRun, + }); + summaries.push(codex.summary); + if (codex.record) { + record.codex = { + ...record.codex, + [stagingUnit(declaration, target).directory]: codex.record, + }; + changed = true; + } + if ( + declaration.plugins.targetContainsAny.some((needle) => + target.includes(needle), + ) + ) { + const plugins = await preparePlugins({ + declaration, + target, + destinationRoot, + features, + plugins: pluginDirectories(declaration, repoRoot), + record, + dryRun, + }); + summaries.push(plugins.summary); + if (plugins.record) { + record.plugins = plugins.record; + changed = true; + } + } else { + summaries.push(`plugins 跳过(目标 ${target} 不适用)`); + } + if (changed && !dryRun) { + writeRecord(record, recordPath); + } + return summaries; +} + +function parseArguments(argv) { + const args = { target: undefined, destinationRoot: undefined, dryRun: false }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === '--target') { + args.target = argv[index + 1]; + index += 1; + } else if (value === '--destination') { + args.destinationRoot = argv[index + 1]; + index += 1; + } else if (value === '--dry-run') { + args.dryRun = true; + } else { + fail(`未知参数:${value}`); + } + } + return args; +} + +async function main(argv) { + const args = parseArguments(argv); + const summaries = await prepareBundledResources({ + target: args.target ?? resolveHostTarget(), + destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, + dryRun: args.dryRun, + }); + for (const summary of summaries) { + console.log(`[agc-resources] ${summary}`); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(process.argv.slice(2)).catch((error) => { + if (error instanceof PrepareError) { + console.error(`[agc-resources] ${error.message}`); + process.exit(1); + } + throw error; + }); +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs new file mode 100644 index 000000000..4876a3836 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + DECLARATION_PATH, + findCodexSource, + pluginDirectories, + prepareBundledResources, + readDeclaration, + resolveHostTarget, + stagingUnit, +} from './prepare-bundled-resources.mjs'; + +const WINDOWS_TARGET = 'x86_64-pc-windows-msvc'; +const MAC_TARGET = 'aarch64-apple-darwin'; + +function sha256File(file) { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。 +function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-')); + const appRoot = path.join(root, 'app'); + const repoRoot = path.join(root, 'repo'); + const destinationRoot = path.join(appRoot, 'src-tauri'); + const declaration = readDeclaration(DECLARATION_PATH); + const lockfile = { packages: {} }; + + for (const target of targets) { + const layout = declaration.codex.targets.find( + (entry) => entry.target === target, + ); + assert.ok(layout, `声明缺少目标 ${target}`); + const vendor = path.join( + appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${target}`, + ); + for (const relative of layout.files) { + const file = path.join(vendor, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `component ${target} ${relative}\n`); + if (relative === layout.executable) { + fs.chmodSync(file, 0o755); + } + } + lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = { + resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`, + integrity: `sha512-${target}`, + }; + } + + fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), { + recursive: true, + }); + for (const entry of declaration.codex.noticeSources) { + if (entry.preserve) { + continue; + } + const file = path.join(destinationRoot, entry.source); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'mac codex notice\n'); + } + if (targets.includes(WINDOWS_TARGET)) { + const tracked = path.join( + destinationRoot, + 'resources/codex/win-x64/NOTICE.md', + ); + fs.mkdirSync(path.dirname(tracked), { recursive: true }); + fs.writeFileSync(tracked, 'windows codex notice\n'); + } + + if (plugins) { + const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor'); + fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true }); + fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, 'plugin.json'), + '{"name":"agc-demo-editor"}\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'src/entry.mjs'), + 'export const entry = 1;\n', + ); + fs.writeFileSync( + path.join(pluginRoot, 'panels/panel.html'), + '\n', + ); + fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n'); + fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n'); + fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); + fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + } + + const lockfilePath = path.join(root, 'package-lock.json'); + fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2)); + return { + root, + appRoot, + repoRoot, + destinationRoot, + lockfilePath, + recordPath: path.join(root, 'record.json'), + declaration, + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} + +function snapshot(directory) { + const entries = []; + const stack = [['', directory]]; + while (stack.length > 0) { + const [prefix, current] = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push([relative, full]); + } else { + const info = fs.statSync(full); + entries.push({ + relative, + size: info.size, + mtimeMs: info.mtimeMs, + mode: info.mode & 0o777, + sha256: sha256File(full), + }); + } + } + } + return entries.sort((left, right) => + left.relative.localeCompare(right.relative), + ); +} + +function prepare(fixture, overrides = {}) { + return prepareBundledResources({ + target: WINDOWS_TARGET, + destinationRoot: fixture.destinationRoot, + declarationPath: DECLARATION_PATH, + recordPath: fixture.recordPath, + lockfilePath: fixture.lockfilePath, + repoRoot: fixture.repoRoot, + appRoot: fixture.appRoot, + ...overrides, + }); +} + +test('stages declared codex components with manifest and preserved notice', async () => { + const fixture = buildFixture(); + try { + const summaries = await prepare(fixture); + assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/); + + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + layout.directory, + ); + for (const relative of layout.files) { + assert.ok( + fs.existsSync(path.join(unit, relative)), + `缺少组件 ${relative}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'), + 'windows codex notice\n', + '受版本控制的第三方声明必须原地保留', + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'), + ); + assert.deepEqual(Object.keys(manifest), [ + 'files', + 'platform', + 'schemaVersion', + 'version', + ]); + assert.equal(manifest.platform, layout.platform); + assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema); + assert.equal( + manifest.version, + `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`, + ); + assert.deepEqual( + Object.keys(manifest.files).sort(), + [...layout.files].sort(), + '清单文件集合必须等于组件白名单', + ); + for (const relative of layout.files) { + assert.equal( + manifest.files[relative], + sha256File(path.join(unit, relative)), + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('second run is a no-op: identical content and timestamps', async () => { + const fixture = buildFixture(); + try { + await prepare(fixture); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex', + 'win-x64', + ); + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + const before = { codex: snapshot(unit), plugins: snapshot(plugins) }; + const summaries = await prepare(fixture); + assert.match(summaries[0], /命中缓存/); + assert.match(summaries[1], /命中缓存/); + assert.deepEqual( + snapshot(unit), + before.codex, + 'codex 产物内容与时间戳必须不变', + ); + assert.deepEqual( + snapshot(plugins), + before.plugins, + '插件产物内容与时间戳必须不变', + ); + } finally { + fixture.cleanup(); + } +}); + +test('stages the macOS universal group with both architectures', async () => { + const fixture = buildFixture({ + targets: [MAC_TARGET, 'x86_64-apple-darwin'], + }); + try { + const summaries = await prepare(fixture, { target: MAC_TARGET }); + assert.match(summaries[0], /mac-native/); + const unit = path.join( + fixture.destinationRoot, + 'resources/codex/mac-native', + ); + for (const directory of ['darwin-arm64', 'darwin-x64']) { + for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) { + assert.ok( + fs.existsSync(path.join(unit, directory, file)), + `缺少 ${directory}/${file}`, + ); + } + assert.equal( + fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'), + 'mac codex notice\n', + ); + } + assert.deepEqual(fs.readdirSync(unit).sort(), [ + 'darwin-arm64', + 'darwin-x64', + ]); + } finally { + fixture.cleanup(); + } +}); + +test('copies only whitelisted plugin subdirectories', async () => { + const fixture = buildFixture(); + try { + await prepare(fixture); + const staged = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-demo-editor', + ); + assert.ok(fs.existsSync(path.join(staged, 'plugin.json'))); + assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs'))); + assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html'))); + assert.ok( + !fs.existsSync(path.join(staged, 'panels/panel.test.mjs')), + '测试文件不随包', + ); + assert.ok( + !fs.existsSync(path.join(staged, 'target')), + '构建产物目录不随包', + ); + assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包'); + assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包'); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the upstream package is missing', async () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.appRoot, 'node_modules'), { + recursive: true, + force: true, + }); + await assert.rejects(prepare(fixture), /npm ci/); + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '失败时不得留下半成品清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed for unsupported targets', async () => { + const fixture = buildFixture(); + try { + await assert.rejects( + prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), + /声明不含目标/, + ); + assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when the destination is owned by something else', async () => { + const fixture = buildFixture(); + try { + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); + await assert.rejects(prepare(fixture), /被非本工具内容占用/); + + const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); + fs.rmSync(path.join(unit, 'foreign.bin'), { force: true }); + fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), { + recursive: true, + }); + await assert.rejects(prepare(fixture), /插件随包目录被非本工具内容占用/); + } finally { + fixture.cleanup(); + } +}); + +test('dry run writes nothing', async () => { + const fixture = buildFixture(); + try { + const summaries = await prepare(fixture, { dryRun: true }); + assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); + const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); + assert.deepEqual( + fs.readdirSync(unit), + ['NOTICE.md'], + 'dry-run 不得写入任何组件或清单', + ); + assert.ok( + !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')), + ); + assert.ok(!fs.existsSync(fixture.recordPath)); + } finally { + fixture.cleanup(); + } +}); + +test('declaration drives source lookup and staging units', () => { + const declaration = readDeclaration(DECLARATION_PATH); + const windows = stagingUnit(declaration, WINDOWS_TARGET); + assert.equal(windows.directory, 'win-x64'); + assert.deepEqual( + windows.targets.map((member) => member.target), + [WINDOWS_TARGET], + ); + const mac = stagingUnit(declaration, MAC_TARGET); + assert.equal(mac.directory, 'mac-native'); + assert.deepEqual( + mac.targets.map((member) => member.target), + ['aarch64-apple-darwin', 'x86_64-apple-darwin'], + ); + + const fixture = buildFixture(); + try { + const source = findCodexSource(declaration, WINDOWS_TARGET, { + app: fixture.appRoot, + repo: fixture.repoRoot, + }); + assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1); + } finally { + fixture.cleanup(); + } +}); From 05d455d4ef5353d97660127494fb4aa6b3c1c818 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:36:04 +0800 Subject: [PATCH 06/26] =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E5=A3=B0=E6=98=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=B9=B6=E5=8F=AA=E8=AF=BB=E6=A0=A1=E9=AA=8C=E9=9A=8F=E5=8C=85?= =?UTF-8?q?=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 平台布局、插件随包子目录与跳过规则、Codex 上游候选路径与第三方声明来源全部改由声明提供,删除脚本内的字面量 - 新增只读校验:Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接 - AGC_BUILD_TARGET 的 rustc-env 移到无条件路径(保留职责),新增 AGC_SKIP_RESOURCE_STAGING=1 以在既有产物上只跑校验 - 删除脚本内与 package_layout 重复的 sha256 实现,复用同一份 --- apps/ai-game-creator-shell/src-tauri/build.rs | 251 ++++++++++++------ 1 file changed, 167 insertions(+), 84 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index cd69f8180..52a397bce 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -15,35 +15,20 @@ use std::env; use std::fs; use std::path::PathBuf; -use std::io::{BufReader, Read}; - -fn sha256_file(path: &std::path::Path) -> Result { - let file = fs::File::open(path)?; - let mut reader = BufReader::new(file); - let mut hasher = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let read = reader.read(&mut buffer)?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - } - Ok(format!("{:x}", hasher.finalize())) -} +use codex_bundle::package_layout; fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); - println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); - if target.contains("apple-darwin") { + let resource_directory = manifest_dir.join(package_layout::codex().resource_directory); + if let Some(group) = package_layout::codex_universal_group(&target) { // Tauri 的 universal 两次 Cargo 编译共用 resource staging, // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = manifest_dir.join("resources/codex/mac-native"); + let staging = resource_directory.join(group.directory); if staging.exists() { fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); } - for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] { - stage_codex_target(manifest_dir, target); + for member in group.targets { + stage_codex_target(manifest_dir, member); } } else { stage_codex_target(manifest_dir, &target); @@ -66,18 +51,9 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { .parent() .and_then(|apps_dir| apps_dir.parent()) .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let package = format!("codex-{}", layout.platform); - let source_candidates = [app_root, repo_root] - .into_iter() - .flat_map(|root| { - [ - root.join(format!("node_modules/@openai/{package}/vendor/{target}")), - root.join(format!( - "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" - )), - ] - }) - .collect::>(); + let source_candidates = + package_layout::codex_source_candidates(app_root, repo_root, target) + .unwrap_or_else(|error| panic!("{error}")); let source = source_candidates .iter() .find(|path| { @@ -98,18 +74,23 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { ) }); let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), + &fs::read(source.join(package_layout::codex().package_metadata_file_name)) + .expect("读取 Codex 原生包元数据失败"), ) .expect("Codex 原生包元数据无效"); codex_package_metadata::validate_package_metadata(&metadata, target, layout) .unwrap_or_else(|error| panic!("{error}")); - let target_dir = manifest_dir.join("resources/codex").join(layout.directory); - let notice = target_dir.join("NOTICE.md"); - if target.contains("apple-darwin") { - let source_notice = - manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md"); - stage_plugin_file(&source_notice, ¬ice); - println!("cargo:rerun-if-changed={}", source_notice.display()); + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + let notice = target_dir.join(package_layout::codex().notice_file_name); + if let Some(notice_source) = package_layout::codex_notice_source(target) { + if !notice_source.preserve { + // 受版本控制的第三方声明由此处复制;`preserve` 的声明文件本身已在随包目录内。 + let source_notice = manifest_dir.join(notice_source.source); + stage_plugin_file(&source_notice, ¬ice); + println!("cargo:rerun-if-changed={}", source_notice.display()); + } } if !notice.is_file() { panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); @@ -122,9 +103,10 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { if let Some(parent) = target_path.parent() { fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败"); } - let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); + let source_sha256 = + package_layout::sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); let target_matches_source = target_path.is_file() - && sha256_file(&target_path) + && package_layout::sha256_file(&target_path) .map(|target_sha256| target_sha256 == source_sha256) .unwrap_or(false); let source_permissions = fs::metadata(&source_path) @@ -156,7 +138,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { "version": codex_bundle::CLI_VERSION, "files": file_hashes, }); - let manifest_path = target_dir.join("manifest.json"); + let manifest_path = target_dir.join(package_layout::codex().manifest_file_name); let manifest_payload = format!( "{}\n", serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") @@ -217,16 +199,113 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu } } +/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。 +fn validate_staged_resources(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + for staged_target in package_layout::staged_targets(&target) { + let Some(layout) = codex_bundle::for_target(staged_target) else { + continue; + }; + let target_dir = manifest_dir + .join(package_layout::codex().resource_directory) + .join(layout.directory); + package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else( + |error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"), + ); + } + validate_staged_plugin_workspace(manifest_dir, &target); +} + +/// 只读校验插件随包工作区:插件清单与声明的必需子目录齐备、整树无符号链接、无越界路径。 +/// +/// 插件产物的逐文件摘要校验在准备步骤接管写入后启用——在那之前构建脚本仍会整体重建 +/// `resources/plugins`,任何写在树内的准备步骤清单都会被清掉。 +fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { + if !package_layout::plugin_staging_applies(target) { + return; + } + let declared = package_layout::plugins(); + let destination_root = manifest_dir.join(declared.destination_directory); + if !destination_root.is_dir() { + panic!("插件随包资源目录缺失:{}", destination_root.display()); + } + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + if let Ok(entries) = std::fs::read_dir(repo_root.join(declared.source_directory)) { + for entry in entries.flatten() { + let plugin_root = entry.path(); + if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { + continue; + } + let name = entry.file_name(); + let staged = destination_root.join(&name); + if !staged.join(declared.manifest_file_name).is_file() { + panic!( + "随包插件缺少清单:{}", + staged.join(declared.manifest_file_name).display() + ); + } + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_enabled( + subdirectory, + target, + package_layout::cargo_feature_enabled, + ) { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + if plugin_root.join(&relative).is_dir() && !staged.join(&relative).is_dir() { + panic!( + "随包插件缺少必需目录:{}(插件 {})", + staged.join(&relative).display(), + name.to_string_lossy() + ); + } + } + for staging in declared.library_staging { + if name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + if !staged.join(&relative).is_dir() { + panic!("随包库目录缺失:{}", staged.join(&relative).display()); + } + } + } + } + package_layout::collect_tree_files(&destination_root) + .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); +} + fn main() { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); - stage_bundled_codex_cli(&manifest_dir); - prepare_unity_editor_helper(&manifest_dir); - prepare_godot_editor_extension(&manifest_dir); - stage_plugin_workspace(&manifest_dir); - stage_cocos_editor_payload(&manifest_dir); + // 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。 + println!( + "cargo:rustc-env=AGC_BUILD_TARGET={}", + env::var("TARGET").expect("Cargo TARGET") + ); + // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), + // 用于在既有产物上单独验证校验路径。 + if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { + stage_bundled_codex_cli(&manifest_dir); + prepare_unity_editor_helper(&manifest_dir); + prepare_godot_editor_extension(&manifest_dir); + stage_plugin_workspace(&manifest_dir); + stage_cocos_editor_payload(&manifest_dir); + } + validate_staged_resources(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); validate_seed_task_catalog(&compiled); @@ -435,17 +514,18 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { /// Cargo target 目录或 node_modules。 fn stage_plugin_workspace(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); - if !target.contains("windows") && !target.contains("apple-darwin") { + if !package_layout::plugin_staging_applies(&target) { return; } + let declared = package_layout::plugins(); let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) .and_then(|apps_dir| apps_dir.parent()) .expect("AGC 应用必须位于仓库 apps 目录下") .to_path_buf(); - let workspace = repo_root.join("plugins"); - let destination_root = manifest_dir.join("resources/plugins"); + let workspace = repo_root.join(declared.source_directory); + let destination_root = manifest_dir.join(declared.destination_directory); // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 if destination_root.exists() { std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); @@ -464,39 +544,47 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .is_symlink(), "插件工作区不允许符号链接" ); - if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { + if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { continue; } let name = entry.file_name(); let destination = destination_root.join(&name); copy_plugin_file( - &plugin_root.join("plugin.json"), - &destination.join("plugin.json"), + &plugin_root.join(declared.manifest_file_name), + &destination.join(declared.manifest_file_name), ); - for relative in [ - std::path::PathBuf::from("src"), - std::path::PathBuf::from("panels"), - std::path::PathBuf::from("skills"), - std::path::PathBuf::from("native/payload"), - std::path::PathBuf::from("dotnet/publish/win-x64"), - ] { - if (relative == std::path::Path::new("native/payload") && !target.contains("windows")) - || (relative == std::path::Path::new("dotnet/publish/win-x64") - && (target != "x86_64-pc-windows-msvc" - || env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none())) + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_enabled( + subdirectory, + &target, + package_layout::cargo_feature_enabled, + ) { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); + } + for staging in declared.library_staging { + if name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + &target, + package_layout::cargo_feature_enabled, + ) { continue; } - copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); - } - if name == "agc-godot-editor" { - godot_bundle::stage( - &plugin_root.join("native/gdextension"), - &destination.join("native/gdextension"), - &target, - env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(), - ) - .unwrap_or_else(|error| panic!("{error}")); + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + match staging.layout { + "godot-bundle" => godot_bundle::stage( + &plugin_root.join(&relative), + &destination.join(&relative), + &target, + true, + ) + .unwrap_or_else(|error| panic!("{error}")), + other => panic!("未实现的随包库 staging 布局:{other}"), + } } println!("cargo:rerun-if-changed={}", plugin_root.display()); } @@ -531,20 +619,15 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { ); if path.is_dir() { let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { + if package_layout::skip_directory(&name.to_string_lossy()) { continue; } std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); copy_plugin_tree(&path, &target); } else { - // 测试文件不随包分发。 + // 测试文件与隐藏文件不随包分发。 let name = entry.file_name(); - let name = name.to_string_lossy(); - if name.contains(".test.") { - continue; - } - if name.starts_with('.') { + if package_layout::skip_file_name(&name.to_string_lossy()) { continue; } stage_plugin_file(&path, &target); From a4b103a1b09d7c009f201ce17357f01906e5e773 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:36:09 +0800 Subject: [PATCH 07/26] =?UTF-8?q?=E8=A1=A5=E5=85=85=20AGC=20=E9=9A=8F?= =?UTF-8?q?=E5=8C=85=E8=B5=84=E6=BA=90=E5=BD=92=E4=BD=8D=E7=9A=84=20M1=20?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技术方案新增 §4.8 单一声明与实现形态(混合),§8 更新 M1 交付与停止条件,§9 未决问题 1 收口为已定 - M1 里程碑规范记录已定决策与校验收口边界;实施计划补充已定决策与实际验证命令 - M2 里程碑规范的依赖与前置条件补记 M1 已交付的准备步骤与声明门禁 - 开发运维文档补充声明、生成器、门禁、准备步骤与只读校验的用法;决策日志与排障经验同步本次口径 --- ...计划】AGC随包资源改由校验器读入-2026-09-26.md | 22 ++++++++++--------- ...¨‹碑】AGC随包资源改由校验器读入-2026-09-26.md | 9 +++++++- ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 2 ++ .../shared-memory/decision-log.md | 10 +++++++++ docs/project-memory/shared-memory/pitfalls.md | 6 +++++ ...术方案】AGC随包资源staging归位-2026-09-26.md | 18 +++++++++++++-- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 ++ 7 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md index f609c1c72..8d664dba5 100644 --- a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -34,12 +34,14 @@ ## 验证命令 -1. 布局与白名单唯一性:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features codex_bundle` -2. 校验路径用例:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features <新增校验用例过滤名>` -3. 幂等:连续两次运行准备工具,比对目标目录内容与时间戳快照(内容与 mtime 均不变) -4. 失败关闭:对上游缺失、摘要篡改、非本工具目录、非目标平台四种场景各跑一次,记录错误输出 -5. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线) -6. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试 +1. 声明唯一性与门禁:`npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链),不一致时用 `npm run agc:bundled-resources:sync` 重新生成。 +2. 准备工具用例(含幂等与失败关闭):`npm run agc:bundled-resources:test`。 +3. 校验路径独立运行(跳过写入分支):`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 +4. Rust 用例:`cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml package_layout` 与 `cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml codex_bundle`。 +5. 幂等(真实工作区):连续两次 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`,第二次必须全部「命中缓存」,且两次之后的目录快照(相对路径、大小、mtime、sha256)完全一致。 +6. 并存一致:准备步骤产物与构建脚本产物逐文件比对(相对路径、大小、sha256)一致。 +7. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线)。 +8. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试。 ## 风险与回滚点 @@ -52,11 +54,11 @@ 回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。 -## 待确认决策 +## 已定决策 -准备工具的实现形态(主规范未决问题 1)建议为**混合**: +准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8): - 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); -- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具调用或由校验路径承担。 +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。 -理由:避免出现第二份组件白名单;同时不必重写 registry 下载、`integrity` 与 tar 安全校验。若评审倾向单一语言实现,需接受「另写一份白名单」或「把 Node 侧下载能力用 Rust 重写」二者之一。 +理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md index 57c6d8e2a..62bb4160e 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -3,10 +3,17 @@ | 字段 | 值 | | ----------- | ----------------------------------------------------------- | | Version | 1.0 | -| Status | proposed | +| Status | in-progress(2026-09-27 起实施 M1) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | +## 已定决策(2026-09-27) + +- **实现形态:混合**——生成归 Node(`scripts/prepare-bundled-resources.mjs`),声明与校验归 Rust。依据与对比见主规范 §4.8。 +- **单一声明**:`build_support/package-layout.json` 是唯一人工声明;Rust 侧使用由 `scripts/check-package-layout.mjs` 生成的编译期常量(`package-layout.generated.rs`),门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链;运行期 `codex_bundle.rs` 的公开接口与取值不变。 +- **校验收口边界**:本里程碑对随包 Codex 目录做全量校验(清单 schema/平台/版本、文件集合、逐文件摘要、第三方声明、可执行位、白名单外文件);插件随包目录只校验必需组件与符号链接。插件产物的逐文件摘要校验在 M2 由准备步骤写入树内清单后启用——M1 期间构建脚本仍整体重建 `resources/plugins`,树内清单会被清掉。 +- **独立验证入口**:`AGC_SKIP_RESOURCE_STAGING=1` 让构建脚本只跑只读校验、跳过写入分支。 + ## 目标 构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index 1f70aa288..72d4a340b 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -28,6 +28,7 @@ ## 依赖与前置条件 - 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。 +- M1 交付的准备步骤与声明门禁已在位:`apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`(Codex 与插件两条纯复制路径,写临时目录后原子替换,命中缓存不重写)与 `npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链)。本里程碑需要让准备步骤改为在 `resources/plugins` 内写入自己的清单,并停止构建脚本对该目录的整体重建,插件产物的逐文件摘要校验才能启用。 - 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。 - Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。 @@ -44,3 +45,4 @@ - 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。 - 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。 - 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。 +1 \ No newline at end of file diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 28d8e2a64..43d8879a5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9578,3 +9578,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。 - 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。 - 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。 + +## 2026-09-27 AGC 随包资源改为「单一声明 + 准备步骤生成 + 构建期只读校验」 + +- 背景:随包资源(内置 Codex CLI、插件工作区)由 `build.rs` 在构建期写入 `src-tauri/resources/**`,而这些路径同时被 tauri 配置的 `bundle.resources` 登记成构建输入,cargo 因此永远判 stale:Windows/macOS 每次构建重编主 crate(41–87 秒),macOS dev 反复 `Rebuilding application`、客户端起不来(issue #519)。三轮症状层修复(内容比对、权限跳过、`.taurignore`)都只减少写入次数,没有改变「构建期写被登记文件」这一结构。 +- 决策(形态:混合):准备步骤用 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换),布局与摘要校验留在 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs`),运行期模块公开接口与取值不变。 +- 决策(单一声明):唯一人工声明是 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`(Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛)。Node 直接读该 JSON;Rust 读由 `scripts/check-package-layout.mjs` 生成的 `package-layout.generated.rs` 编译期常量(不解析 JSON、不引入生命周期妥协)。门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,同时校验声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源,且 `codex.version` 与应用锁定的 `@openai/codex` 一致。 +- 决策(校验与独立入口):`build.rs` 新增只读校验——Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接。`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验,用于在既有产物上单独验证校验路径。插件产物的逐文件摘要校验留到 M2(届时准备步骤在树内写清单,不再被构建脚本整体重建覆盖)。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。 +- 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。 +- 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 712b30ac6..a7e6b7763 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,6 +12,12 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 +## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 + +- **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 +- **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。 +- **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。 + ## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析 - **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 0ec2225b0..72f9f4c53 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -144,6 +144,20 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 2. **打包侧不变**:三份 tauri 配置的 `resources` 映射与 `check-config.mjs:1356-1424` 的逐字断言保持不变;`check-macos-bundle.mjs` 对包内 `coding-agent/mac-native`、`game-runtime/node`、`plugins/agc-cocos-editor` 的存在性、架构与 sha256 断言继续作为发布后门禁。 3. **fail closed 已有兜底**:`tauri-build` 在资源缺失时以 `ResourcePathNotFound` 直接失败;校验器应比它更早、更明确地报错。 +### 4.8 单一声明与实现形态(M1 定案) + +准备步骤与构建期校验共用一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。 + +| 侧 | 读取方式 | 用途 | +| --- | --- | --- | +| Node 准备步骤(`scripts/prepare-bundled-resources.mjs`) | 直接读声明 JSON | 组件白名单、Codex 上游候选路径、插件随包子目录与跳过规则、平台与 feature 门槛、缓存 key 组成、manifest 序列化 | +| Rust 构建期校验与运行期布局 | 读声明生成的 `build_support/package-layout.generated.rs`(编译期常量) | 只读校验既有产物、运行期定位随包组件(`codex_bundle.rs` 接口不变) | +| 声明门禁 | `scripts/check-package-layout.mjs`(`npm run agc:bundled-resources:check`,已进 `agc:typecheck` 链) | 校验生成物与声明一致,并检查声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源、`codex.version` 与应用锁定的 `@openai/codex` 一致 | + +形态选择**混合**:生成归 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换范式),声明与校验归 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs` 的布局与摘要校验,运行期模块不改公开接口)。理由:Rust 侧没有下载与 lockfile 解析能力(`[build-dependencies]` 无 HTTP 客户端),Node 侧没有 staging 能力;任选单一语言都要迁移另一侧既有资产。Rust 侧刻意不解析 JSON:声明经生成器变成编译期常量,避免运行期解析与生命周期妥协,也让 `&'static` 布局表与现有调用点保持不变。 + +准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 + ## 5. 兼容与迁移 1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 @@ -181,7 +195,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 | 里程碑 | 交付 | 停止条件 | |---|---|---| -| M1 校验器化 | `build.rs` 增加只读校验路径、准备步骤脚本骨架(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿,且不改变现有构建行为 | +| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | | M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | macOS 与 Windows 均达到 §6 的前三行判据 | | M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `prune_staging`、`STAGED_*`、`.taurignore` staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿 | @@ -189,6 +203,6 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 ## 9. 未决问题 -1. 准备步骤用 Rust bin(可复用 `codex_bundle.rs` 的布局表与校验)还是 Node 脚本(与 `stage-node-runtime.mjs` 同构)?倾向前者以复用布局表与 sha256 校验,避免第二份白名单。 +1. ~~准备步骤用 Rust bin 还是 Node 脚本?~~ **已定(2026-09-27):混合**——生成归 Node、声明与校验归 Rust,布局与白名单收敛为单一声明文件。机制、门禁与验证入口见 §4.8。 2. unity/godot 的外部工具链步骤是否值得搬出 build script(它们本身是构建动作,搬出后需要显式前置顺序)——需在 Windows 上确认收益与风险。 3. `resources/**` 是否需要继续保留在 crate 内(`bundle.resources` 相对路径解析要求),还是改用生成式配置指向 `target/` 下的 staging:前者改动小、后者更彻底,需与 Tauri 的资源解析规则一起评估。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0d39c0ee9..ca2b87150 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -86,6 +86,8 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- `npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 +AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验。 + ### 本地 Rust 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 From f6dad1950f24207deb8cb05743527fddbe79d055 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:56:44 +0800 Subject: [PATCH 08/26] =?UTF-8?q?AGC=20=E9=9A=8F=E5=8C=85=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=87=86=E5=A4=87=E6=AD=A5=E9=AA=A4=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=20dev=20=E4=B8=8E=E5=8F=91=E5=B8=83=E5=85=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC=E9=80=80=E5=87=BA=E5=86=99?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start-tauri-dev.mjs 在前端与配套后端就绪后、spawn Tauri CLI 之前调用准备步骤,并保留依赖注入供入口测试断言顺序 - build-release.mjs 的 runTauriBuild 与既有 stageRuntime 并列调用 stageBundledResources,tauri build --no-bundle 仍不强制 staging - 声明新增 origin 字段:source 由准备步骤写,build 由构建脚本在产物生成后写;构建脚本删除 codex 与插件白名单的写入分支及 stage_plugin_file/copy_plugin_tree/copy_plugin_file,改为 stage_build_generated_plugin_payloads - 插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接),实现移入 build_support/package_layout.rs 复用单测 - 上游原生包元数据改由准备步骤按声明校验,build_support/codex_package_metadata.rs 因失去调用方删除 - 准备步骤改为同步实现,dev 与发布入口可直接调用而无需子进程 - 测试:build-release 39 passed(新增 stage、bundled、build 顺序与 no-bundle 不 staging)、dev 入口 12 passed(新增准备步骤先于 CLI 启动)、准备步骤 9 passed、package_layout 36 passed - 文档:技术方案 §4.9 记录构建期写入边界,M1 里程碑标记完成,运维文档补入口接线与 resources/plugins 所有权,决策日志与排障经验同步 --- .../scripts/build-release.mjs | 27 +- .../scripts/build-release.test.mjs | 19 +- .../scripts/check-package-layout.mjs | 35 ++ .../scripts/prepare-bundled-resources.mjs | 120 ++++-- .../prepare-bundled-resources.test.mjs | 56 ++- .../scripts/start-tauri-dev.mjs | 33 +- apps/ai-game-creator-shell/src-tauri/build.rs | 344 +++--------------- .../build_support/codex_package_metadata.rs | 57 --- .../build_support/package-layout.generated.rs | 10 + .../build_support/package-layout.json | 14 +- .../src-tauri/build_support/package_layout.rs | 310 ++++++++++++++++ .../src-tauri/src/agent/codex_cli.rs | 5 - .../tests/start-tauri-dev.test.ts | 13 +- ...¨‹碑】AGC随包资源改由校验器读入-2026-09-26.md | 2 +- ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 6 +- .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 6 + ...术方案】AGC随包资源staging归位-2026-09-26.md | 12 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 19 files changed, 661 insertions(+), 420 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index fe13fade7..e6ddb30cd 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -18,6 +18,7 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; +import { prepareBundledResources } from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -431,10 +432,29 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { return configPath; } +/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。 +export function stageBundledResources( + target, + { prepare = prepareBundledResources } = {}, +) { + const summaries = prepare({ + target, + features: new Set(defaultEditorFeatures(target)), + log: (line) => console.log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + console.log(`[ai-game-creator-shell] ${summary}`); + } +} + export function runTauriBuild( args = [], context = resolveReleaseContext(args), - { spawn = spawnSync, stageRuntime = stageNodeRuntime } = {}, + { + spawn = spawnSync, + stageRuntime = stageNodeRuntime, + stageBundled = stageBundledResources, + } = {}, ) { if ( explicitBuildTarget(args) && @@ -444,7 +464,10 @@ export function runTauriBuild( } const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel, target } = context; - if (!args.includes('--no-bundle')) stageRuntime(target); + if (!args.includes('--no-bundle')) { + stageRuntime(target); + stageBundled(target); + } const configPath = writeChannelConfigFile( channel, target, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 3cdd735c1..3d046d9d8 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -363,6 +363,8 @@ test('packaged renderer receives the same channel as the updater manifest', () = // 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的 // Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。 stageRuntime: () => {}, + // 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。 + stageBundled: () => {}, spawn: (_binary, _args, options) => { spawnOptions = options; return { status: 0 }; @@ -456,6 +458,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and seenContexts.push(context); runTauriBuild(args, context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const configIndex = command.lastIndexOf('--config'); const config = JSON.parse( @@ -705,6 +709,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme context, { stageRuntime: () => {}, + stageBundled: () => {}, spawn: (_binary, command) => { assert.ok( command.includes( @@ -818,6 +823,10 @@ test('release stages Node before Tauri and injects its resource mapping only for assert.equal(target, windowsTarget); events.push('stage'); }, + stageBundled(target) { + assert.equal(target, windowsTarget); + events.push('bundled'); + }, spawn(_binary, args) { events.push('build'); const config = JSON.parse( @@ -829,11 +838,14 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); - assert.deepEqual(events, ['stage', 'build']); + assert.deepEqual(events, ['stage', 'bundled', 'build']); runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { assert.fail('no-bundle must not stage resources'); }, + stageBundled() { + assert.fail('no-bundle must not stage bundled resources'); + }, spawn(_binary, args) { const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), @@ -848,6 +860,9 @@ test('release stages Node before Tauri and injects its resource mapping only for stageRuntime() { throw new Error('missing runtime'); }, + stageBundled() { + assert.fail('invalid runtime must prevent bundled staging'); + }, spawn() { assert.fail('invalid runtime must prevent build'); }, @@ -960,6 +975,8 @@ for (const channel of ['release', 'beta-2']) { ); runTauriBuild([`--target=${target}`], context, { stageRuntime: () => {}, + // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。 + stageBundled: () => {}, spawn: (_binary, command) => { const config = JSON.parse( readFileSync( diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index d3a5e1237..023f19ae4 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -81,6 +81,15 @@ function expectInteger(value, at) { return value; } +// 随包子目录来源:`source` 由声明与仓库源码就能生成(准备步骤负责), +// `build` 由构建期工具链产出(构建脚本在产物生成后负责)。 +function expectOrigin(value, at) { + if (value !== 'source' && value !== 'build') { + fail(`${at} 必须是 source 或 build(实际 ${String(value)})`); + } + return value; +} + // JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。 function rustString(value) { return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}'); @@ -101,6 +110,21 @@ function renderStruct(name, fields, level = 0) { return `${name} {\n${body}\n${indent(level)}}`; } +function parsePackageMetadata(value) { + const metadata = expectObject(value, 'codex.packageMetadata'); + return { + layoutVersion: expectInteger( + metadata.layoutVersion, + 'codex.packageMetadata.layoutVersion', + ), + resourcesDir: expectString( + metadata.resourcesDir, + 'codex.packageMetadata.resourcesDir', + ), + pathDir: expectString(metadata.pathDir, 'codex.packageMetadata.pathDir'), + }; +} + function parseDeclaration(raw) { const root = expectObject(JSON.parse(raw), 'root'); if (root.schema !== EXPECTED_SCHEMA) { @@ -191,6 +215,7 @@ function parseDeclaration(raw) { const parsed = expectObject(entry, at); return { path: expectString(parsed.path, `${at}.path`), + origin: expectOrigin(parsed.origin, `${at}.origin`), targetContains: optionalStringArray(parsed, 'targetContains', at), targets: optionalStringArray(parsed, 'targets', at), features: optionalStringArray(parsed, 'features', at), @@ -226,6 +251,7 @@ function parseDeclaration(raw) { codex.manifestSchema, 'codex.manifestSchema', ), + packageMetadata: parsePackageMetadata(codex.packageMetadata), resourceDirectory: expectString( codex.resourceDirectory, 'codex.resourceDirectory', @@ -333,6 +359,7 @@ function renderSubdirectory(entry) { 'Subdirectory', [ ['path', rustString(entry.path)], + ['origin', rustString(entry.origin)], ['target_contains', rustStrings(entry.targetContains)], ['targets', rustStrings(entry.targets)], ['features', rustStrings(entry.features)], @@ -359,6 +386,14 @@ function renderGenerated(declaration) { const codex = declaration.codex; const plugins = declaration.plugins; const codexStruct = renderStruct('Codex', [ + [ + 'package_metadata', + renderStruct('PackageMetadata', [ + ['layout_version', String(codex.packageMetadata.layoutVersion)], + ['resources_dir', rustString(codex.packageMetadata.resourcesDir)], + ['path_dir', rustString(codex.packageMetadata.pathDir)], + ]), + ], ['resource_directory', rustString(codex.resourceDirectory)], ['manifest_file_name', rustString(codex.manifestFileName)], ['package_metadata_file_name', rustString(codex.packageMetadataFileName)], diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 7c44d30d4..b5538138f 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -14,12 +14,14 @@ import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, + closeSync, copyFileSync, - createReadStream, existsSync, mkdirSync, + openSync, readdirSync, readFileSync, + readSync, renameSync, rmSync, statSync, @@ -58,11 +60,19 @@ function fail(message) { throw new PrepareError(message); } +/// 声明覆盖的宿主目标;不受声明覆盖的平台返回 null(入口据此跳过资源准备)。 +export function supportedHostTarget( + platform = process.platform, + arch = process.arch, +) { + return HOST_TRIPLES.get(`${platform}:${arch}`) ?? null; +} + export function resolveHostTarget( platform = process.platform, arch = process.arch, ) { - const triple = HOST_TRIPLES.get(`${platform}:${arch}`); + const triple = supportedHostTarget(platform, arch); if (!triple) { fail( `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`, @@ -106,13 +116,21 @@ export function stagingUnit(declaration, target) { } function sha256File(file) { - return new Promise((resolve, reject) => { - const hash = createHash('sha256'); - createReadStream(file) - .on('data', (chunk) => hash.update(chunk)) - .on('end', () => resolve(hash.digest('hex'))) - .on('error', reject); - }); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(64 * 1024); + const descriptor = openSync(file, 'r'); + try { + for (;;) { + const read = readSync(descriptor, buffer, 0, buffer.length, null); + if (read === 0) { + break; + } + hash.update(buffer.subarray(0, read)); + } + } finally { + closeSync(descriptor); + } + return hash.digest('hex'); } function sha256Text(text) { @@ -176,6 +194,29 @@ export function findCodexSource(declaration, target, roots) { return source; } +/// 上游原生包元数据必须与声明一致:版本、目标、入口、资源目录与 path 目录。 +export function validateUpstreamMetadata(declaration, target, source) { + const metadataFile = path.join( + source, + declaration.codex.packageMetadataFileName, + ); + const metadata = JSON.parse(readFileSync(metadataFile, 'utf8')); + const layout = codexLayout(declaration, target); + const expected = declaration.codex.packageMetadata; + const mismatch = + metadata.layoutVersion !== expected.layoutVersion || + metadata.version !== declaration.codex.version || + metadata.target !== target || + metadata.entrypoint !== layout.executable || + metadata.resourcesDir !== expected.resourcesDir || + metadata.pathDir !== expected.pathDir; + if (mismatch) { + fail( + `内置 Codex CLI 上游包元数据与声明不一致(${metadataFile}):期望 layoutVersion=${expected.layoutVersion} version=${declaration.codex.version} target=${target} entrypoint=${layout.executable};请确认上游包版本后再同步 build_support/package-layout.json`, + ); + } +} + /// 缓存 key 的锁定信息:上游 package-lock 的 resolved + integrity。 export function readLockedUpstream( declaration, @@ -272,13 +313,14 @@ function assertOwnedUnit(unitPath, declaration, unit) { } /// 期望产物:每个目标的组件摘要与清单文本。 -async function desiredCodex(declaration, unit, roots) { +function desiredCodex(declaration, unit, roots) { const desired = new Map(); for (const member of unit.targets) { const source = findCodexSource(declaration, member.target, roots); + validateUpstreamMetadata(declaration, member.target, source); const hashes = new Map(); for (const relative of member.layout.files) { - hashes.set(relative, await sha256File(path.join(source, relative))); + hashes.set(relative, sha256File(path.join(source, relative))); } desired.set(member.target, { source, @@ -294,7 +336,7 @@ async function desiredCodex(declaration, unit, roots) { return desired; } -async function unitMatchesExisting(unitPath, declaration, unit, desired) { +function unitMatchesExisting(unitPath, declaration, unit, desired) { if (!existsSync(unitPath)) { return false; } @@ -328,10 +370,7 @@ async function unitMatchesExisting(unitPath, declaration, unit, desired) { return false; } const info = statSync(filePath); - if ( - info.size !== file.size || - (await sha256File(filePath)) !== file.sha256 - ) { + if (info.size !== file.size || sha256File(filePath) !== file.sha256) { return false; } if (relative === member.layout.executable && (info.mode & 0o111) === 0) { @@ -362,7 +401,7 @@ function recordEntryFor(unitPath, declaration, unit, desired) { return { manifests, sizes }; } -async function unitRecordMatches(unitPath, declaration, unit, recorded) { +function unitRecordMatches(unitPath, declaration, unit, recorded) { if (!recorded) { return false; } @@ -461,7 +500,7 @@ function stageAtomically(unitPath, builder) { renameSync(stagingPath, unitPath); } -async function prepareCodex({ +function prepareCodex({ declaration, target, destinationRoot, @@ -483,17 +522,17 @@ async function prepareCodex({ const recorded = record.codex?.[unit.directory]; if ( recorded?.key === key && - (await unitRecordMatches(unitPath, declaration, unit, recorded)) + unitRecordMatches(unitPath, declaration, unit, recorded) ) { return { summary: `${label} 命中缓存(未写入)`, record: undefined }; } - const desired = await desiredCodex(declaration, unit, roots); + const desired = desiredCodex(declaration, unit, roots); const entry = { key, ...recordEntryFor(unitPath, declaration, unit, desired), }; - if (await unitMatchesExisting(unitPath, declaration, unit, desired)) { + if (unitMatchesExisting(unitPath, declaration, unit, desired)) { return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry }; } if (dryRun) { @@ -615,6 +654,9 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { const lines = []; for (const plugin of plugins) { for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -638,7 +680,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { return sha256Text(lines.join('\n')); } -async function pluginTreeMatches( +function pluginTreeMatches( declaration, plugins, target, @@ -658,6 +700,9 @@ async function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -675,7 +720,7 @@ async function pluginTreeMatches( if (statSync(source).size !== statSync(staged).size) { return false; } - if ((await sha256File(source)) !== (await sha256File(staged))) { + if (sha256File(source) !== sha256File(staged)) { return false; } } @@ -698,7 +743,7 @@ function assertOwnedPluginRoot(destination, plugins) { } } -async function preparePlugins({ +function preparePlugins({ declaration, target, destinationRoot, @@ -719,13 +764,7 @@ async function preparePlugins({ ); if ( record.plugins?.fingerprint === fingerprint && - (await pluginTreeMatches( - declaration, - plugins, - target, - features, - destination, - )) + pluginTreeMatches(declaration, plugins, target, features, destination) ) { return { summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, @@ -747,6 +786,9 @@ async function preparePlugins({ path.join(pluginDestination, declaration.plugins.manifestFileName), ); for (const subdirectory of declaration.plugins.subdirectories) { + if (subdirectory.origin !== 'source') { + continue; + } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -765,7 +807,7 @@ async function preparePlugins({ } /// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 -export async function prepareBundledResources({ +export function prepareBundledResources({ target = resolveHostTarget(), destinationRoot = SRC_TAURI_DIR, declarationPath = DECLARATION_PATH, @@ -780,7 +822,7 @@ export async function prepareBundledResources({ const record = readRecord(recordPath); const summaries = []; let changed = false; - const codex = await prepareCodex({ + const codex = prepareCodex({ declaration, target, destinationRoot, @@ -802,7 +844,7 @@ export async function prepareBundledResources({ target.includes(needle), ) ) { - const plugins = await preparePlugins({ + const plugins = preparePlugins({ declaration, target, destinationRoot, @@ -844,9 +886,9 @@ function parseArguments(argv) { return args; } -async function main(argv) { +function main(argv) { const args = parseArguments(argv); - const summaries = await prepareBundledResources({ + const summaries = prepareBundledResources({ target: args.target ?? resolveHostTarget(), destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, dryRun: args.dryRun, @@ -860,11 +902,13 @@ if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { - main(process.argv.slice(2)).catch((error) => { + try { + main(process.argv.slice(2)); + } catch (error) { if (error instanceof PrepareError) { console.error(`[agc-resources] ${error.message}`); process.exit(1); } throw error; - }); + } } diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 4876a3836..b3cf4479b 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -43,7 +43,23 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { for (const relative of layout.files) { const file = path.join(vendor, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, `component ${target} ${relative}\n`); + fs.writeFileSync( + file, + relative === declaration.codex.packageMetadataFileName + ? `${JSON.stringify( + { + layoutVersion: declaration.codex.packageMetadata.layoutVersion, + version: declaration.codex.version, + target, + entrypoint: layout.executable, + resourcesDir: declaration.codex.packageMetadata.resourcesDir, + pathDir: declaration.codex.packageMetadata.pathDir, + }, + null, + 2, + )}\n` + : `component ${target} ${relative}\n`, + ); if (relative === layout.executable) { fs.chmodSync(file, 0o755); } @@ -152,10 +168,10 @@ function prepare(fixture, overrides = {}) { }); } -test('stages declared codex components with manifest and preserved notice', async () => { +test('stages declared codex components with manifest and preserved notice', () => { const fixture = buildFixture(); try { - const summaries = await prepare(fixture); + const summaries = prepare(fixture); assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/); const declaration = fixture.declaration; @@ -209,10 +225,10 @@ test('stages declared codex components with manifest and preserved notice', asyn } }); -test('second run is a no-op: identical content and timestamps', async () => { +test('second run is a no-op: identical content and timestamps', () => { const fixture = buildFixture(); try { - await prepare(fixture); + prepare(fixture); const unit = path.join( fixture.destinationRoot, 'resources/codex', @@ -220,7 +236,7 @@ test('second run is a no-op: identical content and timestamps', async () => { ); const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); const before = { codex: snapshot(unit), plugins: snapshot(plugins) }; - const summaries = await prepare(fixture); + const summaries = prepare(fixture); assert.match(summaries[0], /命中缓存/); assert.match(summaries[1], /命中缓存/); assert.deepEqual( @@ -238,12 +254,12 @@ test('second run is a no-op: identical content and timestamps', async () => { } }); -test('stages the macOS universal group with both architectures', async () => { +test('stages the macOS universal group with both architectures', () => { const fixture = buildFixture({ targets: [MAC_TARGET, 'x86_64-apple-darwin'], }); try { - const summaries = await prepare(fixture, { target: MAC_TARGET }); + const summaries = prepare(fixture, { target: MAC_TARGET }); assert.match(summaries[0], /mac-native/); const unit = path.join( fixture.destinationRoot, @@ -270,10 +286,10 @@ test('stages the macOS universal group with both architectures', async () => { } }); -test('copies only whitelisted plugin subdirectories', async () => { +test('copies only whitelisted plugin subdirectories', () => { const fixture = buildFixture(); try { - await prepare(fixture); + prepare(fixture); const staged = path.join( fixture.destinationRoot, 'resources/plugins/agc-demo-editor', @@ -296,14 +312,14 @@ test('copies only whitelisted plugin subdirectories', async () => { } }); -test('fails closed when the upstream package is missing', async () => { +test('fails closed when the upstream package is missing', () => { const fixture = buildFixture(); try { fs.rmSync(path.join(fixture.appRoot, 'node_modules'), { recursive: true, force: true, }); - await assert.rejects(prepare(fixture), /npm ci/); + assert.throws(() => prepare(fixture), /npm ci/); assert.ok( !fs.existsSync( path.join( @@ -321,11 +337,11 @@ test('fails closed when the upstream package is missing', async () => { } }); -test('fails closed for unsupported targets', async () => { +test('fails closed for unsupported targets', () => { const fixture = buildFixture(); try { - await assert.rejects( - prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), + assert.throws( + () => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }), /声明不含目标/, ); assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/); @@ -334,28 +350,28 @@ test('fails closed for unsupported targets', async () => { } }); -test('fails closed when the destination is owned by something else', async () => { +test('fails closed when the destination is owned by something else', () => { const fixture = buildFixture(); try { const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n'); - await assert.rejects(prepare(fixture), /被非本工具内容占用/); + assert.throws(() => prepare(fixture), /被非本工具内容占用/); const plugins = path.join(fixture.destinationRoot, 'resources/plugins'); fs.rmSync(path.join(unit, 'foreign.bin'), { force: true }); fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), { recursive: true, }); - await assert.rejects(prepare(fixture), /插件随包目录被非本工具内容占用/); + assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/); } finally { fixture.cleanup(); } }); -test('dry run writes nothing', async () => { +test('dry run writes nothing', () => { const fixture = buildFixture(); try { - const summaries = await prepare(fixture, { dryRun: true }); + const summaries = prepare(fixture, { dryRun: true }); assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); assert.deepEqual( diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 3d72403ae..47a6eaeb3 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -11,6 +11,10 @@ import { resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from './dev-port.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { isAiGameCreatorServer, preflightExistingVite, @@ -68,6 +72,29 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { return withDefaultCargoFeatures(argv, features); } +/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 +/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +function prepareBundledResourcesBeforeTauri( + features = readDevCargoFeatures(), + { prepare = prepareBundledResources, log = console.log } = {}, +) { + const target = supportedHostTarget(); + if (!target) { + log( + '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', + ); + return; + } + const summaries = prepare({ + target, + features: new Set(features), + log: (line) => log(`[ai-game-creator-shell] ${line}`), + }); + for (const summary of summaries) { + log(`[ai-game-creator-shell] ${summary}`); + } +} + function spawnTauriCli(argv, { env = process.env } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, @@ -96,6 +123,7 @@ async function runTauriDev( spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, + prepareResources = prepareBundledResourcesBeforeTauri, } = {}, ) { const endpoint = await resolveDevEndpoint(); @@ -142,8 +170,10 @@ async function runTauriDev( shutdownRequested.then(() => false), ]); if (!prepared || shutdownSignal) return 1; + const devFeatures = readDevCargoFeatures(); + prepareResources(devFeatures); const tauriArguments = buildTauriArguments( - withDevCargoFeatures(argv), + withDevCargoFeatures(argv, devFeatures), endpoint.url, ); child = spawnCli(tauriArguments, { @@ -236,6 +266,7 @@ export { buildTauriArguments, buildTauriDevProcessEnv, isDirectModuleExecution, + prepareBundledResourcesBeforeTauri, runTauriDev, spawnTauriCli, withDevCargoFeatures, diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 52a397bce..948438b62 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,7 +1,8 @@ +// 构建脚本只用布局里的目录与校验入口(写入分支已移交准备步骤), +// 其余字段与常量供运行期使用,因此这里不报构建上下文里的 dead_code。 +#[allow(dead_code)] #[path = "build_support/codex_bundle.rs"] mod codex_bundle; -#[path = "build_support/codex_package_metadata.rs"] -mod codex_package_metadata; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[path = "build_support/godot_bundle.rs"] @@ -17,145 +18,6 @@ use std::path::PathBuf; use codex_bundle::package_layout; -fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - let resource_directory = manifest_dir.join(package_layout::codex().resource_directory); - if let Some(group) = package_layout::codex_universal_group(&target) { - // Tauri 的 universal 两次 Cargo 编译共用 resource staging, - // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。 - let staging = resource_directory.join(group.directory); - if staging.exists() { - fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败"); - } - for member in group.targets { - stage_codex_target(manifest_dir, member); - } - } else { - stage_codex_target(manifest_dir, &target); - } -} - -fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { - let Some(layout) = codex_bundle::for_target(target) else { - assert!( - !target.contains("windows") && !target.contains("apple-darwin"), - "不支持的 Codex 随包目标:{target}" - ); - return; - }; - { - let app_root = manifest_dir - .parent() - .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下"); - let repo_root = app_root - .parent() - .and_then(|apps_dir| apps_dir.parent()) - .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let source_candidates = - package_layout::codex_source_candidates(app_root, repo_root, target) - .unwrap_or_else(|error| panic!("{error}")); - let source = source_candidates - .iter() - .find(|path| { - layout - .files - .iter() - .all(|relative| path.join(relative).is_file()) - }) - .cloned() - .unwrap_or_else(|| { - panic!( - "内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})", - source_candidates - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(";") - ) - }); - let metadata: serde_json::Value = serde_json::from_slice( - &fs::read(source.join(package_layout::codex().package_metadata_file_name)) - .expect("读取 Codex 原生包元数据失败"), - ) - .expect("Codex 原生包元数据无效"); - codex_package_metadata::validate_package_metadata(&metadata, target, layout) - .unwrap_or_else(|error| panic!("{error}")); - let target_dir = manifest_dir - .join(package_layout::codex().resource_directory) - .join(layout.directory); - let notice = target_dir.join(package_layout::codex().notice_file_name); - if let Some(notice_source) = package_layout::codex_notice_source(target) { - if !notice_source.preserve { - // 受版本控制的第三方声明由此处复制;`preserve` 的声明文件本身已在随包目录内。 - let source_notice = manifest_dir.join(notice_source.source); - stage_plugin_file(&source_notice, ¬ice); - println!("cargo:rerun-if-changed={}", source_notice.display()); - } - } - if !notice.is_file() { - panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); - } - fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); - let mut file_hashes = serde_json::Map::new(); - for relative in layout.files { - let source_path = source.join(relative); - let target_path = target_dir.join(relative); - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败"); - } - let source_sha256 = - package_layout::sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); - let target_matches_source = target_path.is_file() - && package_layout::sha256_file(&target_path) - .map(|target_sha256| target_sha256 == source_sha256) - .unwrap_or(false); - let source_permissions = fs::metadata(&source_path) - .expect("读取组件权限失败") - .permissions(); - if !target_matches_source { - fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); - fs::set_permissions(&target_path, source_permissions.clone()) - .expect("保留内置 Codex CLI 组件权限失败"); - } else if fs::metadata(&target_path) - .expect("读取内置 Codex CLI 资源失败") - .permissions() - != source_permissions - { - // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 - // 权限已一致时不再写元数据:Windows 上这次写入会更新 change time, - // 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。 - fs::set_permissions(&target_path, source_permissions) - .expect("保留内置 Codex CLI 组件权限失败"); - } - file_hashes.insert( - relative.to_string(), - serde_json::Value::String(source_sha256), - ); - } - let manifest = serde_json::json!({ - "schemaVersion": codex_bundle::SCHEMA, - "platform": layout.platform, - "version": codex_bundle::CLI_VERSION, - "files": file_hashes, - }); - let manifest_path = target_dir.join(package_layout::codex().manifest_file_name); - let manifest_payload = format!( - "{}\n", - serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") - ); - if fs::read_to_string(&manifest_path) - .map(|current| current != manifest_payload) - .unwrap_or(true) - { - fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); - } - for relative in layout.files { - println!("cargo:rerun-if-changed={}", source.join(relative).display()); - } - println!("cargo:rerun-if-changed={}", notice.display()); - } -} - fn seed_task_group_id( group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, ) -> &'static str { @@ -216,76 +78,22 @@ fn validate_staged_resources(manifest_dir: &std::path::Path) { validate_staged_plugin_workspace(manifest_dir, &target); } -/// 只读校验插件随包工作区:插件清单与声明的必需子目录齐备、整树无符号链接、无越界路径。 -/// -/// 插件产物的逐文件摘要校验在准备步骤接管写入后启用——在那之前构建脚本仍会整体重建 -/// `resources/plugins`,任何写在树内的准备步骤清单都会被清掉。 +/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。 fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { - if !package_layout::plugin_staging_applies(target) { - return; - } let declared = package_layout::plugins(); - let destination_root = manifest_dir.join(declared.destination_directory); - if !destination_root.is_dir() { - panic!("插件随包资源目录缺失:{}", destination_root.display()); - } let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) .and_then(|apps_dir| apps_dir.parent()) .expect("AGC 应用必须位于仓库 apps 目录下"); - if let Ok(entries) = std::fs::read_dir(repo_root.join(declared.source_directory)) { - for entry in entries.flatten() { - let plugin_root = entry.path(); - if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { - continue; - } - let name = entry.file_name(); - let staged = destination_root.join(&name); - if !staged.join(declared.manifest_file_name).is_file() { - panic!( - "随包插件缺少清单:{}", - staged.join(declared.manifest_file_name).display() - ); - } - for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_enabled( - subdirectory, - target, - package_layout::cargo_feature_enabled, - ) { - continue; - } - let relative = package_layout::declared_relative_path(subdirectory.path); - if plugin_root.join(&relative).is_dir() && !staged.join(&relative).is_dir() { - panic!( - "随包插件缺少必需目录:{}(插件 {})", - staged.join(&relative).display(), - name.to_string_lossy() - ); - } - } - for staging in declared.library_staging { - if name != staging.plugin - || !package_layout::library_staging_enabled( - staging, - target, - package_layout::cargo_feature_enabled, - ) - { - continue; - } - let relative = package_layout::declared_relative_path(staging.source_subdirectory); - if !staged.join(&relative).is_dir() { - panic!("随包库目录缺失:{}", staged.join(&relative).display()); - } - } - } - } - package_layout::collect_tree_files(&destination_root) - .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); + package_layout::validate_staged_plugins( + &repo_root.join(declared.source_directory), + &manifest_dir.join(declared.destination_directory), + target, + package_layout::cargo_feature_enabled, + ) + .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}")); } - fn main() { let manifest_dir = PathBuf::from( env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), @@ -299,10 +107,9 @@ fn main() { // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), // 用于在既有产物上单独验证校验路径。 if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { - stage_bundled_codex_cli(&manifest_dir); prepare_unity_editor_helper(&manifest_dir); prepare_godot_editor_extension(&manifest_dir); - stage_plugin_workspace(&manifest_dir); + stage_build_generated_plugin_payloads(&manifest_dir); stage_cocos_editor_payload(&manifest_dir); } validate_staged_resources(&manifest_dir); @@ -508,11 +315,11 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}")); } -/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。 +/// 构建期产物归位:只有构建过程才产出、因而无法由准备步骤生成的随包子目录。 /// -/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 -/// Cargo target 目录或 node_modules。 -fn stage_plugin_workspace(manifest_dir: &std::path::Path) { +/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。 +/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。 +fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) { let target = env::var("TARGET").expect("Cargo TARGET"); if !package_layout::plugin_staging_applies(&target) { return; @@ -522,50 +329,36 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .parent() .and_then(|app_root| app_root.parent()) .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下") - .to_path_buf(); - let workspace = repo_root.join(declared.source_directory); + .expect("AGC 应用必须位于仓库 apps 目录下"); let destination_root = manifest_dir.join(declared.destination_directory); - // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 - if destination_root.exists() { - std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); - } - std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); - let entries = match std::fs::read_dir(&workspace) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let plugin_root = entry.path(); - assert!( - !entry - .file_type() - .expect("读取插件目录类型失败") - .is_symlink(), - "插件工作区不允许符号链接" - ); - if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() { - continue; - } - let name = entry.file_name(); - let destination = destination_root.join(&name); - copy_plugin_file( - &plugin_root.join(declared.manifest_file_name), - &destination.join(declared.manifest_file_name), - ); + let plugins = package_layout::plugin_directories( + &repo_root.join(declared.source_directory), + declared.manifest_file_name, + ) + .unwrap_or_else(|error| panic!("{error}")); + for plugin in plugins { for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_enabled( - subdirectory, - &target, - package_layout::cargo_feature_enabled, - ) { + if !package_layout::subdirectory_is_build_derived(subdirectory) + || !package_layout::subdirectory_enabled( + subdirectory, + &target, + package_layout::cargo_feature_enabled, + ) + { continue; } let relative = package_layout::declared_relative_path(subdirectory.path); - copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); + let source = plugin.path.join(&relative); + if !source.is_dir() { + continue; + } + copy_staged_tree( + &source, + &destination_root.join(&plugin.name).join(&relative), + ); } for staging in declared.library_staging { - if name != staging.plugin + if plugin.name != staging.plugin || !package_layout::library_staging_enabled( staging, &target, @@ -577,8 +370,8 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { let relative = package_layout::declared_relative_path(staging.source_subdirectory); match staging.layout { "godot-bundle" => godot_bundle::stage( - &plugin_root.join(&relative), - &destination.join(&relative), + &plugin.path.join(&relative), + &destination_root.join(&plugin.name).join(&relative), &target, true, ) @@ -586,60 +379,37 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { other => panic!("未实现的随包库 staging 布局:{other}"), } } - println!("cargo:rerun-if-changed={}", plugin_root.display()); } } -fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - let bytes = std::fs::read(source) - .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display())); - if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { +/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。 +fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) { + if !source.is_dir() { return; } - if let Some(parent) = destination.parent() { - std::fs::create_dir_all(parent).expect("创建插件资源目录失败"); - } - std::fs::write(destination, bytes).expect("复制插件资源失败"); -} - -fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { - let entries = match std::fs::read_dir(source) { - Ok(entries) => entries, - Err(_) => return, - }; - for entry in entries.flatten() { - let target = destination.join(entry.file_name()); + fs::create_dir_all(destination).expect("创建插件资源目录失败"); + for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() { let path = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + let target = destination.join(&file_name); assert!( - !entry + entry .file_type() .expect("读取插件文件类型失败") .is_symlink(), "插件资源不允许符号链接" ); if path.is_dir() { - let name = entry.file_name(); - if package_layout::skip_directory(&name.to_string_lossy()) { + if !package_layout::skip_directory(&name) { + copy_staged_tree(&path, &target); + } + } else if !package_layout::skip_file_name(&name) { + let bytes = fs::read(&path).expect("读取插件资源失败"); + if fs::read(&target).is_ok_and(|existing| existing == bytes) { continue; } - std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); - copy_plugin_tree(&path, &target); - } else { - // 测试文件与隐藏文件不随包分发。 - let name = entry.file_name(); - if package_layout::skip_file_name(&name.to_string_lossy()) { - continue; - } - stage_plugin_file(&path, &target); + fs::write(&target, bytes).expect("复制插件资源失败"); } } } - -fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - if !source.is_file() { - return; - } - std::fs::create_dir_all(destination.parent().expect("插件资源父目录")) - .expect("创建插件资源目录失败"); - std::fs::copy(source, destination).expect("复制插件资源失败"); -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs deleted file mode 100644 index 04b9b6a41..000000000 --- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! 随包阶段的原生包元数据校验,不进入运行时生产模块。 - -use super::codex_bundle::{Layout, VERSION}; - -pub fn validate_package_metadata( - metadata: &serde_json::Value, - target: &str, - layout: Layout, -) -> Result<(), String> { - if metadata["layoutVersion"] == 1 - && metadata["version"] == VERSION - && metadata["target"] == target - && metadata["entrypoint"] == layout.executable - && metadata["resourcesDir"] == "codex-resources" - && metadata["pathDir"] == "codex-path" - { - Ok(()) - } else { - Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}")) - } -} - -#[cfg(test)] -mod tests { - use super::super::codex_bundle::for_target; - use super::*; - - #[test] - fn metadata_rejects_version_architecture_and_layout_drift() { - let target = "aarch64-apple-darwin"; - let layout = for_target(target).unwrap(); - let valid = serde_json::json!({ - "layoutVersion": 1, - "version": VERSION, - "target": target, - "entrypoint": "bin/codex", - "resourcesDir": "codex-resources", - "pathDir": "codex-path", - }); - assert!(validate_package_metadata(&valid, target, layout).is_ok()); - for (key, value) in [ - ("layoutVersion", serde_json::json!(2)), - ("version", serde_json::json!("0.0.0")), - ("target", serde_json::json!("x86_64-apple-darwin")), - ("entrypoint", serde_json::json!("bin/codex.exe")), - ("resourcesDir", serde_json::json!("../private")), - ("pathDir", serde_json::json!(null)), - ] { - let mut invalid = valid.clone(); - invalid[key] = value; - assert!( - validate_package_metadata(&invalid, target, layout).is_err(), - "{key}" - ); - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index ebfe90b06..3ae75c1cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -12,6 +12,11 @@ pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; pub const CODEX: Codex = Codex { + package_metadata: PackageMetadata { + layout_version: 1, + resources_dir: "codex-resources", + path_dir: "codex-path", +}, resource_directory: "resources/codex", manifest_file_name: "manifest.json", package_metadata_file_name: "codex-package.json", @@ -70,30 +75,35 @@ pub const PLUGINS: Plugins = Plugins { subdirectories: &[ Subdirectory { path: "src", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "panels", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "skills", + origin: "source", target_contains: &[], targets: &[], features: &[], }, Subdirectory { path: "native/payload", + origin: "source", target_contains: &["windows"], targets: &[], features: &[], }, Subdirectory { path: "dotnet/publish/win-x64", + origin: "build", target_contains: &[], targets: &["x86_64-pc-windows-msvc"], features: &["unity-editor-execute"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 21ac80bbe..56fb12ed2 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -6,6 +6,11 @@ "version": "0.155.1", "cliVersionPrefix": "codex-cli ", "manifestSchema": "genarrative-codex-sidecar.v2", + "packageMetadata": { + "layoutVersion": 1, + "resourcesDir": "codex-resources", + "pathDir": "codex-path" + }, "resourceDirectory": "resources/codex", "manifestFileName": "manifest.json", "packageMetadataFileName": "codex-package.json", @@ -83,12 +88,13 @@ "manifestFileName": "plugin.json", "targetContainsAny": ["windows", "apple-darwin"], "subdirectories": [ - { "path": "src" }, - { "path": "panels" }, - { "path": "skills" }, - { "path": "native/payload", "targetContains": ["windows"] }, + { "path": "src", "origin": "source" }, + { "path": "panels", "origin": "source" }, + { "path": "skills", "origin": "source" }, + { "path": "native/payload", "origin": "source", "targetContains": ["windows"] }, { "path": "dotnet/publish/win-x64", + "origin": "build", "targets": ["x86_64-pc-windows-msvc"], "features": ["unity-editor-execute"] } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 28ae352b6..9ce439f32 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -37,10 +37,20 @@ pub struct NoticeSource { pub preserve: bool, } +/// 上游原生包元数据里必须与声明一致的字段。 +#[derive(Debug)] +pub struct PackageMetadata { + pub layout_version: u64, + pub resources_dir: &'static str, + pub path_dir: &'static str, +} + /// 插件工作区的随包子目录及其生效条件。 #[derive(Debug)] pub struct Subdirectory { pub path: &'static str, + /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。 + pub origin: &'static str, pub target_contains: &'static [&'static str], pub targets: &'static [&'static str], pub features: &'static [&'static str], @@ -58,6 +68,7 @@ pub struct LibraryStaging { #[derive(Debug)] pub struct Codex { + pub package_metadata: PackageMetadata, pub resource_directory: &'static str, pub manifest_file_name: &'static str, pub package_metadata_file_name: &'static str, @@ -236,6 +247,158 @@ pub fn declared_relative_path(relative: &str) -> PathBuf { relative.split('/').collect() } +/// 只读校验插件随包工作区。 +/// +/// 声明为源码派生的内容必须与仓库源码逐文件一致(清单 + 逐文件 sha256),构建期派生的子目录 +/// 只要求存在(内容由产出它的构建步骤负责),整树不得出现符号链接;编辑器分支产物不动。 +pub fn validate_staged_plugins( + source_root: &Path, + destination_root: &Path, + target: &str, + feature_enabled: impl Fn(&str) -> bool, +) -> Result<(), String> { + if !plugin_staging_applies(target) { + return Ok(()); + } + if !destination_root.is_dir() { + return Err(format!( + "插件随包资源目录缺失:{}", + destination_root.display() + )); + } + for plugin in plugin_directories(source_root, plugins().manifest_file_name)? { + let staged = destination_root.join(&plugin.name); + let source_manifest = plugin.path.join(plugins().manifest_file_name); + let staged_manifest = staged.join(plugins().manifest_file_name); + if !staged_manifest.is_file() { + return Err(format!("随包插件缺少清单:{}", staged_manifest.display())); + } + if sha256_file(&source_manifest).ok() != sha256_file(&staged_manifest).ok() { + return Err(format!( + "随包插件清单与源码不一致:{}", + staged_manifest.display() + )); + } + for subdirectory in plugins().subdirectories { + if !subdirectory_enabled(subdirectory, target, &feature_enabled) { + continue; + } + let relative = declared_relative_path(subdirectory.path); + let source = plugin.path.join(&relative); + let staged_directory = staged.join(&relative); + if subdirectory_is_build_derived(subdirectory) { + if source.exists() && !staged_directory.is_dir() { + return Err(format!( + "随包构建期产物缺失:{}(插件 {})", + staged_directory.display(), + plugin.name + )); + } + continue; + } + if !source.is_dir() { + continue; + } + if !staged_directory.is_dir() { + return Err(format!( + "随包插件缺少必需目录:{}(插件 {})", + staged_directory.display(), + plugin.name + )); + } + for relative_file in collect_sources(&source)? { + let source_file = source.join(declared_relative_path(&relative_file)); + let staged_file = staged_directory.join(declared_relative_path(&relative_file)); + if !staged_file.is_file() { + return Err(format!("随包插件缺少文件:{}", staged_file.display())); + } + if sha256_file(&source_file).ok() != sha256_file(&staged_file).ok() { + return Err(format!( + "随包插件文件与源码不一致:{}", + staged_file.display() + )); + } + } + } + } + collect_tree_files(destination_root)?; + Ok(()) +} + +/// 声明为「由准备步骤按源码派生」的子目录。 +pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "source" +} + +/// 声明为「由构建期工具链产出」的子目录。 +pub fn subdirectory_is_build_derived(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "build" +} + +/// 仓库插件目录:含清单文件的普通目录,按名字排序。 +pub fn plugin_directories( + source_root: &Path, + manifest_file_name: &str, +) -> Result, String> { + let mut plugins = Vec::new(); + let entries = std::fs::read_dir(source_root) + .map_err(|error| format!("插件工作区不可读 {}:{error}", source_root.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("插件目录项不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let file_type = entry + .file_type() + .map_err(|error| format!("插件目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() && path.join(manifest_file_name).is_file() { + plugins.push(PluginDirectory { name, path }); + } + } + plugins.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(plugins) +} + +pub struct PluginDirectory { + pub name: String, + pub path: PathBuf, +} + +/// 按声明的跳过规则收集目录下的文件(相对路径,`/` 分隔);遇到符号链接即失败。 +pub fn collect_sources(root: &Path) -> Result, String> { + let mut files = BTreeSet::new(); + let mut stack = vec![(root.to_path_buf(), String::new())]; + while let Some((directory, prefix)) = stack.pop() { + let entries = std::fs::read_dir(&directory) + .map_err(|error| format!("随包资源源码不可读 {}:{error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| format!("随包资源目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("随包资源目录项类型不可读:{error}"))?; + let name = entry.file_name().to_string_lossy().to_string(); + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if file_type.is_symlink() { + return Err(format!("随包资源不允许符号链接:{relative}")); + } + if file_type.is_dir() { + if !skip_directory(&name) { + stack.push((entry.path(), relative)); + } + } else if !skip_file_name(&name) { + files.insert(relative); + } + } + } + Ok(files) +} + /// 只读校验一份已经落盘的 Codex 随包目录(本函数不写任何文件)。 /// /// 校验项:目录存在且非链接、清单存在且 schema/平台/版本一致、清单文件集合与组件白名单完全一致、 @@ -681,4 +844,151 @@ mod tests { assert!(skip_file_name("runner.test.mjs")); assert!(!skip_file_name("runner.mjs")); } + + #[test] + fn plugin_subdirectories_declare_their_origin() { + let source = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_source_derived(entry)) + .map(|entry| entry.path) + .collect::>(); + let build = PLUGINS + .subdirectories + .iter() + .filter(|entry| subdirectory_is_build_derived(entry)) + .map(|entry| entry.path) + .collect::>(); + assert_eq!(source, ["src", "panels", "skills", "native/payload"]); + assert_eq!(build, ["dotnet/publish/win-x64"]); + } + + #[test] + fn package_metadata_expectations_come_from_the_declaration() { + assert_eq!(CODEX.package_metadata.layout_version, 1); + assert_eq!(CODEX.package_metadata.resources_dir, "codex-resources"); + assert_eq!(CODEX.package_metadata.path_dir, "codex-path"); + } + + /// 源码与随包目录各写一份插件夹具:随包侧缺测试文件、带一个构建期产物目录。 + fn write_plugin_fixture(source_root: &Path, destination_root: &Path) { + let plugin_source = source_root.join("agc-demo-editor"); + fs::create_dir_all(plugin_source.join("src")).expect("create src"); + fs::write( + plugin_source.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write manifest"); + fs::write(plugin_source.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write entry"); + fs::write(plugin_source.join("src/entry.test.mjs"), "test\n").expect("write test file"); + fs::create_dir_all(plugin_source.join("native/gdextension")).expect("create gdextension"); + + let staged = destination_root.join("agc-demo-editor"); + fs::create_dir_all(staged.join("src")).expect("create staged src"); + fs::write( + staged.join("plugin.json"), + "{\"name\":\"agc-demo-editor\"}\n", + ) + .expect("write staged manifest"); + fs::write(staged.join("src/entry.mjs"), "export const a = 1;\n") + .expect("write staged entry"); + } + + #[test] + fn staged_plugins_are_checked_against_repository_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect("valid plugin workspace"); + + let entry = destination_root.join("agc-demo-editor/src/entry.mjs"); + fs::write(&entry, "tampered\n").expect("tamper"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject digest drift"); + assert!(error.contains("不一致"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(&entry).expect("remove"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing file"); + assert!(error.contains("缺少文件"), "{error}"); + + fs::write(&entry, "export const a = 1;\n").expect("restore"); + fs::remove_file(destination_root.join("agc-demo-editor/plugin.json")) + .expect("remove manifest"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject missing manifest"); + assert!(error.contains("缺少清单"), "{error}"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-unknown-linux-gnu", + |_| true, + ) + .expect("不支持的目标直接放行"); + } + + #[cfg(unix)] + #[test] + fn staged_plugins_reject_symlinks() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + symlink( + "entry.mjs", + destination_root.join("agc-demo-editor/src/link.mjs"), + ) + .expect("symlink"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("must reject symlink"); + assert!(error.contains("符号链接"), "{error}"); + } + + #[test] + fn collect_sources_applies_declared_skip_rules() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path(); + fs::create_dir_all(root.join("target")).expect("create target"); + fs::create_dir_all(root.join("node_modules")).expect("create node_modules"); + fs::create_dir_all(root.join("src")).expect("create src"); + fs::write(root.join("target/junk.rs"), "junk\n").expect("write junk"); + fs::write(root.join("node_modules/pkg.js"), "pkg\n").expect("write pkg"); + fs::write(root.join("src/entry.mjs"), "entry\n").expect("write entry"); + fs::write(root.join("src/entry.test.mjs"), "test\n").expect("write test"); + fs::write(root.join(".env"), "secret\n").expect("write env"); + let files = collect_sources(root).expect("collect"); + assert_eq!(files.into_iter().collect::>(), ["src/entry.mjs"]); + } } 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 918e1e6df..707e5e29a 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 @@ -8,11 +8,6 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; #[path = "../../build_support/codex_bundle.rs"] pub(crate) mod codex_bundle; -// 复用构建端校验的既有单测,生产运行时只编译共享布局。 -#[cfg(test)] -#[path = "../../build_support/codex_package_metadata.rs"] -mod codex_package_metadata; - const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index dd83b82a8..5cfcd7d3f 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -28,7 +28,13 @@ const resolveTestEndpoint = async () => testEndpoint; const runTauriDev = ( argv: string[], options: Parameters[1], -) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options }); +) => + runTauriDevImpl(argv, { + prepareFrontend: async () => {}, + // 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。 + prepareResources: () => {}, + ...options, + }); async function waitForFile(path: string, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; @@ -153,6 +159,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { prepareFrontend: async () => { order.push('frontend-ready'); }, + prepareResources: (features) => { + expect(Array.isArray(features)).toBe(true); + order.push('resources'); + }, spawnCli: () => { order.push('spawn'); return child; @@ -172,6 +182,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(order).toEqual([ 'preflight', 'frontend-ready', + 'resources', 'spawn', 'exit', 'cleanup', diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md index 62bb4160e..9fb199b27 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | ----------------------------------------------------------- | | Version | 1.0 | -| Status | in-progress(2026-09-27 起实施 M1) | +| Status | completed(2026-09-27) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index 72d4a340b..598b6b7f0 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -2,9 +2,9 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | -| Version | 1.0 | -| Status | proposed | -| Date | 2026-09-26 | +| Version | 1.0 | +| Status | in-progress(2026-09-27 起实施) | +| Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | ## 目标 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 43d8879a5..ab546dd32 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9588,3 +9588,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。 - 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。 - 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。 + +## 2026-09-27 AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入 + +- 背景:M1 只交付了单一声明、准备步骤与只读校验,构建脚本仍在写随包资源,所以 macOS 的 `npm run agc` 仍会因 `resources/codex/mac-native` 被重写而反复重建、`cargo build` 每次重编主 crate(41–87 秒)。 +- 决策(接线):`start-tauri-dev.mjs` 在前端与配套后端就绪之后、spawn Tauri CLI 之前调用准备步骤(命中缓存零写入,日志前缀 `[ai-game-creator-shell]`);`build-release.mjs` 的 `runTauriBuild` 与既有 `stageRuntime(target)` 并列调用 `stageBundledResources(target)`,`tauri build --no-bundle` 仍不强制 staging。两处都保留依赖注入,便于入口测试断言调用顺序与 no-bundle 行为。 +- 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。 +- 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。 +- 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 +- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 9 passed;`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 +- 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a7e6b7763..730f133a0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,6 +12,12 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 +## 2026-09-27 随包资源的写入方按产物来源分界:源码派生归准备步骤,构建期产物仍归构建脚本 + +- **现象**:改造后看到 `resources/plugins` 下 `dotnet/publish/win-x64`、`native/gdextension`、`native/payload` 由构建过程补写,不是回归:这三处只有构建过程才产得出来(Unity helper 的 dotnet publish、Godot gdextension、Cocos cdylib),声明里对应 `origin: build` 或 `libraryStaging`。 +- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`,准备步骤负责);需要外部工具链或同一次 cargo 构建产出的,留在构建脚本并在声明里标注,不要塞进准备步骤(它在 `tauri build` 之前运行,拿不到那些产物)。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`origin: build` 只查存在性。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 + ## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 - **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 72f9f4c53..96794f88d 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -158,6 +158,18 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 +### 4.9 M2 实况:构建期写入边界 + +入口接线后,「谁写随包资源」按**产物来源**分界(声明里的 `origin` 字段表达同一口径): + +| 来源 | 例子 | 谁写 | 时机 | +| --- | --- | --- | --- | +| `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 | +| `build`:只有构建过程才产出 | 插件工作区的 `dotnet/publish/win-x64`(Unity helper 发布物) | 构建脚本 | 产物生成之后、只读校验之前 | +| 外部工具链产物 | `native/gdextension`(Godot)、Cocos payload | 构建脚本 | 同上(本里程碑不动,归位属 M3) | + +因此本里程碑后:Codex 与插件工作区的源码派生内容不再由构建脚本写入(macOS 上已实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒全程 fresh);Windows 上仍有三处构建期写入落在 `resources/plugins/**`(Unity publish 目录、Godot gdextension、Cocos payload),Windows 的构建新鲜度要等 M3 把这三处也归位到准备步骤(准备步骤先跑各自的构建命令,再复制产物)才达标。 + ## 5. 兼容与迁移 1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ca2b87150..df0b3b975 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,6 +88,8 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,`tauri build --no-bundle` 不强制 staging。构建脚本不再生成这些资源(只对既有产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验),因此源码不变时 `cargo build` 稳定 fresh。`resources/plugins` 由准备步骤拥有:不要手工往它下面放东西,准备步骤会按仓库 `plugins/` 与声明重建;**编辑器分支产物**(Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos payload)目前仍由构建脚本在产物生成后写入,归位属后续里程碑。 + ### 本地 Rust 构建缓存与磁盘上限 `server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。 From 33308a93e84fe7271d16f1085fe53d75609f1ba0 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 18:57:54 +0800 Subject: [PATCH 09/26] =?UTF-8?q?=E6=A0=A1=E6=AD=A3=20M2=20=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E5=90=8E=E7=9A=84=E6=96=87=E6=A1=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技术方案修正症状层补丁清单:prune_staging/STAGED_* 只存在于未合入的补丁,主线从未有过 - 技术方案 §5.2/§5.3 记录过渡期已完成、.taurignore staging 条目保留到 M3 的原因,§8 M2 行标注已交付与 Windows 待复验 - M2 里程碑规范按证据勾选 macOS 侧可达的验收项,未验证项保持未勾选 - M1 实施计划标注 codex_package_metadata.rs 已在 M2 退役 --- ...�实施计划】AGC随包资源改由校验器读入-2026-09-26.md | 2 +- ...¨‹碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md | 10 +++++----- .../【技术方案】AGC随包资源staging归位-2026-09-26.md | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md index 8d664dba5..a200761ec 100644 --- a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md +++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md @@ -59,6 +59,6 @@ 准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8): - 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`); -- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`、`build_support/codex_package_metadata.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。 +- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。(上游原生包元数据的期望值后来并入同一份声明;`build_support/codex_package_metadata.rs` 已在 M2 因失去调用方删除。) 理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。 diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index 598b6b7f0..dfe1a529b 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -34,11 +34,11 @@ ## 验收标准 -- [ ] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。 -- [ ] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。 -- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。 -- [ ] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。 -- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。 +- [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。) +- [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。) +- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。) +- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 9 条用例含幂等与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) +- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。) ## 证据要求 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 96794f88d..dcdbb6af8 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -17,7 +17,7 @@ 2. Tauri dev 的文件监听不再因为 staging 变更重启 `cargo run`(macOS 与 Windows 一致,不依赖 `.taurignore` 兜)。 3. staging 与上游版本的绑定可验证:版本、平台、逐文件摘要由校验器 fail closed 检出,不会静默发旧二进制。 4. 打包产物内容与当前口径一致(三份 tauri 配置的 `resources` 映射与包内资源门禁不变)。 -5. 删除症状层补丁(整目录重建的规避、`prune_staging`、`STAGED_*` 幂等判断、`.taurignore` 的 staging 条目)。 +5. 删除症状层补丁(整目录重建的规避、为绕开自触发而加的 `.taurignore` staging 条目)。`prune_staging`、`STAGED_*` 一类命名只出现在未合入的症状层补丁里,主线从未有过,不需要清理。 ### 2.2 非目标 @@ -114,7 +114,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 删除: -1. 五条 staging 的写入逻辑、`prune_staging`、`STAGED_*` 幂等判断、针对大目录的整目录删除。 +1. 五条 staging 的写入逻辑(M2 删除了 codex 与插件工作区的写入分支,仍是构建期产物的三处留在 M3)、针对大目录的整目录重建。 2. 为绕开自触发而加的 `.taurignore` staging 条目与说明(准备步骤在监听启动前完成,不再需要)。 ### 4.5 资源清单(谁生成、谁消费) @@ -173,8 +173,8 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 ## 5. 兼容与迁移 1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。 -2. **过渡期**:改造期间 `resources/**` 仍由 build script 生成(当前主线状态),准备步骤上线后先并存(生成结果与校验一致),再删除 build script 的写入分支——删除与新增不得跨里程碑混在一起。 -3. **本机残留**:改造后 `.taurignore` 的 staging 条目、`prune_staging` 及相关注释一并删除;`resources/**` 仍保持 gitignored。 +2. **过渡期(已完成)**:M1 让准备步骤与构建脚本产物并存并逐文件比对一致,M2 移除了构建脚本里 codex 与插件工作区的写入分支;剩余在构建期写入的三处(Unity publish 目录、Godot gdextension、Cocos payload)随 M3 归位。删除与新增不跨里程碑混在一起。 +3. **本机残留**:M2 已删除 codex 与插件工作区的写入逻辑与整目录重建;`.taurignore` 的 staging 条目及其原因说明保留到 M3——Windows 上仍有三处构建期写入落在 `resources/plugins/**`,去掉忽略会重新引入监听自触发。`resources/**` 仍保持 gitignored。 4. **回滚**:准备步骤与校验器保持独立可关闭(例如校验器只读、不写),回滚只需恢复 build script 的写入分支,不涉及数据迁移。 ## 6. 验收标准与证据 @@ -208,8 +208,8 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 | 里程碑 | 交付 | 停止条件 | |---|---|---| | M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | -| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | macOS 与 Windows 均达到 §6 的前三行判据 | -| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `prune_staging`、`STAGED_*`、`.taurignore` staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿 | +| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿(Windows 真机验收) | 里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 From 96b44d86602541d2e9cd0f17eb6e4cc402e636e9 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 19:01:00 +0800 Subject: [PATCH 10/26] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E6=BC=82=E7=A7=BB=E7=9A=84=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 准备步骤用例新增一条:上游 codex-package.json 的 version/layoutVersion/entrypoint/resourcesDir 任一与声明不一致时必须拒绝且不落产物(共 10 条) - 同步决策日志与 M2 里程碑规范里的用例计数 --- .../prepare-bundled-resources.test.mjs | 43 +++++++++++++++++++ ...‘AGC随包资源生成接入dev与发布入口-2026-09-26.md | 2 +- .../shared-memory/decision-log.md | 2 +- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index b3cf4479b..ef3425e47 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -312,6 +312,49 @@ test('copies only whitelisted plugin subdirectories', () => { } }); +test('fails closed when the upstream package metadata drifts from the declaration', () => { + const fixture = buildFixture(); + try { + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const metadataFile = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`, + ); + const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8')); + assert.equal(metadata.version, declaration.codex.version); + for (const [key, value] of [ + ['version', '0.0.0'], + ['layoutVersion', 2], + ['entrypoint', 'bin/other.exe'], + ['resourcesDir', '../private'], + ]) { + fs.writeFileSync( + metadataFile, + `${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`, + ); + assert.throws( + () => prepare(fixture), + /上游包元数据与声明不一致/, + `${key} 漂移必须被拒绝`, + ); + } + assert.ok( + !fs.existsSync( + path.join( + fixture.destinationRoot, + 'resources/codex/win-x64/manifest.json', + ), + ), + '拒绝时不得留下产物', + ); + } finally { + fixture.cleanup(); + } +}); + test('fails closed when the upstream package is missing', () => { const fixture = buildFixture(); try { diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md index dfe1a529b..9486a877f 100644 --- a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md @@ -37,7 +37,7 @@ - [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。) - [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。) - [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。) -- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 9 条用例含幂等与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) +- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 10 条用例含幂等、上游缺失、上游元数据漂移与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。) - [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。) ## 证据要求 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ab546dd32..bc5846ba7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9596,5 +9596,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。 - 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。 - 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 -- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 9 passed;`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 +- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 - 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 From 0b19df467e4a52d1b379774a8da779b634057352 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 23:01:20 +0800 Subject: [PATCH 11/26] =?UTF-8?q?=E8=A1=A5=E5=85=85=20M3=20=E7=9A=84=20Win?= =?UTF-8?q?dows=20=E4=BE=A7=E9=AA=8C=E6=94=B6=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M3 里程碑规范新增「验证步骤(Windows 侧执行清单)」:准备步骤单跑与幂等、构建期不再写随包资源、构建新鲜度、打包一致性、客户端启动与三类边界负例 - 明确记录方式:命令与输出贴回里程碑或 PR,未通过项回到主规范 §9 --- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 8fcc64b1e..712fff2fa 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -37,6 +37,26 @@ - [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 - [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 +## 验证步骤(Windows 侧执行清单) + +准备:切到本里程碑分支,`npm ci`(需装上 `@openai/codex-win32-x64`),确认 `spacetime --version` 与 `server-rs` 锁定版本一致(仅本地 dev 需要)。 + +1. **准备步骤单独跑(不打包)** + - `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc` + - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展、`agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 全部在位。 + - 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。 +2. **构建期不再写随包资源** + - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 +3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本与只读校验)必须通过。 +5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 +6. **边界(负例)** + - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 + - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 + - `AGC_SKIP_RESOURCE_STAGING=1 cargo build`:只做只读校验;手工改 `resources/**` 一个字节即应失败。 + +记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 + ## 证据要求 - 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。 From faa510e99975ae9b81a2df1f35e78da6b1082b36 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 23:33:48 +0800 Subject: [PATCH 12/26] =?UTF-8?q?AGC=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=88=86=E6=94=AF=E4=BA=A7=E7=89=A9=E5=BD=92=E4=BD=8D=EF=BC=9A?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC=E5=BD=BB=E5=BA=95=E9=80=80?= =?UTF-8?q?=E5=87=BA=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 声明扩展:subdirectories 新增 origin=prepared;libraryStaging 增加 prepare/files;新增 nativePayloads 与 plugins.prepareSteps(程序类型、工作目录、指纹、必需产物) - Godot 随包文件清单改由声明提供,godot_bundle::BUNDLE_FILES 从生成的编译期常量取值,不再各写一份 - 准备步骤按声明运行 powershell.exe -File build.ps1(Unity/Godot,Godot 移除 PSModulePath)与 cargo build -p cocos-editor-bridge --target … --features windows-injection,内容指纹命中且必需产物齐全时零写入,命令执行器可注入以便在 macOS 上覆盖调度与失败关闭 - build.rs 删除 prepare_unity_editor_helper、prepare_godot_editor_extension、stage_cocos_editor_payload、stage_build_generated_plugin_payloads 及辅助函数(415 → 193 行),只留只读校验,并新增已准备产物存在性与 Godot 随包库深度校验;AGC_SKIP_RESOURCE_STAGING 开关随写入分支删除 - 删除两份只含 staging 条目的 .taurignore;发布入口传 profile=release 以定位 Cocos 产物 - 文档:技术方案 §4.9/§8、M3 里程碑(含 Windows 验收清单)、运维文档、决策日志与排障经验同步 - 验证:准备步骤 13 条用例通过(三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等);npm run agc:bundled-resources:check 通过;cargo check --no-default-features 通过且第二次 0.62 秒 fresh - 未验证:Windows 真机(powershell/cargo 路径、产物归位、包内容一致性与客户端加载),按 M3 里程碑验收清单在 Windows 上确认 --- apps/ai-game-creator-shell/.taurignore | 4 - .../scripts/build-release.mjs | 1 + .../scripts/check-package-layout.mjs | 18 +- .../scripts/prepare-bundled-resources.mjs | 352 +++++++++++++++++- .../prepare-bundled-resources.test.mjs | 126 +++++++ .../src-tauri/.taurignore | 4 - apps/ai-game-creator-shell/src-tauri/build.rs | 344 +++-------------- .../src-tauri/build_support/godot_bundle.rs | 12 +- .../build_support/package-layout.generated.rs | 4 +- .../build_support/package-layout.json | 67 +++- .../src-tauri/build_support/package_layout.rs | 14 +- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 19 +- .../shared-memory/decision-log.md | 10 + docs/project-memory/shared-memory/pitfalls.md | 8 +- ...术方案】AGC随包资源staging归位-2026-09-26.md | 13 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- 16 files changed, 664 insertions(+), 336 deletions(-) delete mode 100644 apps/ai-game-creator-shell/.taurignore delete mode 100644 apps/ai-game-creator-shell/src-tauri/.taurignore diff --git a/apps/ai-game-creator-shell/.taurignore b/apps/ai-game-creator-shell/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e6ddb30cd..a10f95ee8 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -440,6 +440,7 @@ export function stageBundledResources( const summaries = prepare({ target, features: new Set(defaultEditorFeatures(target)), + profile: 'release', log: (line) => console.log(`[ai-game-creator-shell] ${line}`), }); for (const summary of summaries) { diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 023f19ae4..617257d34 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -81,11 +81,11 @@ function expectInteger(value, at) { return value; } -// 随包子目录来源:`source` 由声明与仓库源码就能生成(准备步骤负责), -// `build` 由构建期工具链产出(构建脚本在产物生成后负责)。 +// 随包子目录来源:`source` 直接来自仓库源码(准备步骤复制), +// `prepared` 需要先由准备步骤运行声明的构建命令产出,再复制进随包目录。 function expectOrigin(value, at) { - if (value !== 'source' && value !== 'build') { - fail(`${at} 必须是 source 或 build(实际 ${String(value)})`); + if (value !== 'source' && value !== 'prepared') { + fail(`${at} 必须是 source 或 prepared(实际 ${String(value)})`); } return value; } @@ -227,15 +227,21 @@ function parseDeclaration(raw) { ).map((entry, index) => { const at = `plugins.libraryStaging[${index}]`; const parsed = expectObject(entry, at); + const files = expectStringArray(parsed.files, `${at}.files`); + if (files.length === 0) { + fail(`${at}.files 不能为空`); + } return { plugin: expectString(parsed.plugin, `${at}.plugin`), sourceSubdirectory: expectString( parsed.sourceSubdirectory, `${at}.sourceSubdirectory`, ), + prepare: expectString(parsed.prepare, `${at}.prepare`), targets: expectStringArray(parsed.targets, `${at}.targets`), features: expectStringArray(parsed.features, `${at}.features`), layout: expectString(parsed.layout, `${at}.layout`), + files, }; }); @@ -445,6 +451,10 @@ pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; +pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings( + declaration.plugins.libraryStaging[0]?.files ?? [], + )}; + pub const CODEX: Codex = ${codexStruct}; pub const PLUGINS: Plugins = ${pluginsStruct}; diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index b5538138f..577e638c1 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -11,6 +11,7 @@ // 用法(在 apps/ai-game-creator-shell 下): // node scripts/prepare-bundled-resources.mjs [--target ] [--dry-run] +import { spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, @@ -700,9 +701,6 @@ function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { - if (subdirectory.origin !== 'source') { - continue; - } if (!subdirectoryEnabled(subdirectory, target, features)) { continue; } @@ -743,13 +741,330 @@ function assertOwnedPluginRoot(destination, plugins) { } } +/// 声明的准备步骤:需要外部工具链或同一次 cargo 构建才能产出的随包内容。 +function prepareStepsReferencedBy(declaration, target, features) { + const required = new Set(); + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin === 'prepared' && + subdirectoryEnabled(subdirectory, target, features) + ) { + required.add(subdirectory.prepare); + } + } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if (libraryStagingEnabled(staging, target, features)) { + required.add(staging.prepare); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (nativePayloadEnabled(payload, target, features)) { + required.add(payload.prepare); + } + } + return required; +} + +function libraryStagingEnabled(staging, target, features) { + return ( + (!staging.targets?.length || staging.targets.includes(target)) && + (staging.features ?? []).every((name) => features.has(name)) + ); +} + +function nativePayloadEnabled(payload, target, features) { + return ( + (!payload.targets?.length || payload.targets.includes(target)) && + (payload.features ?? []).every((name) => features.has(name)) + ); +} + +/// 指纹覆盖声明的根目录(跳过排除目录),与构建脚本原先的规则一致:按相对路径排序后逐文件哈希。 +function fingerprintSources(repoRoot, step) { + const { roots, excludeDirectoryNames } = step.fingerprint; + const lines = []; + for (const root of roots) { + const absoluteRoot = path.join(repoRoot, step.workingDirectory, root); + for (const relative of collectFingerprintFiles( + absoluteRoot, + excludeDirectoryNames, + )) { + const file = path.join(absoluteRoot, relative); + const info = statSync(file); + lines.push(`${relative}\u0000${info.size}\u0000${sha256File(file)}`); + } + } + return sha256Text(lines.join('\n')); +} + +function collectFingerprintFiles(root, excludeDirectoryNames, prefix = '') { + const files = []; + if (!existsSync(root)) { + return files; + } + for (const entry of readdirSync(root, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + fail(`准备步骤源码不允许符号链接:${path.join(root, entry.name)}`); + } + if (entry.isDirectory()) { + if (!excludeDirectoryNames.includes(entry.name)) { + files.push( + ...collectFingerprintFiles( + path.join(root, entry.name), + excludeDirectoryNames, + relative, + ), + ); + } + } else if (entry.isFile()) { + files.push(relative); + } + } + return files.sort(); +} + +function requiredOutputsPresent(repoRoot, step) { + return step.requiredOutputs.every((relative) => { + const file = path.join(repoRoot, relative); + return ( + existsSync(file) && statSync(file).isFile() && statSync(file).size > 0 + ); + }); +} + +function defaultRunCommand({ program, args, cwd, removeEnvironment }) { + const environment = { ...process.env }; + for (const name of removeEnvironment ?? []) { + delete environment[name]; + } + const result = spawnSync(program, args, { + cwd, + env: environment, + stdio: 'inherit', + }); + if (result.error) { + throw new PrepareError(`执行 ${program} 失败:${result.error.message}`); + } + if (result.status !== 0) { + throw new PrepareError(`${program} 退出码 ${result.status}`); + } +} + +/// 按声明产出「已准备」随包内容;命中指纹且必需产物齐全时零写入。 +export function ensurePreparedArtifacts({ + declaration, + repoRoot, + target, + features, + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, + dryRun = false, +}) { + const required = prepareStepsReferencedBy(declaration, target, features); + const steps = new Map( + (declaration.plugins.prepareSteps ?? []).map((step) => [step.name, step]), + ); + for (const name of required) { + const step = steps.get(name); + if (!step) { + fail(`声明引用了未定义的准备步骤:${name}`); + } + const stampPath = step.fingerprint + ? path.join( + repoRoot, + step.workingDirectory, + step.fingerprint.stampRelativePath, + ) + : undefined; + if (step.fingerprint && step.requiredOutputs.length > 0) { + const expected = fingerprintSources(repoRoot, step); + const current = existsSync(stampPath) + ? readFileSync(stampPath, 'utf8').trim() + : ''; + if (current === expected && requiredOutputsPresent(repoRoot, step)) { + log(`${name} 命中缓存(指纹一致)`); + continue; + } + } + if (dryRun) { + log(`${name} 需要重新构建(dry-run 未执行)`); + continue; + } + if (step.kind === 'powershell') { + runCommand({ + program: 'powershell.exe', + args: [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(repoRoot, step.workingDirectory, step.scriptFileName), + ], + cwd: path.join(repoRoot, step.workingDirectory), + removeEnvironment: step.removeEnvironment ?? [], + }); + } else if (step.kind === 'cargo') { + runCommand({ + program: 'cargo', + args: [ + 'build', + '--manifest-path', + path.join( + repoRoot, + 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ), + '-p', + path.basename(step.packageDirectory), + '--target', + target, + ...(profile === 'release' ? ['--release'] : []), + ...(step.features?.length + ? ['--features', step.features.join(',')] + : []), + ], + cwd: repoRoot, + removeEnvironment: [], + }); + } else { + fail(`未实现的准备步骤类型:${step.kind}`); + } + if (!requiredOutputsPresent(repoRoot, step)) { + const missing = step.requiredOutputs.filter( + (relative) => !existsSync(path.join(repoRoot, relative)), + ); + fail(`${name} 未产出必需文件:${missing.join('、') || '(未知)'}`); + } + if (stampPath && step.fingerprint) { + mkdirSync(path.dirname(stampPath), { recursive: true }); + writeFileSync(stampPath, `${fingerprintSources(repoRoot, step)}\n`); + } + log(`${name} 已构建`); + } +} + +/// 复制声明为已准备的随包子目录与随包库。 +function copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, +}) { + for (const plugin of pluginDirectories(declaration, repoRoot)) { + for (const subdirectory of declaration.plugins.subdirectories) { + if ( + subdirectory.origin !== 'prepared' || + !subdirectoryEnabled(subdirectory, target, features) + ) { + continue; + } + copyPluginSubdirectory( + declaration, + path.join(plugin.root, subdirectory.path), + path.join(staging, plugin.name, subdirectory.path), + ); + } + for (const stagingEntry of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== stagingEntry.plugin || + !libraryStagingEnabled(stagingEntry, target, features) + ) { + continue; + } + for (const relative of stagingEntry.files) { + copyFilePreservingMode( + path.join(plugin.root, stagingEntry.sourceSubdirectory, relative), + path.join( + staging, + plugin.name, + stagingEntry.sourceSubdirectory, + relative, + ), + ); + } + } + } +} + +/// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。 +function copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot, + staging, + target, + features, + profile, +}) { + for (const payload of declaration.plugins.nativePayloads ?? []) { + if (!nativePayloadEnabled(payload, target, features)) { + continue; + } + const candidates = [ + path.join( + srcTauriRoot, + 'target', + target, + profile, + 'deps', + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + target, + profile, + payload.sourceFileName, + ), + path.join( + srcTauriRoot, + 'target', + profile, + 'deps', + payload.sourceFileName, + ), + path.join(srcTauriRoot, 'target', profile, payload.sourceFileName), + ]; + const source = candidates.find((candidate) => existsSync(candidate)); + if (!source) { + fail( + `Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`, + ); + } + copyFilePreservingMode( + source, + path.join( + repoRoot, + 'plugins', + payload.plugin, + payload.destinationSubdirectory, + payload.sourceFileName, + ), + ); + copyFilePreservingMode( + source, + path.join( + staging, + payload.plugin, + payload.destinationSubdirectory, + payload.sourceFileName, + ), + ); + } +} + function preparePlugins({ declaration, target, destinationRoot, + repoRoot, features, plugins, record, + profile, dryRun, }) { const destination = path.join( @@ -799,6 +1114,22 @@ function preparePlugins({ ); } } + copyPreparedPayloads({ + declaration, + repoRoot, + staging, + target, + features, + }); + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging, + target, + features, + profile, + }); }); return { summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, @@ -814,6 +1145,9 @@ export function prepareBundledResources({ features = new Set(defaultEditorFeatures(target)), recordPath = RECORD_PATH, lockfilePath = path.join(REPO_ROOT, 'package-lock.json'), + profile = 'debug', + runCommand = defaultRunCommand, + log = () => {}, dryRun = false, repoRoot = REPO_ROOT, appRoot = path.dirname(destinationRoot), @@ -844,13 +1178,25 @@ export function prepareBundledResources({ target.includes(needle), ) ) { + ensurePreparedArtifacts({ + declaration, + repoRoot, + target, + features, + profile, + runCommand, + dryRun, + log, + }); const plugins = preparePlugins({ declaration, target, destinationRoot, + repoRoot, features, plugins: pluginDirectories(declaration, repoRoot), record, + profile, dryRun, }); summaries.push(plugins.summary); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index ef3425e47..19b90da94 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -23,6 +23,33 @@ function sha256File(file) { } /// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。 +/// 造出声明里所有「已准备」产物:真实构建要 Windows 工具链,用例只需要文件在位。 +function writePrepareArtifacts(root, declaration) { + const created = []; + for (const step of declaration.plugins.prepareSteps ?? []) { + for (const relative of step.requiredOutputs) { + const file = path.join(root, relative); + if (fs.existsSync(file)) { + continue; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `prepared ${relative}\n`); + created.push(relative); + } + } + return created; +} + +/// 记录调用的假执行器:默认把声明的必需产物造出来。 +function fakeRunCommand({ created = [], failOn = null } = {}) { + return (invocation) => { + created.push(`${invocation.program} ${(invocation.args ?? []).join(' ')}`); + if (failOn && invocation.program === failOn) { + throw new Error('fake run failure'); + } + }; +} + function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-')); const appRoot = path.join(root, 'app'); @@ -114,6 +141,18 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); } + writePrepareArtifacts(repoRoot, declaration); + const cocosDll = path.join( + destinationRoot, + 'target', + WINDOWS_TARGET, + 'debug', + 'deps', + 'cocos-editor-bridge.dll', + ); + fs.mkdirSync(path.dirname(cocosDll), { recursive: true }); + fs.writeFileSync(cocosDll, 'cocos bridge payload\n'); + const lockfilePath = path.join(root, 'package-lock.json'); fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2)); return { @@ -156,6 +195,7 @@ function snapshot(directory) { } function prepare(fixture, overrides = {}) { + const runner = overrides.runCommand ?? fakeRunCommand(); return prepareBundledResources({ target: WINDOWS_TARGET, destinationRoot: fixture.destinationRoot, @@ -164,6 +204,7 @@ function prepare(fixture, overrides = {}) { lockfilePath: fixture.lockfilePath, repoRoot: fixture.repoRoot, appRoot: fixture.appRoot, + runCommand: runner, ...overrides, }); } @@ -458,3 +499,88 @@ test('declaration drives source lookup and staging units', () => { fixture.cleanup(); } }); + +test('runs declared prepare steps once and copies their artifacts into the staged tree', () => { + const fixture = buildFixture(); + try { + const created = []; + const summaries = prepare(fixture, { + runCommand: fakeRunCommand({ created }), + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); + assert.match(summaries[0], /命中缓存|重新生成/); + assert.ok( + created.some( + (entry) => + entry.startsWith('powershell.exe') && entry.includes('build.ps1'), + ), + '必须执行声明的 powershell 准备步骤', + ); + assert.ok( + created.some( + (entry) => + entry.startsWith('cargo build') && entry.includes('--target'), + ), + '必须执行声明的 cargo 准备步骤', + ); + const stagedPayload = path.join( + fixture.destinationRoot, + 'resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ); + assert.ok(fs.existsSync(stagedPayload), 'native payload 必须进随包目录'); + assert.ok( + fs.existsSync( + path.join( + fixture.repoRoot, + 'plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + ), + ), + 'native payload 必须写回插件工作区(唯一真源)', + ); + } finally { + fixture.cleanup(); + } +}); + +test('skips prepare steps whose fingerprint is unchanged', () => { + const fixture = buildFixture(); + try { + const first = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: first }) }); + assert.ok(first.length > 0, '首次必须执行准备步骤'); + const second = []; + prepare(fixture, { runCommand: fakeRunCommand({ created: second }) }); + const unityRuns = second.filter( + (entry) => + entry.startsWith('powershell.exe') && + entry.includes('agc-unity-editor'), + ); + assert.deepEqual( + unityRuns, + [], + '指纹一致时不应再跑声明了指纹的 Unity 准备步骤', + ); + } finally { + fixture.cleanup(); + } +}); + +test('fails closed when a prepare step does not produce its declared outputs', () => { + const fixture = buildFixture(); + try { + fs.rmSync(path.join(fixture.repoRoot, 'plugins/agc-unity-editor'), { + recursive: true, + force: true, + }); + assert.throws( + () => prepare(fixture, { runCommand: fakeRunCommand() }), + /未产出必需文件/, + ); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/.taurignore b/apps/ai-game-creator-shell/src-tauri/.taurignore deleted file mode 100644 index 8ec126556..000000000 --- a/apps/ai-game-creator-shell/src-tauri/.taurignore +++ /dev/null @@ -1,4 +0,0 @@ -# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。 -# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发 -# “构建 -> 监听 -> 再构建”的自触发循环。 -resources/plugins/ diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 948438b62..3138d2279 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -10,7 +10,6 @@ mod godot_bundle; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; -use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; @@ -76,9 +75,65 @@ fn validate_staged_resources(manifest_dir: &std::path::Path) { ); } validate_staged_plugin_workspace(manifest_dir, &target); + validate_prepared_payloads(manifest_dir, &target); } /// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。 +/// 已准备产物与随包库在本机无法重建,构建期至少要确认它们已经就位。 +fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) { + if !package_layout::plugin_staging_applies(target) { + return; + } + let declared = package_layout::plugins(); + let repo_root = manifest_dir + .parent() + .and_then(|app_root| app_root.parent()) + .and_then(|apps_dir| apps_dir.parent()) + .expect("AGC 应用必须位于仓库 apps 目录下"); + let destination_root = manifest_dir.join(declared.destination_directory); + for plugin in package_layout::plugin_directories( + &repo_root.join(declared.source_directory), + declared.manifest_file_name, + ) + .unwrap_or_else(|error| panic!("{error}")) + { + for subdirectory in declared.subdirectories { + if !package_layout::subdirectory_is_prepared(subdirectory) + || !package_layout::subdirectory_enabled( + subdirectory, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(subdirectory.path); + let staged = destination_root.join(&plugin.name).join(&relative); + if !staged.is_dir() { + panic!( + "随包已准备产物缺失:{}(请先执行随包资源准备步骤)", + staged.display() + ); + } + } + for staging in declared.library_staging { + if plugin.name != staging.plugin + || !package_layout::library_staging_enabled( + staging, + target, + package_layout::cargo_feature_enabled, + ) + { + continue; + } + let relative = package_layout::declared_relative_path(staging.source_subdirectory); + let staged = destination_root.join(&plugin.name).join(&relative); + godot_bundle::validate(&staged) + .unwrap_or_else(|error| panic!("Godot 随包资源校验失败:{error}")); + } + } +} + fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) { let declared = package_layout::plugins(); let repo_root = manifest_dir @@ -104,14 +159,6 @@ fn main() { "cargo:rustc-env=AGC_BUILD_TARGET={}", env::var("TARGET").expect("Cargo TARGET") ); - // AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成), - // 用于在既有产物上单独验证校验路径。 - if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() { - prepare_unity_editor_helper(&manifest_dir); - prepare_godot_editor_extension(&manifest_dir); - stage_build_generated_plugin_payloads(&manifest_dir); - stage_cocos_editor_payload(&manifest_dir); - } validate_staged_resources(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); @@ -134,282 +181,3 @@ fn main() { } tauri_build::build() } - -#[cfg(windows)] -fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) { - if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() { - return; - } - let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); - let profile_dir = out_dir - .ancestors() - .find(|path| path.file_name().is_some_and(|name| name == "build")) - .and_then(|build_dir| build_dir.parent()) - .expect("AGC Cargo profile directory not found"); - let candidates = [ - profile_dir.join("deps/cocos_editor_bridge.dll"), - profile_dir.join("cocos_editor_bridge.dll"), - ]; - let source = candidates - .iter() - .find(|path| path.is_file()) - .unwrap_or_else(|| { - panic!( - "Cocos bridge native payload 未构建:{}", - candidates - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(";") - ) - }); - for destination in [ - // 插件工作区里的 payload 是开发态与打包态的唯一真源。 - manifest_dir - .join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - // 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。 - manifest_dir - .join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"), - ] { - std::fs::create_dir_all(destination.parent().expect("payload resource parent")) - .expect("创建 Cocos bridge payload 目录失败"); - std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败"); - } - println!("cargo:rerun-if-changed={}", source.display()); -} - -#[cfg(not(windows))] -fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} - -/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。 -fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE"); - let target = env::var("TARGET").expect("Cargo TARGET"); - if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none() - || target != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet"); - let mut sources = Vec::new(); - collect_unity_helper_sources(&root, &mut sources); - sources.sort(); - let mut fingerprint = Sha256::new(); - for source in &sources { - println!("cargo:rerun-if-changed={}", source.display()); - fingerprint.update( - source - .strip_prefix(&root) - .expect("helper source") - .to_string_lossy() - .as_bytes(), - ); - fingerprint.update([0]); - fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败")); - } - let fingerprint = format!("{:x}", fingerprint.finalize()); - let publish = root.join("publish/win-x64"); - let executable = publish.join("Agc.Unity.Attach.exe"); - let stamp = publish.join(".agc-source.sha256"); - println!("cargo:rerun-if-changed={}", executable.display()); - if unity_helper_publish_complete(&publish) - && fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint) - { - return; - } - assert!( - cfg!(windows), - "构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链" - ); - let status = std::process::Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Unity helper 构建脚本"); - assert!( - status.success() && unity_helper_publish_complete(&publish), - "Unity helper 构建失败或缺少运行文件/许可" - ); - fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败"); -} - -fn unity_helper_publish_complete(publish: &std::path::Path) -> bool { - [ - "Agc.Unity.Attach.exe", - "NOTICE", - "THIRD-PARTY-NOTICES.txt", - "licenses/DotCraft-Apache-2.0.txt", - "licenses/Roslyn-MIT.txt", - "licenses/upstream.json", - "licenses/dotnet-LICENSE.TXT", - "licenses/dotnet-THIRD-PARTY-NOTICES.TXT", - "licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", - "licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf", - ] - .iter() - .all(|name| { - fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| { - metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0 - }) - }) -} - -fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec) { - for entry in fs::read_dir(root) - .expect("Unity helper 源码目录缺失") - .flatten() - { - let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败"); - assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接"); - let name = entry.file_name(); - if kind.is_dir() { - if !matches!( - name.to_str(), - Some("bin" | "obj" | "publish" | "native-build") - ) { - collect_unity_helper_sources(&entry.path(), sources); - } - } else if kind.is_file() { - sources.push(entry.path()); - } - } -} - -fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) { - println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE"); - if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none() - || env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc" - { - return; - } - let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension"); - for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) { - println!("cargo:rerun-if-changed={}", source.display()); - } - assert!( - cfg!(windows), - "构建 Godot 原生扩展需要 Windows x64 C 编译器" - ); - let status = std::process::Command::new("powershell.exe") - // Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。 - .env_remove("PSModulePath") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(root.join("build.ps1")) - .current_dir(&root) - .status() - .expect("无法启动 Godot 原生扩展构建脚本"); - assert!(status.success(), "Godot 原生扩展构建失败"); - godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}")); -} - -/// 构建期产物归位:只有构建过程才产出、因而无法由准备步骤生成的随包子目录。 -/// -/// 源码派生的子目录由准备步骤在 `tauri dev|build` 之前写入;这里只补构建期才存在的产物。 -/// 把这批产物也归位到准备步骤(连同编辑器分支产物)在后续里程碑完成。 -fn stage_build_generated_plugin_payloads(manifest_dir: &std::path::Path) { - let target = env::var("TARGET").expect("Cargo TARGET"); - if !package_layout::plugin_staging_applies(&target) { - return; - } - let declared = package_layout::plugins(); - let repo_root = manifest_dir - .parent() - .and_then(|app_root| app_root.parent()) - .and_then(|apps_dir| apps_dir.parent()) - .expect("AGC 应用必须位于仓库 apps 目录下"); - let destination_root = manifest_dir.join(declared.destination_directory); - let plugins = package_layout::plugin_directories( - &repo_root.join(declared.source_directory), - declared.manifest_file_name, - ) - .unwrap_or_else(|error| panic!("{error}")); - for plugin in plugins { - for subdirectory in declared.subdirectories { - if !package_layout::subdirectory_is_build_derived(subdirectory) - || !package_layout::subdirectory_enabled( - subdirectory, - &target, - package_layout::cargo_feature_enabled, - ) - { - continue; - } - let relative = package_layout::declared_relative_path(subdirectory.path); - let source = plugin.path.join(&relative); - if !source.is_dir() { - continue; - } - copy_staged_tree( - &source, - &destination_root.join(&plugin.name).join(&relative), - ); - } - for staging in declared.library_staging { - if plugin.name != staging.plugin - || !package_layout::library_staging_enabled( - staging, - &target, - package_layout::cargo_feature_enabled, - ) - { - continue; - } - let relative = package_layout::declared_relative_path(staging.source_subdirectory); - match staging.layout { - "godot-bundle" => godot_bundle::stage( - &plugin.path.join(&relative), - &destination_root.join(&plugin.name).join(&relative), - &target, - true, - ) - .unwrap_or_else(|error| panic!("{error}")), - other => panic!("未实现的随包库 staging 布局:{other}"), - } - } - } -} - -/// 复制一棵目录树(按声明跳过构建产物与测试文件);内容一致时不重写。 -fn copy_staged_tree(source: &std::path::Path, destination: &std::path::Path) { - if !source.is_dir() { - return; - } - fs::create_dir_all(destination).expect("创建插件资源目录失败"); - for entry in fs::read_dir(source).expect("读取插件资源失败").flatten() { - let path = entry.path(); - let file_name = entry.file_name(); - let name = file_name.to_string_lossy().to_string(); - let target = destination.join(&file_name); - assert!( - entry - .file_type() - .expect("读取插件文件类型失败") - .is_symlink(), - "插件资源不允许符号链接" - ); - if path.is_dir() { - if !package_layout::skip_directory(&name) { - copy_staged_tree(&path, &target); - } - } else if !package_layout::skip_file_name(&name) { - let bytes = fs::read(&path).expect("读取插件资源失败"); - if fs::read(&target).is_ok_and(|existing| existing == bytes) { - continue; - } - fs::write(&target, bytes).expect("复制插件资源失败"); - } - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 8a326ac98..616ad5b30 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -2,12 +2,12 @@ use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; -pub const BUNDLE_FILES: [&str; 4] = [ - "bin/win-x64/agc_godot_editor.dll", - "bin/win-x64/metadata.json", - "vendor/LICENSE.txt", - "vendor/provenance.json", -]; +// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。 +#[allow(dead_code)] +#[path = "package_layout.rs"] +mod package_layout; + +pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES; fn plain_metadata(path: &Path) -> Result { let metadata = fs::symlink_metadata(path) diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 3ae75c1cb..88247e41d 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -11,6 +11,8 @@ pub const CODEX_VERSION: &str = "0.155.1"; pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1"; pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2"; +pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"]; + pub const CODEX: Codex = Codex { package_metadata: PackageMetadata { layout_version: 1, @@ -103,7 +105,7 @@ Subdirectory { }, Subdirectory { path: "dotnet/publish/win-x64", - origin: "build", + origin: "prepared", target_contains: &[], targets: &["x86_64-pc-windows-msvc"], features: &["unity-editor-execute"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 56fb12ed2..8164358ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -94,7 +94,8 @@ { "path": "native/payload", "origin": "source", "targetContains": ["windows"] }, { "path": "dotnet/publish/win-x64", - "origin": "build", + "origin": "prepared", + "prepare": "unity-helper-publish", "targets": ["x86_64-pc-windows-msvc"], "features": ["unity-editor-execute"] } @@ -103,9 +104,71 @@ { "plugin": "agc-godot-editor", "sourceSubdirectory": "native/gdextension", + "prepare": "godot-extension-build", "targets": ["x86_64-pc-windows-msvc"], "features": ["godot-editor-execute"], - "layout": "godot-bundle" + "layout": "godot-bundle", + "files": [ + "bin/win-x64/agc_godot_editor.dll", + "bin/win-x64/metadata.json", + "vendor/LICENSE.txt", + "vendor/provenance.json" + ] + } + ], + "nativePayloads": [ + { + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build", + "sourceFileName": "cocos-editor-bridge.dll", + "destinationSubdirectory": "native/payload", + "targets": ["x86_64-pc-windows-msvc"], + "features": ["cocos-editor-injection"] + } + ], + "prepareSteps": [ + { + "name": "unity-helper-publish", + "kind": "powershell", + "workingDirectory": "plugins/agc-unity-editor/dotnet", + "scriptFileName": "build.ps1", + "fingerprint": { + "roots": ["."], + "excludeDirectoryNames": ["bin", "obj", "publish", "native-build"], + "stampRelativePath": "publish/win-x64/.agc-source.sha256" + }, + "requiredOutputs": [ + "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe", + "plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE", + "plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf", + "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf" + ] + }, + { + "name": "godot-extension-build", + "kind": "powershell", + "workingDirectory": "plugins/agc-godot-editor/native/gdextension", + "scriptFileName": "build.ps1", + "removeEnvironment": ["PSModulePath"], + "requiredOutputs": [ + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll", + "plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json", + "plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt", + "plugins/agc-godot-editor/native/gdextension/vendor/provenance.json" + ] + }, + { + "name": "cocos-bridge-build", + "kind": "cargo", + "packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge", + "features": ["windows-injection"], + "requiredOutputs": [] } ], "skipDirectoryNames": ["target", "node_modules"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 9ce439f32..e27c8bedd 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -286,7 +286,7 @@ pub fn validate_staged_plugins( let relative = declared_relative_path(subdirectory.path); let source = plugin.path.join(&relative); let staged_directory = staged.join(&relative); - if subdirectory_is_build_derived(subdirectory) { + if subdirectory_is_prepared(subdirectory) { if source.exists() && !staged_directory.is_dir() { return Err(format!( "随包构建期产物缺失:{}(插件 {})", @@ -330,9 +330,9 @@ pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { subdirectory.origin == "source" } -/// 声明为「由构建期工具链产出」的子目录。 -pub fn subdirectory_is_build_derived(subdirectory: &Subdirectory) -> bool { - subdirectory.origin == "build" +/// 声明为「需要先由准备步骤运行构建命令产出」的子目录。 +pub fn subdirectory_is_prepared(subdirectory: &Subdirectory) -> bool { + subdirectory.origin == "prepared" } /// 仓库插件目录:含清单文件的普通目录,按名字排序。 @@ -853,14 +853,14 @@ mod tests { .filter(|entry| subdirectory_is_source_derived(entry)) .map(|entry| entry.path) .collect::>(); - let build = PLUGINS + let prepared = PLUGINS .subdirectories .iter() - .filter(|entry| subdirectory_is_build_derived(entry)) + .filter(|entry| subdirectory_is_prepared(entry)) .map(|entry| entry.path) .collect::>(); assert_eq!(source, ["src", "panels", "skills", "native/payload"]); - assert_eq!(build, ["dotnet/publish/win-x64"]); + assert_eq!(prepared, ["dotnet/publish/win-x64"]); } #[test] diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 712fff2fa..d9be7289d 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -2,11 +2,19 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | -| Version | 1.0 | -| Status | proposed | -| Date | 2026-09-26 | +| Version | 1.0 | +| Status | in-progress(2026-09-27 实现完成,待 Windows 真机验收) | +| Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | +## 已实现(2026-09-27) + +- 声明新增三类「准备步骤」产物:`subdirectories[origin=prepared]`(Unity publish 目录)、`libraryStaging[].prepare + files`(Godot gdextension)、`nativePayloads[]`(Cocos bridge dll);`plugins.prepareSteps` 描述每个准备步骤的程序、工作目录、指纹与必需产物。 +- 准备步骤(`scripts/prepare-bundled-resources.mjs`)按声明执行 `powershell.exe -File build.ps1` 与 `cargo build -p … --target …`,用内容指纹跳过未变化的步骤,校验必需产物齐全后才复制;命中指纹且产物齐全时零写入。 +- 构建脚本删除了三处产物生成与整棵树复制(`prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数,共减少约 220 行),只保留只读校验:源码派生内容逐文件比对、已准备产物存在性、Godot 随包库沿用既有深度校验(`godot_bundle::validate`)。Godot 随包文件清单改由声明提供(单一来源)。 +- `.taurignore` 的两份 staging 条目已删除(构建期不再写 `resources/plugins`,无需忽略)。 +- 准备步骤的 Windows 侧行为**尚未在真机验证**:本机 macOS 只能跑通编译、声明门禁与用例(命令执行器在用例中注入假实现)。 + ## 目标 编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。 @@ -48,12 +56,13 @@ 2. **构建期不再写随包资源** - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 -4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本与只读校验)必须通过。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本的只读校验)必须通过。 5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 6. **边界(负例)** - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 - - `AGC_SKIP_RESOURCE_STAGING=1 cargo build`:只做只读校验;手工改 `resources/**` 一个字节即应失败。 + - 删掉 `plugins/agc-unity-editor/dotnet/publish/win-x64/.agc-source.sha256` 后重跑准备步骤:预期重新执行 dotnet publish,而不是复用旧产物。 + - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(源码派生内容逐文件比对)。 记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bc5846ba7..707659ae2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9598,3 +9598,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。 - 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。 - 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。 + +## 2026-09-27 AGC 编辑器分支产物归位:构建脚本彻底退出写入 + +- 背景:M2 之后构建脚本仍生成 Unity publish 目录、Godot gdextension 与 Cocos bridge payload,这三处写入落在 `resources/plugins/**`(`bundle.resources` 映射目录),Windows 上仍会触发每次重编,`.taurignore` 的 staging 条目也还不能删。 +- 决策(声明扩展):`subdirectories` 新增 `origin: prepared`;`libraryStaging` 增加 `prepare` 与 `files`;新增 `nativePayloads` 与 `plugins.prepareSteps`(程序类型、工作目录、指纹、必需产物)。Godot 随包文件清单改由声明提供——`godot_bundle::BUNDLE_FILES` 从生成的编译期常量取值,不再各写一份。 +- 决策(准备步骤执行器):准备步骤按声明运行 `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File build.ps1`(Unity/Godot,Godot 额外移除 `PSModulePath`)与 `cargo build -p cocos-editor-bridge --target … --features windows-injection`;内容指纹命中且必需产物齐全时零写入;命令执行器可注入,便于在 macOS 上用假执行器覆盖调度、指纹与失败关闭逻辑。 +- 决策(构建脚本瘦身):删除 `prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数(build.rs 415 → 193 行),只留只读校验,并新增「已准备产物存在性 + Godot 随包库深度校验」;`AGC_SKIP_RESOURCE_STAGING` 开关随写入分支一并删除;两份只含 staging 条目的 `.taurignore` 删除。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs,godot_bundle.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,build-release.mjs}`、两份 `.taurignore`、技术方案 §4.9/§8、M3 里程碑、运维文档、决策日志与排障经验。 +- 验证:准备步骤 13 条用例通过(含三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等与失败关闭);`npm run agc:bundled-resources:check` 通过;`cargo check --no-default-features` 通过(构建脚本仅剩只读校验,且不再出现在随包资源的写入路径上)。 +- 边界(未验证):Windows 真机未验证——powershell/cargo 两条命令路径、Unity/Godot/Cocos 产物归位、包内容一致性与客户端加载,需按 M3 里程碑的验收清单在 Windows 上确认。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 730f133a0..9a319d877 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -12,11 +12,11 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 -## 2026-09-27 随包资源的写入方按产物来源分界:源码派生归准备步骤,构建期产物仍归构建脚本 +## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 -- **现象**:改造后看到 `resources/plugins` 下 `dotnet/publish/win-x64`、`native/gdextension`、`native/payload` 由构建过程补写,不是回归:这三处只有构建过程才产得出来(Unity helper 的 dotnet publish、Godot gdextension、Cocos cdylib),声明里对应 `origin: build` 或 `libraryStaging`。 -- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`,准备步骤负责);需要外部工具链或同一次 cargo 构建产出的,留在构建脚本并在声明里标注,不要塞进准备步骤(它在 `tauri build` 之前运行,拿不到那些产物)。 -- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`origin: build` 只查存在性。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 +- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`prepared` 只查存在性,Godot 随包库额外跑 `godot_bundle::validate` 的深度校验。 +- **准备步骤指纹**:声明了指纹的步骤(Unity)命中后不会重跑工具链,改 `plugins/**` 源码即失效;指纹戳文件(`publish/win-x64/.agc-source.sha256`)删掉只会多跑一次构建。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 ## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index dcdbb6af8..245d7cfce 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -158,17 +158,18 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 -### 4.9 M2 实况:构建期写入边界 +### 4.9 M2/M3 实况:构建期写入边界 -入口接线后,「谁写随包资源」按**产物来源**分界(声明里的 `origin` 字段表达同一口径): +「谁写随包资源」按产物来源分界,声明里的 `origin` 字段表达同一口径: | 来源 | 例子 | 谁写 | 时机 | | --- | --- | --- | --- | | `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 | -| `build`:只有构建过程才产出 | 插件工作区的 `dotnet/publish/win-x64`(Unity helper 发布物) | 构建脚本 | 产物生成之后、只读校验之前 | -| 外部工具链产物 | `native/gdextension`(Godot)、Cocos payload | 构建脚本 | 同上(本里程碑不动,归位属 M3) | +| `prepared` / `libraryStaging` / `nativePayloads`:需要外部工具链或同一次 cargo 构建 | Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos `native/payload` | 准备步骤:先按声明运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …`,再复制产物 | 同上 | -因此本里程碑后:Codex 与插件工作区的源码派生内容不再由构建脚本写入(macOS 上已实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒全程 fresh);Windows 上仍有三处构建期写入落在 `resources/plugins/**`(Unity publish 目录、Godot gdextension、Cocos payload),Windows 的构建新鲜度要等 M3 把这三处也归位到准备步骤(准备步骤先跑各自的构建命令,再复制产物)才达标。 +M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随包资源(连编辑器分支产物一并交给准备步骤),并在校验阶段确认源码派生内容逐文件一致、已准备产物在位、Godot 随包库通过既有深度校验。因此源码不变时 `cargo` 稳定 fresh(macOS 实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒),`resources/plugins` 也不再需要在 `.taurignore` 里忽略(两份 staging 条目已删除)。 + +准备步骤的 Windows 侧命令执行(powershell / cargo)只在本机无法验证,验收清单见 M3 里程碑规范。 ## 5. 兼容与迁移 @@ -209,7 +210,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 |---|---|---| | M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | | M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | -| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 包内容逐项对得上,门禁全绿(Windows 真机验收) | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;**待 Windows 真机按验收清单确认** | 里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index df0b3b975..8ecb6365b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -86,9 +86,9 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- `npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。 -AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验。 +AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 -随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,`tauri build --no-bundle` 不强制 staging。构建脚本不再生成这些资源(只对既有产物做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验),因此源码不变时 `cargo build` 稳定 fresh。`resources/plugins` 由准备步骤拥有:不要手工往它下面放东西,准备步骤会按仓库 `plugins/` 与声明重建;**编辑器分支产物**(Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos payload)目前仍由构建脚本在产物生成后写入,归位属后续里程碑。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 ### 本地 Rust 构建缓存与磁盘上限 From e4634928c662d9d1733c9410c6722ca0b21bacc3 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 00:23:26 +0800 Subject: [PATCH 13/26] =?UTF-8?q?=E6=8C=89=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20M3=EF=BC=9A=E6=A0=A1=E9=AA=8C=E6=8C=89=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=94=B6=E6=95=9B=E3=80=81Cocos=20=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E4=B8=8E=20Godot=20=E6=8C=87=E7=BA=B9=E3=80=81=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E4=B8=8E=E6=96=87=E6=A1=A3=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0:validate_prepared_payloads 遍历所有插件 × 所有子目录 → 与 libraryStaging 分支对称地按插件收敛(声明新增 subdirectories[].plugin,Rust 加 subdirectory_applies_to_plugin,工具复制与指纹同样按插件收敛) - P1 Cocos:cargo 准备步骤改用该 crate 自身 manifest 并把 --target-dir 指回 src-tauri/target(原 -p + --features 被 cargo 以「cannot specify features for packages outside of workspace」拒绝);nativePayloads 拆出 sourceFileName(下划线)与 destinationFileName(连字符);native/payload 改为 origin=prepared 并绑定 cocos-bridge-build - P1 Godot:补 fingerprint(roots ["."],排除 bin/.build/native-build,戳文件 bin/win-x64/.agc-source.sha256),不再每次真跑 build.ps1 - P2:pluginSourceFingerprint 对 prepared 产物改用内容哈希(避免无条件覆盖 mtime 导致每次全量重建)、库文件纳入指纹;pluginTreeMatches 对 prepared 只查存在性(内容由指纹覆盖,顺带缓解 90MB exe 的哈希开销);GODOT_BUNDLE_FILES 缺清单即失败,不再静默退化为空数组;CLI 传 log 并新增 --features;用例正则兼容 Windows 路径分隔符;主规范 §4.8 残留的 AGC_SKIP_RESOURCE_STAGING 已清 - 安全:撤出误提交进 .env.local 的真实 token(还原模板并 force-push 分支尖端) - 验证:npm run agc:bundled-resources:check 通过;cargo fmt + cargo check --no-default-features 通过(含新 plugin 字段与按插件收敛的校验);工具用例 12/13(余 1 条为夹具断言待修) --- .../scripts/check-package-layout.mjs | 16 ++- .../scripts/prepare-bundled-resources.mjs | 50 +++++-- .../prepare-bundled-resources.test.mjs | 7 +- apps/ai-game-creator-shell/src-tauri/build.rs | 1 + .../build_support/package-layout.generated.rs | 7 +- .../build_support/package-layout.json | 126 ++++++++++++++---- .../src-tauri/build_support/package_layout.rs | 7 + ...术方案】AGC随包资源staging归位-2026-09-26.md | 2 +- 8 files changed, 173 insertions(+), 43 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 617257d34..a8eda97e1 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -216,6 +216,10 @@ function parseDeclaration(raw) { return { path: expectString(parsed.path, `${at}.path`), origin: expectOrigin(parsed.origin, `${at}.origin`), + plugin: + parsed.plugin === undefined + ? '' + : expectString(parsed.plugin, `${at}.plugin`), targetContains: optionalStringArray(parsed, 'targetContains', at), targets: optionalStringArray(parsed, 'targets', at), features: optionalStringArray(parsed, 'features', at), @@ -365,6 +369,7 @@ function renderSubdirectory(entry) { 'Subdirectory', [ ['path', rustString(entry.path)], + ['plugin', rustString(entry.plugin ?? '')], ['origin', rustString(entry.origin)], ['target_contains', rustStrings(entry.targetContains)], ['targets', rustStrings(entry.targets)], @@ -390,6 +395,13 @@ function renderLibraryStaging(entry) { function renderGenerated(declaration) { const codex = declaration.codex; + const godotStaging = declaration.plugins.libraryStaging.find( + (entry) => entry.layout === 'godot-bundle', + ); + if (!godotStaging || godotStaging.files.length === 0) { + fail('声明缺少 godot-bundle 随包文件清单,拒绝生成空清单'); + } + const godotFiles = godotStaging.files; const plugins = declaration.plugins; const codexStruct = renderStruct('Codex', [ [ @@ -451,9 +463,7 @@ pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)}; pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)}; pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)}; -pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings( - declaration.plugins.libraryStaging[0]?.files ?? [], - )}; +pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(godotFiles)}; pub const CODEX: Codex = ${codexStruct}; diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 577e638c1..9b6a9508b 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -572,6 +572,10 @@ export function pluginDirectories(declaration, repoRoot) { ); } +function subdirectoryAppliesToPlugin(subdirectory, pluginName) { + return !subdirectory.plugin || subdirectory.plugin === pluginName; +} + function subdirectoryEnabled(subdirectory, target, features) { const matchesContains = !subdirectory.targetContains?.length || @@ -663,10 +667,14 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { } const root = path.join(plugin.root, subdirectory.path); for (const file of walkFiles(declaration, root)) { - const info = statSync(path.join(root, file)); - lines.push( - `${plugin.name}/${subdirectory.path}/${file}:${info.size}:${Math.round(info.mtimeMs)}`, - ); + const absolute = path.join(root, file); + const info = statSync(absolute); + // prepared 产物由准备步骤无条件复制,mtime 会变;用内容哈希保证幂等判断稳定。 + const stamp = + subdirectory.origin === 'prepared' + ? `sha256:${sha256File(absolute)}` + : `${info.size}:${Math.round(info.mtimeMs)}`; + lines.push(`${plugin.name}/${subdirectory.path}/${file}:${stamp}`); } } const manifest = path.join( @@ -701,10 +709,22 @@ function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { - if (!subdirectoryEnabled(subdirectory, target, features)) { + if ( + subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + subdirectoryEnabled(subdirectory, target, features) + ) { continue; } const sourceRoot = path.join(plugin.root, subdirectory.path); + if (subdirectory.origin !== 'source') { + if ( + existsSync(sourceRoot) && + !existsSync(path.join(pluginDestination, subdirectory.path)) + ) { + return false; + } + continue; + } for (const relative of walkFiles(declaration, sourceRoot)) { const source = path.join(sourceRoot, relative); const staged = path.join( @@ -855,6 +875,7 @@ function defaultRunCommand({ program, args, cwd, removeEnvironment }) { export function ensurePreparedArtifacts({ declaration, repoRoot, + srcTauriRoot, target, features, profile = 'debug', @@ -912,12 +933,9 @@ export function ensurePreparedArtifacts({ args: [ 'build', '--manifest-path', - path.join( - repoRoot, - 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', - ), - '-p', - path.basename(step.packageDirectory), + path.join(repoRoot, step.packageDirectory, 'Cargo.toml'), + '--target-dir', + path.join(srcTauriRoot, 'target'), '--target', target, ...(profile === 'release' ? ['--release'] : []), @@ -1181,6 +1199,7 @@ export function prepareBundledResources({ ensurePreparedArtifacts({ declaration, repoRoot, + srcTauriRoot: destinationRoot, target, features, profile, @@ -1225,6 +1244,13 @@ function parseArguments(argv) { index += 1; } else if (value === '--dry-run') { args.dryRun = true; + } else if (value === '--features') { + args.features = new Set( + String(argv[index + 1] ?? '') + .split(',') + .filter(Boolean), + ); + index += 1; } else { fail(`未知参数:${value}`); } @@ -1237,7 +1263,9 @@ function main(argv) { const summaries = prepareBundledResources({ target: args.target ?? resolveHostTarget(), destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR, + ...(args.features ? { features: args.features } : {}), dryRun: args.dryRun, + log: (line) => console.log(`[agc-resources] ${line}`), }); for (const summary of summaries) { console.log(`[agc-resources] ${summary}`); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 19b90da94..2bb540151 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -148,7 +148,7 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { WINDOWS_TARGET, 'debug', 'deps', - 'cocos-editor-bridge.dll', + 'cocos_editor_bridge.dll', ); fs.mkdirSync(path.dirname(cocosDll), { recursive: true }); fs.writeFileSync(cocosDll, 'cocos bridge payload\n'); @@ -493,7 +493,10 @@ test('declaration drives source lookup and staging units', () => { app: fixture.appRoot, repo: fixture.repoRoot, }); - assert.match(source, /codex-win32-x64\/vendor\/x86_64-pc-windows-msvc$/); + assert.match( + source, + /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, + ); assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1); } finally { fixture.cleanup(); diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 3138d2279..0e98da9a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -99,6 +99,7 @@ fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) { { for subdirectory in declared.subdirectories { if !package_layout::subdirectory_is_prepared(subdirectory) + || !package_layout::subdirectory_applies_to_plugin(subdirectory, &plugin.name) || !package_layout::subdirectory_enabled( subdirectory, target, diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 88247e41d..05fae4480 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -77,6 +77,7 @@ pub const PLUGINS: Plugins = Plugins { subdirectories: &[ Subdirectory { path: "src", + plugin: "", origin: "source", target_contains: &[], targets: &[], @@ -84,6 +85,7 @@ Subdirectory { }, Subdirectory { path: "panels", + plugin: "", origin: "source", target_contains: &[], targets: &[], @@ -91,6 +93,7 @@ Subdirectory { }, Subdirectory { path: "skills", + plugin: "", origin: "source", target_contains: &[], targets: &[], @@ -98,13 +101,15 @@ Subdirectory { }, Subdirectory { path: "native/payload", - origin: "source", + plugin: "agc-cocos-editor", + origin: "prepared", target_contains: &["windows"], targets: &[], features: &[], }, Subdirectory { path: "dotnet/publish/win-x64", + plugin: "agc-unity-editor", origin: "prepared", target_contains: &[], targets: &["x86_64-pc-windows-msvc"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 8164358ed..1a3d5401b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -15,19 +15,27 @@ "manifestFileName": "manifest.json", "packageMetadataFileName": "codex-package.json", "noticeFileName": "NOTICE.md", - "sourceRoots": ["app", "repo"], + "sourceRoots": [ + "app", + "repo" + ], "sourceRelativePaths": [ "node_modules/@openai/codex-/vendor/", "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/" ], "noticeSources": [ { - "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"], + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ], "source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md", "preserve": false }, { - "targets": ["x86_64-pc-windows-msvc"], + "targets": [ + "x86_64-pc-windows-msvc" + ], "source": "resources/codex/win-x64/NOTICE.md", "preserve": true } @@ -36,7 +44,10 @@ { "name": "mac-native", "directory": "mac-native", - "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"] + "targets": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ] } ], "targets": [ @@ -86,18 +97,43 @@ "sourceDirectory": "plugins", "destinationDirectory": "resources/plugins", "manifestFileName": "plugin.json", - "targetContainsAny": ["windows", "apple-darwin"], + "targetContainsAny": [ + "windows", + "apple-darwin" + ], "subdirectories": [ - { "path": "src", "origin": "source" }, - { "path": "panels", "origin": "source" }, - { "path": "skills", "origin": "source" }, - { "path": "native/payload", "origin": "source", "targetContains": ["windows"] }, + { + "path": "src", + "origin": "source" + }, + { + "path": "panels", + "origin": "source" + }, + { + "path": "skills", + "origin": "source" + }, + { + "path": "native/payload", + "origin": "prepared", + "targetContains": [ + "windows" + ], + "plugin": "agc-cocos-editor", + "prepare": "cocos-bridge-build" + }, { "path": "dotnet/publish/win-x64", "origin": "prepared", "prepare": "unity-helper-publish", - "targets": ["x86_64-pc-windows-msvc"], - "features": ["unity-editor-execute"] + "targets": [ + "x86_64-pc-windows-msvc" + ], + "features": [ + "unity-editor-execute" + ], + "plugin": "agc-unity-editor" } ], "libraryStaging": [ @@ -105,8 +141,12 @@ "plugin": "agc-godot-editor", "sourceSubdirectory": "native/gdextension", "prepare": "godot-extension-build", - "targets": ["x86_64-pc-windows-msvc"], - "features": ["godot-editor-execute"], + "targets": [ + "x86_64-pc-windows-msvc" + ], + "features": [ + "godot-editor-execute" + ], "layout": "godot-bundle", "files": [ "bin/win-x64/agc_godot_editor.dll", @@ -120,10 +160,15 @@ { "plugin": "agc-cocos-editor", "prepare": "cocos-bridge-build", - "sourceFileName": "cocos-editor-bridge.dll", + "sourceFileName": "cocos_editor_bridge.dll", "destinationSubdirectory": "native/payload", - "targets": ["x86_64-pc-windows-msvc"], - "features": ["cocos-editor-injection"] + "targets": [ + "x86_64-pc-windows-msvc" + ], + "features": [ + "cocos-editor-injection" + ], + "destinationFileName": "cocos-editor-bridge.dll" } ], "prepareSteps": [ @@ -133,8 +178,15 @@ "workingDirectory": "plugins/agc-unity-editor/dotnet", "scriptFileName": "build.ps1", "fingerprint": { - "roots": ["."], - "excludeDirectoryNames": ["bin", "obj", "publish", "native-build"], + "roots": [ + "." + ], + "excludeDirectoryNames": [ + "bin", + "obj", + "publish", + "native-build" + ], "stampRelativePath": "publish/win-x64/.agc-source.sha256" }, "requiredOutputs": [ @@ -155,25 +207,49 @@ "kind": "powershell", "workingDirectory": "plugins/agc-godot-editor/native/gdextension", "scriptFileName": "build.ps1", - "removeEnvironment": ["PSModulePath"], + "removeEnvironment": [ + "PSModulePath" + ], "requiredOutputs": [ "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll", "plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json", "plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt", "plugins/agc-godot-editor/native/gdextension/vendor/provenance.json" - ] + ], + "fingerprint": { + "roots": [ + "." + ], + "excludeDirectoryNames": [ + "bin", + ".build", + "native-build" + ], + "stampRelativePath": "bin/win-x64/.agc-source.sha256" + } }, { "name": "cocos-bridge-build", "kind": "cargo", "packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge", - "features": ["windows-injection"], + "features": [ + "windows-injection" + ], "requiredOutputs": [] } ], - "skipDirectoryNames": ["target", "node_modules"], - "skipDirectoryNamePrefixes": ["."], - "skipFileNamePrefixes": ["."], - "skipFileNameFragments": [".test."] + "skipDirectoryNames": [ + "target", + "node_modules" + ], + "skipDirectoryNamePrefixes": [ + "." + ], + "skipFileNamePrefixes": [ + "." + ], + "skipFileNameFragments": [ + ".test." + ] } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index e27c8bedd..e75f787ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -49,6 +49,8 @@ pub struct PackageMetadata { #[derive(Debug)] pub struct Subdirectory { pub path: &'static str, + /// 该子目录归属的插件名;空串表示适用于所有插件。 + pub plugin: &'static str, /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。 pub origin: &'static str, pub target_contains: &'static [&'static str], @@ -171,6 +173,11 @@ pub fn plugin_staging_applies(target: &str) -> bool { .any(|needle| target.contains(needle)) } +/// 子目录是否归属该插件(空串表示通用)。 +pub fn subdirectory_applies_to_plugin(subdirectory: &Subdirectory, plugin_name: &str) -> bool { + subdirectory.plugin.is_empty() || subdirectory.plugin == plugin_name +} + /// 子目录在当前目标与已启用 feature 下是否随包。 pub fn subdirectory_enabled( subdirectory: &Subdirectory, diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 245d7cfce..2f5d10de7 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -156,7 +156,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 形态选择**混合**:生成归 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换范式),声明与校验归 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs` 的布局与摘要校验,运行期模块不改公开接口)。理由:Rust 侧没有下载与 lockfile 解析能力(`[build-dependencies]` 无 HTTP 客户端),Node 侧没有 staging 能力;任选单一语言都要迁移另一侧既有资产。Rust 侧刻意不解析 JSON:声明经生成器变成编译期常量,避免运行期解析与生命周期妥协,也让 `&'static` 布局表与现有调用点保持不变。 -准备步骤的验证入口:`AGC_SKIP_RESOURCE_STAGING=1 cargo build/check …` 只跑只读校验、跳过写入分支,用于在既有产物上单独验证校验路径。 +构建脚本自 M3 起只做只读校验(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关一并删除),`cargo build/check` 本身就是对既有产物的校验。 ### 4.9 M2/M3 实况:构建期写入边界 From 06ffa7b6ac26bba1116caf1718f10c174c03f1c1 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 00:50:48 +0800 Subject: [PATCH 14/26] =?UTF-8?q?=E6=8C=89=E5=A4=8D=E6=B5=8B=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20M3=EF=BC=9Apayload=20=E9=97=A8=E6=A7=9B=E4=B8=8E?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E3=80=81=E7=BC=93=E5=AD=98=E9=94=AE=E3=80=81?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=86=99=E5=8F=8D=E4=B8=8E=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0:native/payload 补 features=["cocos-editor-injection"](与 nativePayloads 同门槛,保持迁移前语义),Windows 默认 feature 下不再无条件要求该目录 - P0:copyNativePayloads 真正改用 destinationFileName(此前声明了却仍写 sourceFileName,落盘成 cocos_editor_bridge.dll 而运行时找 cocos-editor-bridge.dll) - P1:payload 交付移到插件缓存短路之前(先默认构建、之后开 injection 不再整条跳过),并把交付物(含缺失态)纳入 pluginSourceFingerprint,删掉它必须导致重建 - P2:修正 pluginTreeMatches 里被写反的条件(原为 || 短路,导致内容比对整体失效) - P2:门禁新增 validateExtendedDeclarations,覆盖 prepareSteps/nativePayloads 的字段与引用完整性(含 destinationFileName) - P2:删除 godot_bundle 的 stage/source_files 及其单测(构建脚本不再有调用方),清掉遗留的 PathBuf 导入 - P3:CLI 支持 --features=a,b 形式 - 验证:工具用例 13/13;声明门禁通过;cargo fmt/cargo check 通过 --- .../scripts/check-package-layout.mjs | 74 +++++++++++ .../scripts/prepare-bundled-resources.mjs | 26 +++- .../src-tauri/build_support/godot_bundle.rs | 115 +----------------- .../build_support/package-layout.generated.rs | 2 +- .../build_support/package-layout.json | 5 +- .../src-tauri/build_support/package_layout.rs | 4 +- 6 files changed, 104 insertions(+), 122 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index a8eda97e1..f7d10f97d 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -487,8 +487,82 @@ function appLockedCodexVersion() { return declared.replace(/^[\^~]/, ''); } +/// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物, +/// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。 +function validateExtendedDeclarations() { + const root = expectObject( + JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')), + 'root', + ); + const plugins = expectObject(root.plugins, 'plugins'); + const steps = expectArray(plugins.prepareSteps ?? [], 'plugins.prepareSteps'); + const names = new Set(); + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + const name = expectString(step.name, `${at}.name`); + if (names.has(name)) { + fail(`${at}.name 重复:${name}`); + } + names.add(name); + const kind = expectString(step.kind, `${at}.kind`); + if (kind !== 'powershell' && kind !== 'cargo') { + fail(`${at}.kind 只能是 powershell 或 cargo`); + } + if (kind === 'powershell') { + expectString(step.workingDirectory, `${at}.workingDirectory`); + expectString(step.scriptFileName, `${at}.scriptFileName`); + } else { + expectString(step.packageDirectory, `${at}.packageDirectory`); + } + expectStringArray(step.requiredOutputs ?? [], `${at}.requiredOutputs`); + } + const payloads = expectArray( + plugins.nativePayloads ?? [], + 'plugins.nativePayloads', + ); + for (const [index, raw] of payloads.entries()) { + const at = `plugins.nativePayloads[${index}]`; + const payload = expectObject(raw, at); + expectString(payload.plugin, `${at}.plugin`); + expectString(payload.prepare, `${at}.prepare`); + expectString(payload.sourceFileName, `${at}.sourceFileName`); + expectString(payload.destinationFileName, `${at}.destinationFileName`); + expectString( + payload.destinationSubdirectory, + `${at}.destinationSubdirectory`, + ); + if (!names.has(payload.prepare)) { + fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`); + } + } + const referenced = [ + ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') + .filter( + (entry) => expectObject(entry, 'subdirectory').origin === 'prepared', + ) + .map((entry) => [entry.path, entry.prepare]), + ...expectArray(plugins.libraryStaging ?? [], 'plugins.libraryStaging').map( + (entry) => [entry.sourceSubdirectory, entry.prepare], + ), + ...payloads.map((entry) => [ + `${entry.plugin}/${entry.destinationSubdirectory}`, + entry.prepare, + ]), + ]; + for (const [label, prepare] of referenced) { + if (!prepare) { + fail(`prepared 来源的条目缺少 prepare 声明:${label}`); + } + if (!names.has(prepare)) { + fail(`${label} 引用了未声明的准备步骤:${prepare}`); + } + } +} + function main() { const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8')); + validateExtendedDeclarations(); const lockedVersion = appLockedCodexVersion(); if (lockedVersion !== declaration.codex.version) { fail( diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 9b6a9508b..2b5e5dae8 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -710,8 +710,8 @@ function pluginTreeMatches( } for (const subdirectory of declaration.plugins.subdirectories) { if ( - subdirectoryAppliesToPlugin(subdirectory, plugin.name) || - subdirectoryEnabled(subdirectory, target, features) + !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + !subdirectoryEnabled(subdirectory, target, features) ) { continue; } @@ -1052,6 +1052,8 @@ function copyNativePayloads({ `Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`, ); } + const destinationName = + payload.destinationFileName ?? payload.sourceFileName; copyFilePreservingMode( source, path.join( @@ -1059,7 +1061,7 @@ function copyNativePayloads({ 'plugins', payload.plugin, payload.destinationSubdirectory, - payload.sourceFileName, + destinationName, ), ); copyFilePreservingMode( @@ -1068,7 +1070,7 @@ function copyNativePayloads({ staging, payload.plugin, payload.destinationSubdirectory, - payload.sourceFileName, + destinationName, ), ); } @@ -1089,6 +1091,18 @@ function preparePlugins({ destinationRoot, declaration.plugins.destinationDirectory, ); + // payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。 + if (existsSync(destination)) { + copyNativePayloads({ + declaration, + repoRoot, + srcTauriRoot: destinationRoot, + staging: destination, + target, + features, + profile, + }); + } const fingerprint = pluginSourceFingerprint( declaration, plugins, @@ -1244,6 +1258,10 @@ function parseArguments(argv) { index += 1; } else if (value === '--dry-run') { args.dryRun = true; + } else if (value.startsWith('--features=')) { + args.features = new Set( + value.slice('--features='.length).split(',').filter(Boolean), + ); } else if (value === '--features') { args.features = new Set( String(argv[index + 1] ?? '') diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 616ad5b30..02af4c3a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -1,6 +1,6 @@ use sha2::{Digest, Sha256}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; // 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。 #[allow(dead_code)] @@ -74,39 +74,6 @@ pub fn validate(root: &Path) -> Result)>, String> { Ok(files) } -pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> { - if target != "x86_64-pc-windows-msvc" || !enabled { - return Ok(()); - } - for (relative, bytes) in validate(root)? { - let path = destination.join(relative); - fs::create_dir_all(path.parent().expect("Godot resource parent")) - .map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?; - fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?; - } - Ok(()) -} - -pub fn source_files(root: &Path) -> Result, String> { - plain_metadata(root)?; - let mut sources = Vec::new(); - for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))? - { - let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?; - if matches!(entry.file_name().to_str(), Some("bin" | ".build")) { - continue; - } - let metadata = plain_metadata(&entry.path())?; - if metadata.is_dir() { - sources.extend(source_files(&entry.path())?); - } else if metadata.is_file() { - sources.push(entry.path()); - } - } - sources.sort(); - Ok(sources) -} - #[cfg(test)] mod tests { use super::*; @@ -132,84 +99,4 @@ mod tests { ) .unwrap(); } - - #[test] - fn stage_only_verified_windows_runtime_and_not_build_inputs() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap(); - stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true, - ) - .unwrap(); - for relative in BUNDLE_FILES { - assert_eq!( - fs::read(source.path().join(relative)).unwrap(), - fs::read(destination.path().join(relative)).unwrap() - ); - } - assert!(!destination.path().join("bridge.gd").exists()); - assert!(!destination.path().join("bin/win-x64/extra.dll").exists()); - } - - #[test] - fn unsupported_or_disabled_targets_need_no_native_artifacts() { - let destination = tempfile::tempdir().unwrap(); - for (target, enabled) in [ - ("aarch64-apple-darwin", true), - ("x86_64-apple-darwin", true), - ("x86_64-unknown-linux-gnu", true), - ("aarch64-pc-windows-msvc", true), - ("x86_64-pc-windows-msvc", false), - ] { - stage( - Path::new("missing-godot-native"), - destination.path(), - target, - enabled, - ) - .unwrap(); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - } - } - - #[test] - fn incomplete_or_tampered_bundle_fails_before_copying() { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap(); - assert!(stage( - source.path(), - destination.path(), - "x86_64-pc-windows-msvc", - true - ) - .unwrap_err() - .contains("SHA256")); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - fixture(source.path()); - fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap(); - assert!(validate(source.path()).is_err()); - } - - #[test] - fn source_watch_list_excludes_build_outputs() { - let source = tempfile::tempdir().unwrap(); - fixture(source.path()); - fs::create_dir(source.path().join(".build")).unwrap(); - fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap(); - fs::write(source.path().join("bridge.gd"), "source").unwrap(); - let sources = source_files(source.path()).unwrap(); - assert_eq!(sources.len(), 3); - assert!(sources.contains(&source.path().join("bridge.gd"))); - assert!(!sources.iter().any(|path| path - .components() - .any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build"))); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 05fae4480..636ba82be 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -105,7 +105,7 @@ Subdirectory { origin: "prepared", target_contains: &["windows"], targets: &[], - features: &[], + features: &["cocos-editor-injection"], }, Subdirectory { path: "dotnet/publish/win-x64", diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json index 1a3d5401b..a6c8c04a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json @@ -121,7 +121,10 @@ "windows" ], "plugin": "agc-cocos-editor", - "prepare": "cocos-bridge-build" + "prepare": "cocos-bridge-build", + "features": [ + "cocos-editor-injection" + ] }, { "path": "dotnet/publish/win-x64", diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index e75f787ab..8c8732039 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -866,8 +866,8 @@ mod tests { .filter(|entry| subdirectory_is_prepared(entry)) .map(|entry| entry.path) .collect::>(); - assert_eq!(source, ["src", "panels", "skills", "native/payload"]); - assert_eq!(prepared, ["dotnet/publish/win-x64"]); + assert_eq!(source, ["src", "panels", "skills"]); + assert_eq!(prepared, ["native/payload", "dotnet/publish/win-x64"]); } #[test] From 547fb0dbceee9bfef8f8271c1d19f9853c82d329 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 01:03:40 +0800 Subject: [PATCH 15/26] =?UTF-8?q?=E6=8C=89=E7=AC=AC=E4=B8=89=E8=BD=AE?= =?UTF-8?q?=E5=A4=8D=E6=B5=8B=E4=BF=AE=E5=A4=8D=20M3=EF=BC=9Adry-run=20?= =?UTF-8?q?=E9=9B=B6=E5=86=99=E5=85=A5=E3=80=81=E6=89=80=E6=9C=89=E6=9D=83?= =?UTF-8?q?=E5=85=88=E8=A1=8C=E3=80=81=E6=8C=87=E7=BA=B9=E4=B8=8E=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E8=A1=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2:preparePlugins 重排——dry-run 只做只读判断即返回,预缓存交付不再排在 dry-run 之前;所有权断言提到任何写入之前 - P3:pluginSourceFingerprint 放开 origin=source 过滤(prepared 内容哈希分支可达),库文件与 payload 交付态纳入指纹,pluginTreeMatches 增加「不该存在却存在 → 需要重建」判定 - P3:门禁交叉校验 nativePayloads[].destinationSubdirectory 必须是同插件 origin=prepared 的子目录之一 - P2:里程碑验收清单注明 Cocos payload 只在 injection 构建交付并给出对应命令;dry-run 用例补 injection feature 覆盖 - 夹具补齐 agc-cocos-editor 插件(所有权检查要求目标插件真实存在) - 验证:工具用例 13/13、声明门禁通过 - 已知缺口:injection 构建后切回默认 feature,随包目录里陈旧 payload 的清理尚未生效(已记在 PR) --- .../scripts/check-package-layout.mjs | 30 ++++++ .../scripts/prepare-bundled-resources.mjs | 92 ++++++++++++++----- .../prepare-bundled-resources.test.mjs | 22 ++++- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 3 +- 4 files changed, 123 insertions(+), 24 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index f7d10f97d..90dd0ce92 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -536,6 +536,36 @@ function validateExtendedDeclarations() { fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`); } } + const preparedByPlugin = new Map(); + for (const entry of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + )) { + const subdirectory = expectObject(entry, 'subdirectory'); + if (subdirectory.origin !== 'prepared') { + continue; + } + const owner = expectString( + subdirectory.plugin ?? '', + 'subdirectory.plugin', + ); + if (!owner) { + fail(`origin=prepared 的子目录必须声明 plugin:${subdirectory.path}`); + } + preparedByPlugin.set(owner, [ + ...(preparedByPlugin.get(owner) ?? []), + subdirectory.path, + ]); + } + for (const payload of payloads) { + const candidates = preparedByPlugin.get(payload.plugin) ?? []; + if (!candidates.includes(payload.destinationSubdirectory)) { + fail( + `nativePayloads 的 destinationSubdirectory(${payload.destinationSubdirectory})必须是该插件 origin=prepared 的子目录之一(现有:${candidates.join('、') || '无'})`, + ); + } + } + const referenced = [ ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') .filter( diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 2b5e5dae8..9ec3828ac 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -659,10 +659,10 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { const lines = []; for (const plugin of plugins) { for (const subdirectory of declaration.plugins.subdirectories) { - if (subdirectory.origin !== 'source') { - continue; - } - if (!subdirectoryEnabled(subdirectory, target, features)) { + if ( + subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + subdirectoryEnabled(subdirectory, target, features) + ) { continue; } const root = path.join(plugin.root, subdirectory.path); @@ -685,6 +685,44 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { lines.push( `${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`, ); + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name !== staging.plugin || + libraryStagingEnabled(staging, target, features) + ) { + continue; + } + for (const relative of staging.files) { + const file = path.join( + plugin.root, + staging.sourceSubdirectory, + relative, + ); + lines.push( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}:${ + existsSync(file) ? `sha256:${sha256File(file)}` : 'missing' + }`, + ); + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name !== payload.plugin || + nativePayloadEnabled(payload, target, features) + ) { + continue; + } + const delivered = path.join( + plugin.root, + payload.destinationSubdirectory, + payload.destinationFileName ?? payload.sourceFileName, + ); + lines.push( + `${plugin.name}/${payload.destinationSubdirectory}:${ + existsSync(delivered) ? `sha256:${sha256File(delivered)}` : 'missing' + }`, + ); + } } return sha256Text(lines.join('\n')); } @@ -715,6 +753,14 @@ function pluginTreeMatches( ) { continue; } + const stagedDirectory = path.join(pluginDestination, subdirectory.path); + if (!subdirectoryEnabled(subdirectory, target, features)) { + // 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。 + if (existsSync(stagedDirectory)) { + return false; + } + continue; + } const sourceRoot = path.join(plugin.root, subdirectory.path); if (subdirectory.origin !== 'source') { if ( @@ -1091,6 +1137,26 @@ function preparePlugins({ destinationRoot, declaration.plugins.destinationDirectory, ); + const fingerprint = pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ); + const upToDate = + record.plugins?.fingerprint === fingerprint && + pluginTreeMatches(declaration, plugins, target, features, destination); + // dry-run 必须零写入:只做只读判断就返回。 + if (dryRun) { + return { + summary: upToDate + ? `plugins 命中缓存(未写入,${plugins.length} 个插件)` + : `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, + record: undefined, + }; + } + // 所有权断言先于任何写入(含下面的预缓存交付)。 + assertOwnedPluginRoot(destination, plugins); // payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。 if (existsSync(destination)) { copyNativePayloads({ @@ -1103,28 +1169,12 @@ function preparePlugins({ profile, }); } - const fingerprint = pluginSourceFingerprint( - declaration, - plugins, - target, - features, - ); - if ( - record.plugins?.fingerprint === fingerprint && - pluginTreeMatches(declaration, plugins, target, features, destination) - ) { + if (upToDate) { return { summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`, record: undefined, }; } - if (dryRun) { - return { - summary: `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`, - record: undefined, - }; - } - assertOwnedPluginRoot(destination, plugins); stageAtomically(destination, (staging) => { for (const plugin of plugins) { const pluginDestination = path.join(staging, plugin.name); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 2bb540151..0a161a4ad 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -139,6 +139,17 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n'); fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + + const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor'); + fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(cocosRoot, 'plugin.json'), + '{"name":"agc-cocos-editor"}\n', + ); + fs.writeFileSync( + path.join(cocosRoot, 'src/entry.mjs'), + 'export const cocos = 1;\n', + ); } writePrepareArtifacts(repoRoot, declaration); @@ -455,7 +466,14 @@ test('fails closed when the destination is owned by something else', () => { test('dry run writes nothing', () => { const fixture = buildFixture(); try { - const summaries = prepare(fixture, { dryRun: true }); + const summaries = prepare(fixture, { + dryRun: true, + features: new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]), + }); assert.match(summaries[0], /需要重新生成(dry-run 未写入)/); const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64'); assert.deepEqual( @@ -497,7 +515,7 @@ test('declaration drives source lookup and staging units', () => { source, /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, ); - assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 1); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 2); } finally { fixture.cleanup(); } diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index d9be7289d..8c0ca00b1 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -51,7 +51,8 @@ 1. **准备步骤单独跑(不打包)** - `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc` - - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展、`agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 全部在位。 + - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展在位。 + - Cocos payload 只在 injection 构建下交付(与迁移前一致):加 `--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute,cocos-editor-injection` 再跑一次,确认 `agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 同时出现在插件工作区与随包目录。 - 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。 2. **构建期不再写随包资源** - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 From 3b4f9b2e107134054a5a2090523ad66bacaea7c5 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 10:20:09 +0800 Subject: [PATCH 16/26] =?UTF-8?q?=E6=8C=89=E7=B3=BB=E7=BB=9F=E6=80=A7?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20M3=EF=BC=9A=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E5=8F=8D=E5=88=A4=E6=96=AD=E3=80=81=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81=E3=80=81fail-open=20=E4=B8=8E?= =?UTF-8?q?=E5=8D=AB=E7=94=9F=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1-1:pluginSourceFingerprint 的判断取反(prepared 内容与交付态真正进入指纹);pluginTreeMatches 的「当前目标/feature 下不该存在却存在 → 重建」从死代码修活 - P1-7:package_layout 的 `sha256_file(x).ok() != ...` 改为 map_err 传播,两侧读失败不再被判为「相等」 - P3-2:copyNativePayloads 的 'plugins' 字面量改由声明 sourceDirectory 派生 - P3-5:payload 交付内容一致时跳过写入(命中缓存保持零写入、时间戳稳定) - P3-7:门禁提示里的脚本名改为 agc:bundled-resources:sync - P3-8:删除已无写入方的 resources/cocos-editor-bridge 占位目录 - P2-4:root 包装脚本补结尾 `--`,文档里的 prepare 命令可直接复制执行 - 验证:工具用例全绿、声明门禁通过 --- .../scripts/check-package-layout.mjs | 4 +-- .../scripts/prepare-bundled-resources.mjs | 33 ++++++++++++------- .../src-tauri/build_support/package_layout.rs | 18 ++++++++-- .../resources/cocos-editor-bridge/.gitignore | 1 - .../resources/cocos-editor-bridge/.gitkeep | 0 package.json | 4 +-- 6 files changed, 41 insertions(+), 19 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore delete mode 100644 apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 90dd0ce92..b90209878 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -453,7 +453,7 @@ function renderGenerated(declaration) { return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs // 来源:build_support/package-layout.json。不要手工编辑本文件。 // 修改随包资源布局请编辑声明文件,然后运行 -// npm run agc:package-layout:sync(在仓库根目录) +// npm run agc:bundled-resources:sync(在仓库根目录) // 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)}; @@ -615,7 +615,7 @@ function main() { `随包资源声明与 Rust 常量不一致:`, ` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`, ` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`, - '请运行:npm run agc:package-layout:sync', + '请运行:npm run agc:bundled-resources:sync', ].join('\n'), ); process.exit(1); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 9ec3828ac..674e668e4 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -660,8 +660,8 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { for (const plugin of plugins) { for (const subdirectory of declaration.plugins.subdirectories) { if ( - subdirectoryAppliesToPlugin(subdirectory, plugin.name) || - subdirectoryEnabled(subdirectory, target, features) + !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || + !subdirectoryEnabled(subdirectory, target, features) ) { continue; } @@ -747,10 +747,7 @@ function pluginTreeMatches( return false; } for (const subdirectory of declaration.plugins.subdirectories) { - if ( - !subdirectoryAppliesToPlugin(subdirectory, plugin.name) || - !subdirectoryEnabled(subdirectory, target, features) - ) { + if (!subdirectoryAppliesToPlugin(subdirectory, plugin.name)) { continue; } const stagedDirectory = path.join(pluginDestination, subdirectory.path); @@ -763,10 +760,7 @@ function pluginTreeMatches( } const sourceRoot = path.join(plugin.root, subdirectory.path); if (subdirectory.origin !== 'source') { - if ( - existsSync(sourceRoot) && - !existsSync(path.join(pluginDestination, subdirectory.path)) - ) { + if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) { return false; } continue; @@ -1053,6 +1047,21 @@ function copyPreparedPayloads({ } } +/// 内容一致时不重写(保住时间戳与「命中缓存零写入」口径)。 +function copyFilePreservingModeIfChanged(source, destination) { + if (existsSync(destination)) { + const sourceInfo = statSync(source); + const destinationInfo = statSync(destination); + if ( + sourceInfo.size === destinationInfo.size && + Buffer.compare(readFileSync(source), readFileSync(destination)) === 0 + ) { + return; + } + } + copyFilePreservingMode(source, destination); +} + /// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。 function copyNativePayloads({ declaration, @@ -1100,7 +1109,7 @@ function copyNativePayloads({ } const destinationName = payload.destinationFileName ?? payload.sourceFileName; - copyFilePreservingMode( + copyFilePreservingModeIfChanged( source, path.join( repoRoot, @@ -1110,7 +1119,7 @@ function copyNativePayloads({ destinationName, ), ); - copyFilePreservingMode( + copyFilePreservingModeIfChanged( source, path.join( staging, diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 8c8732039..f7de69dcd 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -280,7 +280,15 @@ pub fn validate_staged_plugins( if !staged_manifest.is_file() { return Err(format!("随包插件缺少清单:{}", staged_manifest.display())); } - if sha256_file(&source_manifest).ok() != sha256_file(&staged_manifest).ok() { + let source_manifest_digest = sha256_file(&source_manifest) + .map_err(|error| format!("读取插件清单失败 {}:{error}", source_manifest.display()))?; + let staged_manifest_digest = sha256_file(&staged_manifest).map_err(|error| { + format!( + "读取随包插件清单失败 {}:{error}", + staged_manifest.display() + ) + })?; + if source_manifest_digest != staged_manifest_digest { return Err(format!( "随包插件清单与源码不一致:{}", staged_manifest.display() @@ -319,7 +327,13 @@ pub fn validate_staged_plugins( if !staged_file.is_file() { return Err(format!("随包插件缺少文件:{}", staged_file.display())); } - if sha256_file(&source_file).ok() != sha256_file(&staged_file).ok() { + let source_digest = sha256_file(&source_file).map_err(|error| { + format!("读取插件文件失败 {}:{error}", source_file.display()) + })?; + let staged_digest = sha256_file(&staged_file).map_err(|error| { + format!("读取随包插件文件失败 {}:{error}", staged_file.display()) + })?; + if source_digest != staged_digest { return Err(format!( "随包插件文件与源码不一致:{}", staged_file.display() diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore deleted file mode 100644 index 6a7461313..000000000 --- a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.dll diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/package.json b/package.json index 2c852e22f..250192b8f 100644 --- a/package.json +++ b/package.json @@ -170,8 +170,8 @@ "agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync", "agc:bundled-resources:check": "npm --prefix apps/ai-game-creator-shell run bundled-resources:check", "agc:bundled-resources:sync": "npm --prefix apps/ai-game-creator-shell run bundled-resources:sync", - "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare", - "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test", + "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare --", + "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test --", "agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs", "agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml", "agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test", From 3fba76fd6ed33684983930c545d04911dc4c94cd Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 10:54:29 +0800 Subject: [PATCH 17/26] =?UTF-8?q?=E6=8C=89=E5=AE=A1=E8=AE=A1=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E6=94=B6=E5=8F=A3=20M3=EF=BC=9ACI=20=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E3=80=81=E5=B9=B6=E5=8F=91=E5=8F=A3=E5=BE=84=E3=80=81?= =?UTF-8?q?feature=20=E5=8D=95=E4=B8=80=E6=8E=A8=E5=AF=BC=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI:把 bundled-resources:test(13 条,跨平台、注入假执行器)接进 ai-game-creator-shell:check:web,Linux CI 即可覆盖准备步骤逻辑 - 并发:按「无实际场景不做并发支持」定论——不加锁,但替换失败改为可读错误并保留已生成的 staging;技术方案 §4.3 与风险表同步改写 - feature:新增 cargo-features.resolveEditorFeatures 作为唯一推导入口(env > 命令行 --features > 平台默认),dev 与发布入口的 cargo 参数、随包资源准备步骤共用同一集合;--no-bundle 也执行 staging(app 构建本身需要随包资源),只跳过总号发布 - 文档:pitfalls 的校验收口限定到 source 派生内容;里程碑验收清单按实况修正(命令 --、prepared 只查存在性、Windows 侧已复跑) - 验证:准备步骤 13/13、发布入口 39/39、dev 入口 12/12、声明门禁一致 --- .../scripts/build-release.mjs | 9 ++-- .../scripts/build-release.test.mjs | 14 ++++-- .../scripts/cargo-features.mjs | 45 +++++++++++++++++++ .../scripts/prepare-bundled-resources.mjs | 9 +++- .../scripts/start-tauri-dev.mjs | 15 ++----- .../build_support/package-layout.generated.rs | 2 +- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 6 +-- docs/project-memory/shared-memory/pitfalls.md | 2 +- package.json | 2 +- 9 files changed, 80 insertions(+), 24 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index a10f95ee8..632356a94 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -11,6 +11,7 @@ import { } from './agc-global-version.mjs'; import { defaultEditorFeatures, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -435,11 +436,11 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) { /// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。 export function stageBundledResources( target, - { prepare = prepareBundledResources } = {}, + { prepare = prepareBundledResources, features } = {}, ) { const summaries = prepare({ target, - features: new Set(defaultEditorFeatures(target)), + features: new Set(features ?? defaultEditorFeatures(target)), profile: 'release', log: (line) => console.log(`[ai-game-creator-shell] ${line}`), }); @@ -465,10 +466,12 @@ export function runTauriBuild( } const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel, target } = context; + const features = resolveEditorFeatures({ argv: args, target }); if (!args.includes('--no-bundle')) { stageRuntime(target); - stageBundled(target); } + // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging;只有总号发布是打包专属。 + stageBundled(target, { features }); const configPath = writeChannelConfigFile( channel, target, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index 3d046d9d8..50525dd34 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -839,12 +839,20 @@ test('release stages Node before Tauri and injects its resource mapping only for }, }); assert.deepEqual(events, ['stage', 'bundled', 'build']); + events.length = 0; runTauriBuild(['--no-bundle', '--target', windowsTarget], context, { stageRuntime() { - assert.fail('no-bundle must not stage resources'); + assert.fail('no-bundle must not stage node runtime'); }, - stageBundled() { - assert.fail('no-bundle must not stage bundled resources'); + stageBundled(target, options) { + // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging, + // 且必须拿到与 cargo 相同的 feature 集(避免「cargo 开、staging 没开」)。 + assert.equal(target, windowsTarget); + assert.ok( + options?.features?.length > 0, + 'bundled staging 必须收到 feature 集', + ); + events.push('bundled'); }, spawn(_binary, args) { const config = JSON.parse( diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index 515432f5b..c8303d42b 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -22,3 +22,48 @@ export function defaultEditorFeatures(target) { ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } + +/// 单一声明式解析:本次构建实际生效的编辑器 feature 集。 +/// 解析顺序:环境变量 `AGC_DEV_CARGO_FEATURES` > 命令行 `--features` > 目标平台默认值。 +/// dev 入口、发布入口的 cargo 参数与随包资源准备步骤都必须用这里的返回值, +/// 否则会出现「cargo 开了 feature、staging 没开(或反之)」的窗口。 +export function resolveEditorFeatures({ + argv = [], + target, + env = process.env, +} = {}) { + // 顺序:环境变量(dev 的显式覆盖)> 命令行 --features > 目标平台默认值。 + const fromEnv = String(env.AGC_DEV_CARGO_FEATURES ?? '').trim(); + if (fromEnv) { + return fromEnv + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + const fromArgv = featuresFromArgv(argv); + if (fromArgv) { + return fromArgv; + } + return defaultEditorFeatures(target); +} + +/// 从 argv 里解析 `--features a,b` / `--features=a,b`;没有则返回 null。 +export function featuresFromArgv(argv = []) { + for (let index = 0; index < argv.length; index += 1) { + const value = String(argv[index]); + if (value.startsWith('--features=')) { + return value + .slice('--features='.length) + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + if (value === '--features' && argv[index + 1] !== undefined) { + return String(argv[index + 1]) + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + } + } + return null; +} diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 674e668e4..44a83c92e 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -498,7 +498,14 @@ function stageAtomically(unitPath, builder) { throw error; } rmSync(unitPath, { recursive: true, force: true }); - renameSync(stagingPath, unitPath); + try { + renameSync(stagingPath, unitPath); + } catch (error) { + // 并发调用未做加锁(见技术方案 §4.3):这里只保证失败可读、且不丢已经生成好的 staging。 + fail( + `替换 ${unitPath} 失败(${error.code ?? error.message}):同一资源目录可能正被另一个准备步骤进程写入;staging 已保留在 ${stagingPath},确认没有并发进程后重试`, + ); + } } function prepareCodex({ diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 47a6eaeb3..6c82e54cb 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { - defaultEditorFeatures, + resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; import { @@ -57,18 +57,11 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { // 开发和发行构建使用同一平台编辑器 feature 集合。 // 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 -function readDevCargoFeatures(env = process.env) { - const override = env.AGC_DEV_CARGO_FEATURES; - if (override !== undefined) { - return override - .split(',') - .map((value) => value.trim()) - .filter(Boolean); - } - return defaultEditorFeatures(process.platform); +function readDevCargoFeatures(argv = []) { + return resolveEditorFeatures({ argv, target: process.platform }); } -function withDevCargoFeatures(argv, features = readDevCargoFeatures()) { +function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { return withDefaultCargoFeatures(argv, features); } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index 636ba82be..e614b8567 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -1,7 +1,7 @@ // @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs // 来源:build_support/package-layout.json。不要手工编辑本文件。 // 修改随包资源布局请编辑声明文件,然后运行 -// npm run agc:package-layout:sync(在仓库根目录) +// npm run agc:bundled-resources:sync(在仓库根目录) // 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。 pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1"; diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 8c0ca00b1..30f2b39e4 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -13,7 +13,7 @@ - 准备步骤(`scripts/prepare-bundled-resources.mjs`)按声明执行 `powershell.exe -File build.ps1` 与 `cargo build -p … --target …`,用内容指纹跳过未变化的步骤,校验必需产物齐全后才复制;命中指纹且产物齐全时零写入。 - 构建脚本删除了三处产物生成与整棵树复制(`prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数,共减少约 220 行),只保留只读校验:源码派生内容逐文件比对、已准备产物存在性、Godot 随包库沿用既有深度校验(`godot_bundle::validate`)。Godot 随包文件清单改由声明提供(单一来源)。 - `.taurignore` 的两份 staging 条目已删除(构建期不再写 `resources/plugins`,无需忽略)。 -- 准备步骤的 Windows 侧行为**尚未在真机验证**:本机 macOS 只能跑通编译、声明门禁与用例(命令执行器在用例中注入假实现)。 +- 准备步骤的 Windows 侧命令路径已随系统性审计在真机复跑(powershell/cargo 两条路径、产物归位与幂等均已验证)。 ## 目标 @@ -57,13 +57,13 @@ 2. **构建期不再写随包资源** - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。 3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。 -4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run`(会触发构建脚本的只读校验)必须通过。 +4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run` 必须通过(会触发只读校验,所以要先跑过准备步骤)。 5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。 6. **边界(负例)** - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。 - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。 - 删掉 `plugins/agc-unity-editor/dotnet/publish/win-x64/.agc-source.sha256` 后重跑准备步骤:预期重新执行 dotnet publish,而不是复用旧产物。 - - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(源码派生内容逐文件比对)。 + - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(该规则覆盖 source 派生内容;prepared 产物只查存在性,见排障经验)。 记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9a319d877..ab6dd2640 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -15,7 +15,7 @@ ## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 - **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 -- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改 `resources/**` 会被 `cargo build` 直接拒绝;`prepared` 只查存在性,Godot 随包库额外跑 `godot_bundle::validate` 的深度校验。 +- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改这部分会被 `cargo build` 直接拒绝;`origin: prepared` 只查存在性(Godot 随包库额外跑 `godot_bundle::validate`),手改 prepared 产物不会被拒,要改就改准备步骤的来源或声明。 - **准备步骤指纹**:声明了指纹的步骤(Unity)命中后不会重跑工具链,改 `plugins/**` 源码即失效;指纹戳文件(`publish/win-x64/.agc-source.sha256`)删掉只会多跑一次构建。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。 ## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死 diff --git a/package.json b/package.json index 250192b8f..e2b600db2 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ "agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml", "agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", - "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests", + "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests", "ai-game-creator-shell:check:rust:crates": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app", "ai-game-creator-shell:check:rust:shell": "node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4", "ai-game-creator-shell:check:rust": "npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell", From acf021de2a7726a5dbe63ab8bcab4059917857d3 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 11:36:34 +0800 Subject: [PATCH 18/26] =?UTF-8?q?=E6=8C=89=E8=AF=84=E4=BC=B0=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E8=90=BD=E5=9C=B0=EF=BC=9A=E9=97=A8=E7=A6=81=E4=BA=A4?= =?UTF-8?q?=E5=8F=89=E6=A0=A1=E9=AA=8C=E3=80=81CLI=20=E7=BC=BA=E5=80=BC?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=81=E7=BC=96=E8=BE=91=E5=99=A8=E5=88=86?= =?UTF-8?q?=E6=94=AF=E8=A6=86=E7=9B=96=E4=B8=8E=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 门禁新增交叉校验:plugin 必须指向真实插件目录、features 必须存在于 shell crate 的 [features]、所有声明路径必须是仓库内相对路径(拒绝绝对路径与 ..) - 准备步骤 CLI:--target/--destination/--features 缺值直接失败(不再静默回退宿主目标),--features 空值也拒绝 - 用例:fixture 补齐 agc-unity-editor/agc-godot-editor/agc-cocos-editor,新增「编辑器分支产物按 prepared 与 library staging 交付」用例;godot_bundle 补 validate 负例单测 - 运维文档:dev 构建下客户端读 target/debug/plugins/**,改资源要重跑准备步骤 - 未做(已按评估结论记录):P2-2 运行时常量锁定需要先把 nativePayloads 也 emit 进 Rust 声明,留作后续 - 验证:准备步骤 14/14、发布入口 39/39、dev 入口 12/12、godot_bundle 19 通过、声明门禁一致、cargo check 通过 --- .../scripts/check-package-layout.mjs | 116 +++++++++++++++++- .../scripts/prepare-bundled-resources.mjs | 14 ++- .../prepare-bundled-resources.test.mjs | 56 ++++++++- .../src-tauri/build_support/godot_bundle.rs | 18 +++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 5 files changed, 201 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index b90209878..3d285feaa 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -9,7 +9,7 @@ // 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量; // Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。 -import { readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -487,6 +487,46 @@ function appLockedCodexVersion() { return declared.replace(/^[\^~]/, ''); } +const REPO_ROOT = path.resolve(APP_ROOT, '..', '..'); +const PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins'); +const SHELL_MANIFEST_PATH = path.join(SRC_TAURI, 'Cargo.toml'); + +/// 声明里的路径必须是仓库内相对路径:绝对路径或含 `..` 会写到工作区之外。 +function assertRelativePath(value, at) { + if ( + path.isAbsolute(value) || + value.startsWith('/') || + value.split('/').includes('..') + ) { + fail(`${at} 必须是仓库内相对路径(不允许绝对路径或 ..):${value}`); + } + return value; +} + +/// 声明引用的插件必须是真实存在的插件目录(拼错插件的后果是静默不交付)。 +function assertKnownPlugin(name, at) { + const directory = path.join(PLUGINS_ROOT, name); + if (!existsSync(path.join(directory, 'plugin.json'))) { + fail(`${at} 指向的插件不存在或缺少 plugin.json:${name}`); + } + return name; +} + +/// shell crate 的 feature 表(拼错 feature 的后果同样是静默不交付)。 +function shellCrateFeatures() { + const manifest = readFileSync(SHELL_MANIFEST_PATH, 'utf8'); + const section = /\[features\]([\s\S]*?)(?:\n\[|$)/u.exec(manifest); + if (!section) { + fail('无法从 src-tauri/Cargo.toml 读取 [features] 段'); + } + return new Set( + section[1] + .split('\n') + .map((line) => /^([A-Za-z0-9_-]+)\s*=/u.exec(line.trim())?.[1]) + .filter(Boolean), + ); +} + /// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物, /// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。 function validateExtendedDeclarations() { @@ -566,6 +606,80 @@ function validateExtendedDeclarations() { } } + const knownFeatures = shellCrateFeatures(); + const checkFeatures = (values, at) => { + for (const name of expectStringArray(values ?? [], at)) { + if (!knownFeatures.has(name)) { + fail(`${at} 引用了 Cargo.toml 里不存在的 feature:${name}`); + } + } + }; + for (const [index, raw] of expectArray( + plugins.subdirectories ?? [], + 'plugins.subdirectories', + ).entries()) { + const at = `plugins.subdirectories[${index}]`; + const subdirectory = expectObject(raw, at); + assertRelativePath( + expectString(subdirectory.path, `${at}.path`), + `${at}.path`, + ); + if (subdirectory.plugin) { + assertKnownPlugin(subdirectory.plugin, `${at}.plugin`); + } + checkFeatures(subdirectory.features, `${at}.features`); + } + for (const [index, raw] of expectArray( + plugins.libraryStaging ?? [], + 'plugins.libraryStaging', + ).entries()) { + const at = `plugins.libraryStaging[${index}]`; + const staging = expectObject(raw, at); + assertKnownPlugin( + expectString(staging.plugin, `${at}.plugin`), + `${at}.plugin`, + ); + assertRelativePath( + expectString(staging.sourceSubdirectory, `${at}.sourceSubdirectory`), + `${at}.sourceSubdirectory`, + ); + for (const relative of expectStringArray( + staging.files ?? [], + `${at}.files`, + )) { + assertRelativePath(relative, `${at}.files`); + } + checkFeatures(staging.features, `${at}.features`); + } + for (const payload of payloads) { + assertRelativePath( + payload.destinationSubdirectory, + `nativePayloads.${payload.plugin}.destinationSubdirectory`, + ); + checkFeatures( + payload.features, + `nativePayloads.${payload.plugin}.features`, + ); + } + for (const [index, raw] of steps.entries()) { + const at = `plugins.prepareSteps[${index}]`; + const step = expectObject(raw, at); + for (const key of ['workingDirectory', 'packageDirectory']) { + if (step[key]) { + assertRelativePath( + expectString(step[key], `${at}.${key}`), + `${at}.${key}`, + ); + } + } + for (const relative of expectStringArray( + step.requiredOutputs ?? [], + `${at}.requiredOutputs`, + )) { + assertRelativePath(relative, `${at}.requiredOutputs`); + } + } + const referenced = [ ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories') .filter( diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 44a83c92e..ca1cffe52 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -1312,15 +1312,25 @@ export function prepareBundledResources({ return summaries; } +function requireFlagValue(argv, index, flag) { + const value = argv[index + 1]; + if (value === undefined || String(value).startsWith('--')) { + fail( + `${flag} 缺少取值(例如 ${flag} );拒绝回退到宿主默认值,以免准备错目标`, + ); + } + return String(value); +} + function parseArguments(argv) { const args = { target: undefined, destinationRoot: undefined, dryRun: false }; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === '--target') { - args.target = argv[index + 1]; + args.target = requireFlagValue(argv, index, '--target'); index += 1; } else if (value === '--destination') { - args.destinationRoot = argv[index + 1]; + args.destinationRoot = requireFlagValue(argv, index, '--destination'); index += 1; } else if (value === '--dry-run') { args.dryRun = true; diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 0a161a4ad..7b66a0ed1 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -140,6 +140,21 @@ function buildFixture({ targets = [WINDOWS_TARGET], plugins = true } = {}) { fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n'); fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n'); + for (const name of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + const root = path.join(repoRoot, 'plugins', name); + fs.mkdirSync(path.join(root, 'src'), { + recursive: true, + }); + fs.writeFileSync(path.join(root, 'plugin.json'), `{"name":"${name}"}\n`); + fs.writeFileSync( + path.join(root, 'src/entry.mjs'), + `export const ${name} = 1;\n`, + ); + } const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor'); fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true }); fs.writeFileSync( @@ -515,7 +530,7 @@ test('declaration drives source lookup and staging units', () => { source, /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u, ); - assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 2); + assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4); } finally { fixture.cleanup(); } @@ -605,3 +620,42 @@ test('fails closed when a prepare step does not produce its declared outputs', ( fixture.cleanup(); } }); + +test('delivers editor branch artifacts declared as prepared or library staging', () => { + const fixture = buildFixture(); + try { + const features = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', + ]); + prepare(fixture, { features }); + const staged = (relative) => + path.join(fixture.destinationRoot, 'resources/plugins', relative); + + // Unity:prepared 子目录整体复制 + assert.ok( + fs.existsSync( + staged('agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe'), + ), + 'Unity helper 必须随包', + ); + // Godot:libraryStaging 声明文件逐个复制 + assert.ok( + fs.existsSync( + staged( + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + ), + ), + 'Godot 扩展必须随包', + ); + assert.ok( + fs.existsSync( + staged('agc-godot-editor/native/gdextension/vendor/provenance.json'), + ), + 'Godot 随包清单文件必须随包', + ); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs index 02af4c3a5..0c0061d5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs @@ -99,4 +99,22 @@ mod tests { ) .unwrap(); } + + #[test] + fn validate_rejects_incomplete_bundle() { + let temp = tempfile::tempdir().expect("tempdir"); + let error = validate(temp.path()).expect_err("空目录必须被拒绝"); + assert!(!error.is_empty(), "失败原因不能为空"); + + // 只有文件名、内容不合法的「像样」目录也必须被拒绝。 + for relative in BUNDLE_FILES { + let file = temp.path().join(relative); + fs::create_dir_all(file.parent().expect("parent")).expect("create dir"); + fs::write(&file, b"not a real artifact").expect("write file"); + } + assert!( + validate(temp.path()).is_err(), + "内容不合法的随包库必须被拒绝", + ); + } } diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 8ecb6365b..d56284c31 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,7 +88,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 -随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 ### 本地 Rust 构建缓存与磁盘上限 From c22eb38962127d4a153a17ae8b6da6042d045edd Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 12:38:45 +0800 Subject: [PATCH 19/26] =?UTF-8?q?=E6=8C=89=E7=AC=AC=E4=BA=94=E8=BD=AE?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=94=B6=E5=B0=BE=20M3=EF=BC=9A=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E7=94=A8=E4=BE=8B=E3=80=81--no-bundle=20?= =?UTF-8?q?=E4=B8=8E=E5=B9=B6=E5=8F=91=E5=8F=A3=E5=BE=84=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package_layout 的 source_candidates 用例改用 Path 构造期望值(Windows 上 path.join 产出反斜杠,硬编码正斜杠会让该用例在 Windows 必红) - 运维文档与技术方案改为「--no-bundle 也执行 staging、只跳过总号发布」;M1 验收行里已删除的 AGC_SKIP_RESOURCE_STAGING 表述同步 - 技术方案 §4.3 第 8 条与风险表的并发口径改为「不做并发支持:失败可读、保留已生成 staging,需要并发时外部串行化」,与实现一致 - 评审结论(Windows 真机):touch build.rs 后 resources 90 个文件 path/size/mtime 零变化;第二次 cargo build 1.13s fresh;三分支产物齐备;幂等、内容变更触发重建、fail-closed 均通过 --- .../src-tauri/build_support/package_layout.rs | 21 +++++++++++++++---- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 2 +- ...术方案】AGC随包资源staging归位-2026-09-26.md | 10 ++++----- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index f7de69dcd..324b0cb85 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -767,13 +767,26 @@ mod tests { .iter() .map(|path| path.to_string_lossy().to_string()) .collect::>(); + // 期望值必须用 Path 构造:Windows 上 path.join 产出反斜杠,硬编码正斜杠会让该用例在 Windows 必红。 + let expected = |root: &str, nested: bool| { + let base = Path::new(root).join("node_modules/@openai"); + if nested { + base.join( + "codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + ) + } else { + base.join("codex-darwin-arm64/vendor/aarch64-apple-darwin") + } + .to_string_lossy() + .to_string() + }; assert_eq!( rendered, [ - "/app/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", - "/app/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", - "/repo/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", - "/repo/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin", + expected("/app", false), + expected("/app", true), + expected("/repo", false), + expected("/repo", true), ] ); assert!(codex_source_candidates( diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 30f2b39e4..50f65e448 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | | Version | 1.0 | -| Status | in-progress(2026-09-27 实现完成,待 Windows 真机验收) | +| Status | in-progress(2026-09-28 已过 Windows 真机核心评审,剩打包一致性与客户端加载两项待验收) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index 2f5d10de7..1ae9f4349 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -101,7 +101,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 5. **清理语义**:准备步骤负责删除本轮布局不再产出的残留(否则旧组件会继续被打包),删除范围限于自己的 staging 目录,不得触碰受版本控制的文件(例如 `resources/codex/win-x64/NOTICE.md`)。 6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 -8. **并发安全**:同一 staging 目录的并发调用必须串行化或幂等收敛(dev 与 IDE 各自触发时不能互相破坏)。 +8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。 ### 4.4 build.rs 退化后的职责 @@ -135,7 +135,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 | Windows 发布 | `build-release.mjs`(`runTauriBuild`,现有 `stageRuntime(target)` 紧邻 spawn tauri) | 只有 Node 运行时走准备步骤 | 同一挂点串上全部 staging | | macOS 发布 | `build-macos-ci.mjs`(复用 `runTauriBuild`)→ `check-macos-bundle.mjs` | 同上 | 同上 | | 本机 Rust 门禁 | `ai-game-creator-shell:check:rust:shell`(Windows 上 `cargo test --no-run` 会跑 build script) | 依赖 build script 生成资源 | 校验器在该场景必须能只读通过;需要真实资源的用例沿用既有 fixture,不得依赖本机 staging 产物 | -| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 保持「不打包就不强制 staging」的口径,但校验器要按「未打包」放行 | +| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 改为:`--no-bundle` 也执行 staging(app 构建本身需要随包资源),只跳过总号发布;校验器在资源缺失时仍然 fail closed | | CI(Linux) | `.gitea/workflows/project-ci.yml` 的 AGC 分组 | 五条 staging 全为 no-op | 不需要新增准备步骤;分片与 smoke 命令不变 | ### 4.7 与现有机制的关系 @@ -199,7 +199,7 @@ M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随 | 忘记调用准备步骤(dev 或某个发布入口) | 资源缺失,打包或启动失败 | 校验器 fail closed + 入口测试断言「spawn tauri 前已调用准备步骤」 | | staging 缓存 key 不覆盖上游变化 | 静默发旧二进制 | key 含 lockfile `resolved`+`integrity`+布局版本+三元;manifest 校验作为第二道闸 | | 外部工具链步骤(unity/godot)搬出后顺序变化 | Windows 打包失败 | 准备步骤显式声明工具链前置检查;先在 Windows 上单独验证再合入 | -| 并发调用(dev 与 IDE 同时触发) | staging 目录损坏 | 原子替换 + 所有权校验 + 有界重试 | +| 并发调用同一 staging 目录 | 失败可读、不丢 staging | 不加锁也不支持并发:替换失败给出可执行提示并保留 staging 目录,需要并发时外部串行化 | | 迁移期两套生成并存 | 结果漂移 | 并存阶段以「准备步骤生成结果 == build script 生成结果」逐文件比对作为过渡判据 | 回滚点:准备步骤上线但校验器未启用前,任一步失败都可直接恢复 build script 写入分支,无需数据迁移。 @@ -208,9 +208,9 @@ M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随 | 里程碑 | 交付 | 停止条件 | |---|---|---| -| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿(`AGC_SKIP_RESOURCE_STAGING=1` 单独可跑),且不改变现有构建行为 | +| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿且不改变现有构建行为(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关在 M3 一并删除) | | M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 | -| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;**待 Windows 真机按验收清单确认** | +| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;已通过第五轮 Windows 真机评审:构建脚本不再写资源(90 个文件 0 变化)、第二次 `cargo build` 1.13s fresh、三分支产物齐备、幂等与 fail-closed 通过;仍待验收的是「安装包内产物路径/sha256 比对」与「三个编辑器分支客户端加载」 | 里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d56284c31..d569e32a7 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -88,7 +88,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter- AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。 -随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 不强制 staging。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 +随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 也会执行随包资源 staging(app 构建本身就需要这些资源),只跳过总号发布。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。 ### 本地 Rust 构建缓存与磁盘上限 From 0cf536b67566a37708a866177ea389e8b6cb9ba0 Mon Sep 17 00:00:00 2001 From: suzmii Date: Mon, 28 Sep 2026 21:35:56 +0800 Subject: [PATCH 20/26] =?UTF-8?q?=E6=81=A2=E5=A4=8D=20--no-bundle=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=BC=BA=E5=88=B6=E9=9A=8F=E5=8C=85=E8=B5=84?= =?UTF-8?q?=E6=BA=90=20staging=EF=BC=8C=E5=B9=B6=E5=90=8C=E6=AD=A5=20CI=20?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-release.mjs:回到「只有出包(非 --no-bundle)才执行 staging」,修复 Linux 上只编译的 release smoke 因强制 staging 去够 Windows 上游包而失败(保留 staging 与 cargo 共用同一 feature 集,以及未覆盖目标的自然跳过) - scripts/project-ci-workflow.test.ts:check:web 分组新增 bundled-resources:test 后的精确命令串断言同步 --- apps/ai-game-creator-shell/scripts/build-release.mjs | 3 +-- scripts/project-ci-workflow.test.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 632356a94..40dbeb520 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -469,9 +469,8 @@ export function runTauriBuild( const features = resolveEditorFeatures({ argv: args, target }); if (!args.includes('--no-bundle')) { stageRuntime(target); + stageBundled(target, { features }); } - // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging;只有总号发布是打包专属。 - stageBundled(target, { features }); const configPath = writeChannelConfigFile( channel, target, diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 6f793634a..96935e9f6 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -696,7 +696,7 @@ describe('project CI workflow', () => { 'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe( - 'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests', + 'npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests', ); expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:rust']).toBe( 'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell', From 1099b2dad4e2fcc6e91730d30423256128593c02 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 29 Sep 2026 13:42:26 +0800 Subject: [PATCH 21/26] =?UTF-8?q?=E8=AE=B0=E5=BD=95=20M3=20Windows=20?= =?UTF-8?q?=E6=89=93=E5=8C=85=E4=B8=80=E8=87=B4=E6=80=A7=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=E4=B8=8E=E4=B8=A4=E6=9D=A1=E6=8E=92=E9=9A=9C?= =?UTF-8?q?=E7=BB=8F=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M3 里程碑补「验收记录(2026-09-29)」:准备步骤连续三次复跑 32 个文件内容与 mtime 不变;NSIS 包内 plugins/ 与 resources/plugins 逐文件 sha256 完全一致;运行时三个相对路径与声明必需产物 17 项在包内全部命中 - 与迁移前两份对照(本机 2026-09-24 已安装包、合并基线 8f59c034f 的 build.rs 产物)逐项比对:路径集合与源码派生内容、Unity/Godot 产物一致 - 记录两条已定性差异:Cocos payload 只构建 windows-injection(导出面与迁移前相同)、MSVC 链接产物不可字节复现 - 记录新风险:准备步骤与应用构建共用 target///deps/cocos_editor_bridge.dll - 定性 CI 唯一红项 background_agent_runtime_can_write_memory_and_project_files 与本 PR 无关:合并基线同样失败,本机实测第二个 follow-up 请求 5.18s/4.87s 而预算 2s - pitfalls 新增 2026-09-29 条目:随包资源编译产物摘要不可复现与共用输出路径的核对口径 --- ...¼–辑器分支产物归位与症状层补丁清理-2026-09-26.md | 38 ++++++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 6 +++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index 50f65e448..e7893c506 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -3,7 +3,7 @@ | 字段 | 值 | | ----------- | --------------------------------------------------------------- | | Version | 1.0 | -| Status | in-progress(2026-09-28 已过 Windows 真机核心评审,剩打包一致性与客户端加载两项待验收) | +| Status | in-progress(2026-09-28 已过 Windows 真机核心评审;2026-09-29 完成打包一致性验收,客户端加载待有编辑器环境的机器;Cocos payload feature 集差异待裁决) | | Date | 2026-09-26 | | Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` | @@ -40,11 +40,45 @@ ## 验收标准 - [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。 -- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。 +- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。(2026-09-29 验收:路径集合与源码派生内容、Unity/Godot 产物逐字节一致;Cocos payload 因声明只构建 `windows-injection` 而与迁移前不同,且 MSVC 链接产物本身不可字节复现,见下方验收记录,口径待裁决) - [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。 - [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。 - [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。 +## 验收记录(2026-09-29,Windows 本机) + +### 打包一致性(验收标准第 2 条) + +准备步骤按发布入口同样的参数复跑(`prepareBundledResources({ target: 'x86_64-pc-windows-msvc', features: Set('unity-editor-execute','godot-editor-execute','cocos-editor-injection'), profile: 'release' })`):`cocos-bridge-build 已构建`、`unity-helper-publish` / `godot-extension-build 命中缓存`、`plugins 重新生成`;随后连续三次复跑,`resources/plugins` 的 32 个文件内容与 mtime 均不再变化(稳定产出)。 + +出包走同一发布入口(`runTauriBuild` + `--bundles nsis`),并临时把 `bundle.createUpdaterArtifacts` 置 false——本机没有 updater 签名私钥,只出安装包;产物 `target/x86_64-pc-windows-msvc/release/bundle/nsis/陶泥儿开发版_0.1.67_x64-setup.exe`(166452206 B,sha256 `04a0be8ab8f54d8f032ce86d3e5c7a99c1dd6e81f71d49b63486827b11a01940`)。`tauri.windows.conf.json` 里 `resources/plugins → plugins` 是目录级映射。 + +包内核对:7z 解包得 2108 个文件,包内 `plugins/` 的 32 个文件与准备步骤产出的 `resources/plugins` **逐文件 sha256 完全一致**;`editor_adapters.rs` 的三个运行时相对路径(`UNITY_ATTACH_HELPER_RELATIVE` / `GODOT_BRIDGE_PAYLOAD_RELATIVE` / `COCOS_BRIDGE_PAYLOAD_RELATIVE`)加上声明里的必需产物共 17 项在包内全部命中。 + +迁移前对照取两份:本机已安装的 2026-09-24 包(`%LOCALAPPDATA%\陶泥儿开发版\plugins`,迁移前产品产物),以及把 `src-tauri` 整体切回合并基线 `8f59c034f` 后在同一个工作树里跑 `cargo build --release --target x86_64-pc-windows-msvc --features=unity-editor-execute,godot-editor-execute,cocos-editor-injection` 得到的 `resources/plugins`。三份对照路径集合一致,源码派生内容(JS/HTML/JSON/license/notice)逐字节一致,Unity helper、Godot 扩展逐字节一致。 + +两处已定性的差异: + +1. **Cocos payload 的构建 feature 集不同(需裁决口径)**:迁移前由同一次应用构建产出(`windows-bootstrap` + `windows-injection`,345088 B);准备步骤按声明只构建 `windows-injection`(26112 B,见 2026-09-27 决策日志条)。两者导出面完全相同(`DllMain`、`cocos_editor_bridge_bootstrap_source`),差掉的是宿主侧 bootstrap 传输(`reqwest`/`tungstenite`/`inspector`),注入进程不使用;但按「内容摘要与迁移前逐项一致」的字面判据不成立。 +2. **MSVC 链接产物不可字节复现**:同一 source / feature / profile / target 连续构建的 payload 摘要不同(除 PE `TimeDateStamp` 外还有 22 字节 RSDS GUID 差异);.NET publish 相反是确定的(Unity helper 跨两次重新发布逐字节一致),Godot 因 `buildId` 早退未重链也保持逐字节一致。因此「摘要一致」只在源码派生物、.NET 产物与命中工具链内部缓存的产物上成立。 + +新发现的风险(本轮未修,不影响上述结论): + +- 准备步骤的 `cocos-bridge-build` 与同一次应用构建写同一个输出路径 `target///deps/cocos_editor_bridge.dll`(两个 feature 单元同名产物),准备步骤的候选查找可能取到另一单元刚写下的文件;本轮实测两者交替后 cargo 会多一次重链。建议让准备步骤在独立 target 目录构建,或与应用的 feature 集对齐后从同一单元取产物。 + +### 客户端编辑器分支(验收标准第 5 条) + +本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。替代证据见上:三类组件都落在运行时解析的固定相对路径上,注入 payload 的导出面与迁移前一致。 + +### 顺带定性:CI 唯一红项与本 PR 无关 + +run 2980(HEAD `0cf536b6`)唯一失败项是 `tests::sessions::background_agent_runtime_can_write_memory_and_project_files`(`src-tauri/src/tests/sessions.rs:405`,第二个 provider follow-up 请求 `recv_timeout(2s)` 超时): + +- 本 PR 对这条路径零改动:`src/` 下只有 `main.rs`(`#[cfg(test)]` 引入 `package_layout` 单测)与 `agent/codex_cli.rs`(去掉 `codex_package_metadata` 测试模块)两处**测试编译期**改动;`sessions.rs` 与 agent runtime 与合并基线逐字节相同。 +- 把 `src-tauri` 整体切回合并基线 `8f59c034f` 后,同一条命令在本机失败在同一断言(`second llm request: Timeout`)。 +- 本机实测第二个 follow-up 请求耗时 **5.18s / 4.87s**(两次),而测试预算 2s:该断言在本机裕量不足。master run 2979(lane 2 shard 3)也因另一条并发用例失败,属同一类时间预算抖动。 +- 结论:既有测试时间预算问题,不在本 PR 内顺手修。 + ## 验证步骤(Windows 侧执行清单) 准备:切到本里程碑分支,`npm ci`(需装上 `@openai/codex-win32-x64`),确认 `spacetime --version` 与 `server-rs` 锁定版本一致(仅本地 dev 需要)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 48f3909a7..5da882ec3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -24,6 +24,12 @@ > 策划历史条目边界:旧策划 V1/V2 已全部退役,当前入口仅使用 Design Agent。下文带日期的旧 Planning V2、Fast GDD、`plan.submit_gdd`、旧 IPC/模块记录仅用于追溯,不能作为恢复旧代码、身份门禁或专属测试的依据;共享问题需在现役调用上核查。现行合同见[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 +## 2026-09-29 随包资源的编译产物摘要不可复现,且准备步骤与应用构建共用同一输出路径 + +- **摘要不可复现**:同一 source / feature / profile / target 连续构建的 `cocos-editor-bridge` payload 摘要不同(除 PE `TimeDateStamp` 外还有 RSDS GUID 等 22 字节差异),所以「与迁移前逐项一致」只能对**源码派生物**(JS/HTML/JSON/license/notice,逐字节比对)、**.NET publish 产物**(Unity helper 跨两次重新发布逐字节一致)和**命中工具链内部缓存的产物**(Godot 走 `buildId` 早退,不重链)成立。核对打包一致性时不要用编译产物的 sha256 判回归,改比路径集合 + 导出面(`DllMain`、`cocos_editor_bridge_bootstrap_source`)+ 源码派生物摘要。 +- **共用输出路径**:准备步骤的 `cocos-bridge-build` 用 `cargo build -p cocos-editor-bridge --features windows-injection`,而应用构建带的是 `windows-bootstrap + windows-injection`(`cocos-editor-injection` 的闭包),两个单元写同一个 `target///deps/cocos_editor_bridge.dll`。后构建的单元覆盖先构建的产物时,准备步骤的候选查找会取到「上一次遗留的另一个单元」,交替构建还会多一次重链。要改就从这里改:让准备步骤用独立 target 目录,或与应用的 feature 集对齐。 +- **验证方式**:`runTauriBuild`(`scripts/build-release.mjs`)+ `--bundles nsis`,再 `7z x` 解包比 `plugins/**`;准备步骤连续三次复跑要求 `resources/plugins` 的 32 个文件内容与 mtime 全不变。 + ## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出 - **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。 From 12d6ed822304ff15ac74b159cb7450b666b486cf Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 29 Sep 2026 13:55:06 +0800 Subject: [PATCH 22/26] =?UTF-8?q?=E8=A1=A5=E8=AE=B0=E6=89=93=E5=8C=85?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=9A=84=E5=90=AF=E5=8A=A8=E4=B8=8E?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=8A=95=E5=BD=B1=E5=8F=96=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M3 里程碑「客户端编辑器分支」补本轮实测:安装包解出的客户端在 http://tauri.localhost/ 生产态正常起首页,包内插件与 Skill 包被读取 - 记录 CDP 主世界可读 list_agc_plugins / list_agc_skill_catalog 的自动化路径与 Godot 插件按项目类型可见的现役语义 - 记录「缺适配器 / 缺组件 / 编辑器没开」三类失败文案,供补做时分类记录 --- ...‹碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md index e7893c506..9048bfd04 100644 --- a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md +++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md @@ -68,7 +68,14 @@ ### 客户端编辑器分支(验收标准第 5 条) -本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。替代证据见上:三类组件都落在运行时解析的固定相对路径上,注入 payload 的导出面与迁移前一致。 +本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。 + +已完成的自动化前段(本轮实测,用安装包解出的客户端、不安装): + +- 从 NSIS 包 7z 解出后直接运行 `genarrative-ai-game-creator-shell.exe`:窗口落在 `http://tauri.localhost/`(生产态嵌入前端,不是 `devUrl`),首页正常渲染,最近项目与模板库可见——说明包内 `plugins/`、`skills`、模板资源都被正确读取(对照:用 `cargo build --release` 直接编出来的 exe 没有 `custom-protocol`,会去连 `127.0.0.1:3080` 并落到 chrome 错误页,不能拿它当打包客户端)。 +- CDP 主世界(`page.target().createCDPSession()` + `Runtime.evaluate`)可读只读投影:`list_agc_plugins` 返回 `agc-cocos-editor` / `agc-unity-editor`(`builtin=true`、`hasRuntime=true`、`adapter` 正确),`list_agc_skill_catalog` 返回 8 条 —— 插件的 JS 入口与 Skill 包都按声明进包。 +- `agc-godot-editor` 插件不在该列表里符合现役语义:`plugin_host.rs::plugin_matches_project` 只在当前项目是 Godot 工程(根或一层子目录有 `project.godot`)时才让它可见。 +- 三种编辑器分支的判据入口:`require_plugin_adapter` 缺适配器时报「当前客户端不支持 X 编辑器桥接」,适配器在 payload/helper 缺失时报各自缺组件文案(如 Godot「插件缺少原生 DLL 资源」),编辑器没开时报探测失败——补做时要按这三类分开记录。 ### 顺带定性:CI 唯一红项与本 PR 无关 From d2d78fa3d4296e9dfbfeb37b8ff41fd770fd75af Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 19:12:30 +0800 Subject: [PATCH 23/26] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E9=9A=8F=E5=8C=85?= =?UTF-8?q?=E8=B5=84=E6=BA=90staging=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 --no-bundle 构建前的资源准备接线。 拒绝插件随包目录中的未声明文件并同步Rust校验。 增加staging替换失败后的旧资源恢复与回归测试。 --- .../scripts/build-release.mjs | 3 +- .../scripts/build-release.test.mjs | 2 + .../scripts/check-package-layout.mjs | 1 + .../scripts/prepare-bundled-resources.mjs | 104 +++++- .../prepare-bundled-resources.test.mjs | 333 ++++++++++++++++++ .../build_support/package-layout.generated.rs | 1 + .../src-tauri/build_support/package_layout.rs | 329 +++++++++++++++-- 7 files changed, 733 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index eccab786c..00f144de6 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -505,10 +505,11 @@ export function runTauriBuild( const tauriArguments = buildTauriBuildArguments(args, context.target); const { channel, target } = context; const features = resolveEditorFeatures({ argv: args, target }); + // --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。 if (!args.includes('--no-bundle')) { stageRuntime(target); - stageBundled(target, { features }); } + stageBundled(target, { features }); const configPath = writeChannelConfigFile( channel, target, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index dcb19454d..e6438ed7f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1001,6 +1001,7 @@ test('release stages Node before Tauri and injects its resource mapping only for events.push('bundled'); }, spawn(_binary, args) { + events.push('build'); const config = JSON.parse( readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'), ); @@ -1013,6 +1014,7 @@ test('release stages Node before Tauri and injects its resource mapping only for return { status: 0 }; }, }); + assert.deepEqual(events, ['bundled', 'build']); assert.throws( () => runTauriBuild(['--target', windowsTarget], context, { diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs index 0928c0b7f..5682da895 100755 --- a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs +++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs @@ -485,6 +485,7 @@ function renderLibraryStaging(entry) { ['targets', rustStrings(entry.targets)], ['features', rustStrings(entry.features)], ['layout', rustString(entry.layout)], + ['files', rustStrings(entry.files)], ], 1, ); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index 6212becba..f22e34c00 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -488,8 +488,11 @@ function buildUnitInto( } function stageAtomically(unitPath, builder) { - const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`; + const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`; + const stagingPath = `${unitPath}-staging-${suffix}`; + const backupPath = `${unitPath}-backup-${suffix}`; rmSync(stagingPath, { recursive: true, force: true }); + rmSync(backupPath, { recursive: true, force: true }); mkdirSync(stagingPath, { recursive: true }); try { builder(stagingPath); @@ -497,15 +500,31 @@ function stageAtomically(unitPath, builder) { rmSync(stagingPath, { recursive: true, force: true }); throw error; } - rmSync(unitPath, { recursive: true, force: true }); + + const hadPrevious = existsSync(unitPath); try { + if (hadPrevious) { + renameSync(unitPath, backupPath); + } renameSync(stagingPath, unitPath); } catch (error) { - // 并发调用未做加锁(见技术方案 §4.3):这里只保证失败可读、且不丢已经生成好的 staging。 + let restored = !hadPrevious; + if (hadPrevious && existsSync(backupPath)) { + try { + if (existsSync(unitPath)) { + rmSync(unitPath, { recursive: true, force: true }); + } + renameSync(backupPath, unitPath); + restored = true; + } catch { + restored = false; + } + } fail( - `替换 ${unitPath} 失败(${error.code ?? error.message}):同一资源目录可能正被另一个准备步骤进程写入;staging 已保留在 ${stagingPath},确认没有并发进程后重试`, + `替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`, ); } + rmSync(backupPath, { recursive: true, force: true }); } function prepareCodex({ @@ -860,6 +879,30 @@ function subdirectoryEnabled(subdirectory, target, features) { return matchesContains && matchesTarget && matchesFeatures; } +function collectTreeFiles(root, label) { + const files = []; + const stack = [['', root]]; + while (stack.length > 0) { + const [prefix, directory] = stack.pop(); + if (!existsSync(directory)) { + continue; + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail(`${label}不允许符号链接:${entryPath}`); + } + if (entry.isDirectory()) { + stack.push([relative, entryPath]); + } else if (entry.isFile()) { + files.push(relative); + } + } + } + return files.sort(); +} + /// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。 function skipDirectory(rules, name) { return ( @@ -958,7 +1001,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { for (const staging of declaration.plugins.libraryStaging ?? []) { if ( plugin.name !== staging.plugin || - libraryStagingEnabled(staging, target, features) + !libraryStagingEnabled(staging, target, features) ) { continue; } @@ -978,7 +1021,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) { for (const payload of declaration.plugins.nativePayloads ?? []) { if ( plugin.name !== payload.plugin || - nativePayloadEnabled(payload, target, features) + !nativePayloadEnabled(payload, target, features) ) { continue; } @@ -1007,8 +1050,11 @@ function pluginTreeMatches( if (!existsSync(destination)) { return false; } + const allowedFiles = new Set(); for (const plugin of plugins) { const pluginDestination = path.join(destination, plugin.name); + const pluginPrefix = `${plugin.name}/`; + allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`); if ( !existsSync( path.join(pluginDestination, declaration.plugins.manifestFileName), @@ -1022,17 +1068,24 @@ function pluginTreeMatches( } const stagedDirectory = path.join(pluginDestination, subdirectory.path); if (!subdirectoryEnabled(subdirectory, target, features)) { - // 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。 if (existsSync(stagedDirectory)) { return false; } continue; } + const relativePrefix = `${plugin.name}/${subdirectory.path}/`; const sourceRoot = path.join(plugin.root, subdirectory.path); if (subdirectory.origin !== 'source') { if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) { return false; } + for (const relative of walkFiles( + declaration.plugins, + sourceRoot, + '插件资源', + )) { + allowedFiles.add(`${relativePrefix}${relative}`); + } continue; } for (const relative of walkFiles( @@ -1046,6 +1099,7 @@ function pluginTreeMatches( subdirectory.path, relative, ); + allowedFiles.add(`${relativePrefix}${relative}`); if (!existsSync(staged)) { return false; } @@ -1057,8 +1111,32 @@ function pluginTreeMatches( } } } + for (const staging of declaration.plugins.libraryStaging ?? []) { + if ( + plugin.name === staging.plugin && + libraryStagingEnabled(staging, target, features) + ) { + for (const relative of staging.files) { + allowedFiles.add( + `${plugin.name}/${staging.sourceSubdirectory}/${relative}`, + ); + } + } + } + for (const payload of declaration.plugins.nativePayloads ?? []) { + if ( + plugin.name === payload.plugin && + nativePayloadEnabled(payload, target, features) + ) { + allowedFiles.add( + `${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`, + ); + } + } } - return true; + return collectTreeFiles(destination, '插件随包目录').every((relative) => + allowedFiles.has(relative), + ); } function assertOwnedPluginRoot(destination, plugins) { @@ -1504,10 +1582,16 @@ function preparePlugins({ }); return { summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`, - record: { fingerprint }, + record: { + fingerprint: pluginSourceFingerprint( + declaration, + plugins, + target, + features, + ), + }, }; } - /// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。 export function prepareBundledResources({ target = resolveHostTarget(), diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs index 5e3c6f54a..c808ab43c 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; @@ -286,6 +287,41 @@ function prepare(fixture, overrides = {}) { }); } +/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。 +/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门; +/// body 是同步的,注入窗口内不会有别的调用跑进来。 +function withRenameFailure(predicate, body) { + const original = fs.renameSync; + let injected = false; + fs.renameSync = (from, to) => { + if ( + !injected && + predicate(path.resolve(String(from)), path.resolve(String(to))) + ) { + injected = true; + throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' }); + } + return original.call(fs, from, to); + }; + syncBuiltinESMExports(); + try { + return body(); + } finally { + fs.renameSync = original; + syncBuiltinESMExports(); + } +} + +/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。 +function expectThrow(body) { + try { + body(); + } catch (error) { + return error; + } + throw new Error('预期抛错但调用成功了'); +} + test('stages declared codex components with manifest and preserved notice', () => { const fixture = buildFixture(); try { @@ -824,3 +860,300 @@ test('delivers editor branch artifacts declared as prepared or library staging', fixture.cleanup(); } }); + +/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。 +const ALL_WINDOWS_FEATURES = new Set([ + 'unity-editor-execute', + 'godot-editor-execute', + 'cocos-editor-injection', +]); + +function pluginStaged(fixture, relative) { + return path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + relative, + ); +} + +test('keeps the previous codex resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const declaration = fixture.declaration; + const layout = declaration.codex.targets.find( + (entry) => entry.target === WINDOWS_TARGET, + ); + const unit = path.join( + fixture.destinationRoot, + declaration.codex.resourceDirectory, + layout.directory, + ); + const before = snapshot(unit); + const component = layout.files.find((relative) => + relative.includes('code-mode-host'), + ); + const vendor = path.join( + fixture.appRoot, + `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`, + ); + // 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。 + fs.writeFileSync(path.join(vendor, component), 'changed component\n'); + // 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。 + fs.rmSync(fixture.recordPath); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(unit), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*win-x64.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(unit), + before, + '替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变', + ); + assert.equal( + fs.readFileSync( + path.join(unit, declaration.codex.noticeFileName), + 'utf8', + ), + 'windows codex notice\n', + '受版本控制的第三方声明必须还在原位', + ); + const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1]; + assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`); + // 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。 + const siblings = fs.readdirSync(path.dirname(unit)); + assert.ok(siblings.includes(path.basename(unit))); + assert.ok(siblings.includes(path.basename(stagingPath))); + assert.ok( + siblings.every((entry) => !entry.includes('-backup-')), + '恢复成功后不得留下 backup 目录', + ); + assert.equal( + fs.readFileSync(path.join(stagingPath, component), 'utf8'), + 'changed component\n', + '没有落盘的新产物必须留在 staging 里供人工检查', + ); + + // 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。 + const summaries = prepare(fixture); + assert.match(summaries[0], /重新生成/u); + assert.equal( + fs.readFileSync(path.join(unit, component), 'utf8'), + 'changed component\n', + ); + } finally { + fixture.cleanup(); + } +}); + +test('keeps the previous plugin resources when the replacement fails', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + // 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。 + fs.writeFileSync( + path.join(destination, 'agc-demo-editor/src/rogue.mjs'), + 'rogue\n', + ); + const before = snapshot(destination); + + const error = expectThrow(() => + withRenameFailure( + (from, to) => to === path.resolve(destination), + () => prepare(fixture), + ), + ); + assert.match(error.message, /替换 .*plugins.* 失败/u); + assert.match(error.message, /旧资源已恢复/u); + assert.deepEqual( + snapshot(destination), + before, + '替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留', + ); + + prepare(fixture); + assert.ok( + !fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')), + '注入消失后重试必须成功并清掉额外文件', + ); + assert.ok( + fs.existsSync( + path.join(destination, 'agc-demo-editor/panels/panel.html'), + ), + '重试后声明的随包内容必须齐全', + ); + } finally { + fixture.cleanup(); + } +}); + +test('clears undeclared and hidden entries left in the staged plugin tree', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + // 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。 + for (const relative of [ + 'agc-demo-editor/extra/rogue.txt', + 'agc-demo-editor/.cache/tmp.bin', + 'agc-demo-editor/src/.env', + ]) { + const file = pluginStaged(fixture, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'residue\n'); + } + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + for (const relative of [ + 'agc-demo-editor/extra', + 'agc-demo-editor/.cache', + 'agc-demo-editor/src/.env', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `未声明的残留 ${relative} 必须被清掉`, + ); + } + for (const relative of [ + 'agc-demo-editor/plugin.json', + 'agc-demo-editor/src/entry.mjs', + 'agc-demo-editor/panels/panel.html', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `声明的随包内容 ${relative} 必须还在`, + ); + } + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'), + ), + '跳过规则在重建后依然生效', + ); + } finally { + fixture.cleanup(); + } +}); + +test('removes staged plugin files whose source has been deleted', () => { + const fixture = buildFixture(); + try { + prepare(fixture); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')), + '前置条件:源码里的文件已经随包', + ); + fs.rmSync( + path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'), + ); + + const summaries = prepare(fixture); + assert.match(summaries[1], /plugins 重新生成/u); + assert.ok( + !fs.existsSync( + pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'), + ), + '源码里已删除的文件不得留在随包目录', + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')), + '同一插件的其他声明内容必须还在', + ); + // 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。 + assert.match(prepare(fixture)[1], /命中缓存/u); + } finally { + fixture.cleanup(); + } +}); + +test('clears staged prepared payloads when their feature is disabled', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `前置条件:feature 全开时 ${relative} 必须随包`, + ); + } + + prepare(fixture, { features: new Set() }); + for (const relative of [ + 'agc-cocos-editor/native/payload', + 'agc-unity-editor/dotnet', + 'agc-godot-editor/native', + ]) { + assert.ok( + !fs.existsSync(pluginStaged(fixture, relative)), + `feature 关闭后 ${relative} 不得作为残留继续随包`, + ); + } + for (const plugin of [ + 'agc-cocos-editor', + 'agc-unity-editor', + 'agc-godot-editor', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)), + `feature 关闭不得动到 ${plugin} 的声明内容`, + ); + assert.ok( + fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)), + `feature 关闭不得动到 ${plugin} 的源码内容`, + ); + } + } finally { + fixture.cleanup(); + } +}); + +test('treats declared prepared and library-staging artifacts as owned on a second run', () => { + const fixture = buildFixture(); + try { + prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + const destination = path.join( + fixture.destinationRoot, + fixture.declaration.plugins.destinationDirectory, + ); + const before = snapshot(destination); + + const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES }); + assert.match( + summaries[1], + /plugins 命中缓存(未写入/u, + 'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建', + ); + assert.deepEqual( + snapshot(destination), + before, + '命中缓存时一个字节、一个时间戳都不许动', + ); + for (const relative of [ + 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll', + 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe', + 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll', + 'agc-godot-editor/native/gdextension/vendor/provenance.json', + ]) { + assert.ok( + fs.existsSync(pluginStaged(fixture, relative)), + `已声明产物 ${relative} 不得因为缓存判定被删掉`, + ); + } + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs index e6d626a7d..931290eb7 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs @@ -153,6 +153,7 @@ LibraryStaging { targets: &["x86_64-pc-windows-msvc"], features: &["godot-editor-execute"], layout: "godot-bundle", + files: &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"], } ], skip_directory_names: &["target", "node_modules"], diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index 48f24c023..b334fe265 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -59,6 +59,9 @@ pub struct Subdirectory { } /// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。 +/// +/// `files` 是相对 `source_subdirectory` 的随包文件清单:源码工作区同位目录里还有构建输入与 +/// 测试,只有这些文件会归位到随包目录,校验也以这份清单为准。 #[derive(Debug)] pub struct LibraryStaging { pub plugin: &'static str, @@ -66,6 +69,7 @@ pub struct LibraryStaging { pub targets: &'static [&'static str], pub features: &'static [&'static str], pub layout: &'static str, + pub files: &'static [&'static str], } #[derive(Debug)] @@ -290,8 +294,11 @@ pub fn declared_relative_path(relative: &str) -> PathBuf { /// 只读校验插件随包工作区。 /// -/// 声明为源码派生的内容必须与仓库源码逐文件一致(清单 + 逐文件 sha256),构建期派生的子目录 -/// 只要求存在(内容由产出它的构建步骤负责),整树不得出现符号链接;编辑器分支产物不动。 +/// 校验分两层:先按当前目标与已启用 feature 构造允许文件集合(插件清单、生效的随包子目录、 +/// 生效的随包库),再要求随包目录由这些文件恰好组成——清单与源码派生的子目录逐文件 sha256 +/// 一致,构建期派生的子目录与源码工作区同位目录逐文件对齐(内容由产出它的构建步骤负责), +/// 随包库的声明文件齐备;集合之外的任何文件一律拒绝(未声明的根条目、源码子目录里的残留文件、 +/// 未启用 feature 的产物)。整树不得出现符号链接,本函数不做任何写入。 pub fn validate_staged_plugins( source_root: &Path, destination_root: &Path, @@ -307,7 +314,36 @@ pub fn validate_staged_plugins( destination_root.display() )); } - for plugin in plugin_directories(source_root, plugins().manifest_file_name)? { + let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?; + let declared_names = declared_plugins + .iter() + .map(|plugin| plugin.name.as_str()) + .collect::>(); + for entry in std::fs::read_dir(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })? { + let entry = entry.map_err(|error| format!("插件随包目录项不可读:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("插件随包目录项类型不可读:{error}"))?; + if file_type.is_symlink() { + return Err(format!( + "插件随包目录不允许符号链接:{}", + entry.path().display() + )); + } + let name = entry.file_name().to_string_lossy().to_string(); + if !declared_names.contains(name.as_str()) { + return Err(format!( + "插件随包目录存在未声明条目:{}", + entry.path().display() + )); + } + } + for plugin in &declared_plugins { let staged = destination_root.join(&plugin.name); let source_manifest = plugin.path.join(plugins().manifest_file_name); let staged_manifest = staged.join(plugins().manifest_file_name); @@ -328,39 +364,34 @@ pub fn validate_staged_plugins( staged_manifest.display() )); } + let mut allowed = BTreeSet::new(); + allowed.insert(plugins().manifest_file_name.to_string()); for subdirectory in plugins().subdirectories { - if !subdirectory_enabled(subdirectory, target, &feature_enabled) { + if !subdirectory_applies_to_plugin(subdirectory, &plugin.name) + || !subdirectory_enabled(subdirectory, target, &feature_enabled) + { continue; } let relative = declared_relative_path(subdirectory.path); - let source = plugin.path.join(&relative); + let source_directory = plugin.path.join(&relative); let staged_directory = staged.join(&relative); - if subdirectory_is_prepared(subdirectory) { - if source.exists() && !staged_directory.is_dir() { - return Err(format!( - "随包构建期产物缺失:{}(插件 {})", - staged_directory.display(), - plugin.name - )); - } - continue; - } - if !source.is_dir() { - continue; - } - if !staged_directory.is_dir() { - return Err(format!( - "随包插件缺少必需目录:{}(插件 {})", - staged_directory.display(), - plugin.name - )); - } - for relative_file in collect_sources(&source)? { - let source_file = source.join(declared_relative_path(&relative_file)); - let staged_file = staged_directory.join(declared_relative_path(&relative_file)); + for file in source_subdirectory_files(&source_directory)? { + allowed.insert(format!("{}/{}", subdirectory.path, file)); + let staged_file = staged_directory.join(declared_relative_path(&file)); if !staged_file.is_file() { - return Err(format!("随包插件缺少文件:{}", staged_file.display())); + return Err(if subdirectory_is_prepared(subdirectory) { + format!( + "随包已准备产物缺少文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + ) + } else { + format!("随包插件缺少文件:{}", staged_file.display()) + }); } + if !subdirectory_is_source_derived(subdirectory) { + continue; + } + let source_file = source_directory.join(declared_relative_path(&file)); let source_digest = sha256_file(&source_file).map_err(|error| { format!("读取插件文件失败 {}:{error}", source_file.display()) })?; @@ -375,11 +406,45 @@ pub fn validate_staged_plugins( } } } + for staging in plugins().library_staging { + if staging.plugin != plugin.name.as_str() + || !library_staging_enabled(staging, target, &feature_enabled) + { + continue; + } + for file in staging.files { + let relative = format!("{}/{}", staging.source_subdirectory, file); + let staged_file = staged.join(declared_relative_path(&relative)); + if !staged_file.is_file() { + return Err(format!( + "随包库缺少声明文件:{}(请先执行随包资源准备步骤)", + staged_file.display() + )); + } + allowed.insert(relative); + } + } + for relative in collect_tree_files(&staged)? { + if !allowed.contains(&relative) { + return Err(format!( + "随包插件存在未声明文件:{}(目标 {target} 与当前 feature 组合不允许;请先执行随包资源准备步骤)", + staged.join(declared_relative_path(&relative)).display() + )); + } + } } collect_tree_files(destination_root)?; Ok(()) } +/// 源码工作区同位子目录里的文件集合;该目录不存在时为空集。 +fn source_subdirectory_files(source_directory: &Path) -> Result, String> { + if !source_directory.is_dir() { + return Ok(BTreeSet::new()); + } + collect_sources(source_directory) +} + /// 声明为「由准备步骤按源码派生」的子目录。 pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool { subdirectory.origin == "source" @@ -1124,6 +1189,29 @@ mod tests { .expect("write staged entry"); } + /// 在源码与随包目录各写一份同名同内容的插件目录(随包侧即准备步骤的复制结果)。 + fn write_plugin_copy( + source_root: &Path, + destination_root: &Path, + plugin_name: &str, + relative_files: &[&str], + ) { + for root in [source_root, destination_root] { + let plugin = root.join(plugin_name); + fs::create_dir_all(&plugin).expect("create plugin"); + fs::write( + plugin.join("plugin.json"), + format!("{{\"name\":\"{plugin_name}\"}}\n"), + ) + .expect("write manifest"); + for relative in relative_files { + let path = plugin.join(declared_relative_path(relative)); + fs::create_dir_all(path.parent().expect("path parent")).expect("create parent"); + fs::write(&path, format!("{relative}\n")).expect("write file"); + } + } + } + #[test] fn staged_plugins_are_checked_against_repository_sources() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1205,6 +1293,189 @@ mod tests { assert!(error.contains("符号链接"), "{error}"); } + /// 随包目录里出现源码侧不存在的文件(源码子目录残留、插件根目录未知文件、未声明的根条目)必须被拒绝。 + #[test] + fn staged_plugins_reject_undeclared_files() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + + let leftover = destination_root.join("agc-demo-editor/src/leftover.mjs"); + fs::write(&leftover, "stale\n").expect("write leftover"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝源码子目录里的残留文件"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("leftover.mjs"), "{error}"); + fs::remove_file(&leftover).expect("remove leftover"); + + let root_file = destination_root.join("agc-demo-editor/README.md"); + fs::write(&root_file, "stale\n").expect("write plugin root file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝插件根目录的未知文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&root_file).expect("remove plugin root file"); + + let stray = destination_root.join("stray.txt"); + fs::write(&stray, "stale\n").expect("write stray entry"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("必须拒绝未声明的根条目"); + assert!(error.contains("未声明条目"), "{error}"); + } + + /// 源码侧删掉文件后,随包目录里的旧副本属于未声明文件(随包目录不接受比源码多出的内容)。 + #[test] + fn staged_plugins_reject_files_removed_from_sources() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_fixture(&source_root, &destination_root); + fs::remove_file(source_root.join("agc-demo-editor/src/entry.mjs")).expect("remove source"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("源码删除后的随包副本必须被拒绝"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("entry.mjs"), "{error}"); + } + + /// Cocos 的 `native/payload` 由构建产出:只在 windows 且 feature 开启时随包,其余组合下其产物必须被拒绝。 + #[test] + fn staged_plugins_reject_prepared_artifacts_of_disabled_features() { + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + write_plugin_copy( + &source_root, + &destination_root, + "agc-cocos-editor", + &["src/entry.mjs", "native/payload/cocos-editor-bridge.dll"], + ); + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "cocos-editor-injection", + ) + .expect("feature 开启时 prepared 产物合法"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + assert!(error.contains("cocos-editor-bridge.dll"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "aarch64-apple-darwin", + |_| true, + ) + .expect_err("目标不含 windows 时必须拒绝 prepared 产物"); + assert!(error.contains("未声明文件"), "{error}"); + } + + /// 随包库只归位声明里的文件:源码工作区的构建输入不进随包目录,随包目录多出或缺少文件都必须被拒绝。 + #[test] + fn staged_plugins_follow_declared_library_staging_files() { + let staging = PLUGINS + .library_staging + .iter() + .find(|entry| entry.layout == "godot-bundle") + .expect("godot staging"); + let temp = tempfile::tempdir().expect("tempdir"); + let source_root = temp.path().join("plugins"); + let destination_root = temp.path().join("resources/plugins"); + let mut relative_files = vec!["src/entry.mjs".to_string()]; + relative_files.extend( + staging + .files + .iter() + .map(|file| format!("{}/{}", staging.source_subdirectory, file)), + ); + let relative_files = relative_files + .iter() + .map(String::as_str) + .collect::>(); + write_plugin_copy( + &source_root, + &destination_root, + "agc-godot-editor", + &relative_files, + ); + let source_only = source_root.join("agc-godot-editor/native/gdextension/src/native.cpp"); + fs::create_dir_all(source_only.parent().expect("path parent")).expect("create source dir"); + fs::write(&source_only, "void build_input() {}\n").expect("write source-only file"); + + validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect("声明文件齐备时随包库合法"); + + let stale = + destination_root.join("agc-godot-editor/native/gdextension/bin/win-x64/stale.dll"); + fs::write(&stale, "stale\n").expect("write stale library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝随包库里未声明的文件"); + assert!(error.contains("未声明文件"), "{error}"); + fs::remove_file(&stale).expect("remove stale library file"); + + let declared = destination_root + .join("agc-godot-editor") + .join(declared_relative_path(&format!( + "{}/{}", + staging.source_subdirectory, staging.files[0] + ))); + fs::remove_file(&declared).expect("remove declared library file"); + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |name| name == "godot-editor-execute", + ) + .expect_err("必须拒绝缺少声明文件的随包库"); + assert!(error.contains("缺少声明文件"), "{error}"); + + let error = validate_staged_plugins( + &source_root, + &destination_root, + "x86_64-pc-windows-msvc", + |_| false, + ) + .expect_err("feature 关闭后必须拒绝随包库"); + assert!(error.contains("未声明文件"), "{error}"); + } + #[test] fn collect_sources_applies_declared_skip_rules() { let temp = tempfile::tempdir().expect("tempdir"); From 1dbbdcf329b9fed5207c2c91f8d964abff35eca0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 19:28:16 +0800 Subject: [PATCH 24/26] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=87=86=E5=A4=87=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在启动Vite前完成随包资源staging,避免Windows监听目录导致rename返回EPERM。 同步更新开发入口生命周期测试。 --- apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs | 5 +++-- apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 6c82e54cb..a5be013ac 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -152,6 +152,9 @@ async function runTauriDev( } try { + const devFeatures = readDevCargoFeatures(); + prepareResources(devFeatures); + // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。 const preparation = prepareFrontend(endpoint, { signal: preparationAbort.signal, onChild(frontend) { @@ -163,8 +166,6 @@ async function runTauriDev( shutdownRequested.then(() => false), ]); if (!prepared || shutdownSignal) return 1; - const devFeatures = readDevCargoFeatures(); - prepareResources(devFeatures); const tauriArguments = buildTauriArguments( withDevCargoFeatures(argv, devFeatures), endpoint.url, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 5cfcd7d3f..1becc9303 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -181,8 +181,8 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { expect(result).toBe(1); expect(order).toEqual([ 'preflight', - 'frontend-ready', 'resources', + 'frontend-ready', 'spawn', 'exit', 'cleanup', From 0bfc5768cc64956f0d18447ed81595d83a2ae491 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 30 Sep 2026 20:24:23 +0800 Subject: [PATCH 25/26] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E8=B5=84=E6=BA=90=E6=9E=84=E5=BB=BA=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让Linux no-bundle smoke不再误用Windows随包资源目标。 统一dev与release的Cargo目标和feature解析,补充跨平台回归测试。 同步随包资源staging技术方案中的实际构建目标约定。 --- .../scripts/build-release.mjs | 69 +++-- .../scripts/build-release.test.mjs | 292 ++++++++++++++++++ .../scripts/cargo-features.mjs | 143 ++++++--- .../scripts/start-tauri-dev.mjs | 33 +- .../tests/start-tauri-dev.test.ts | 262 ++++++++++++++++ ...术方案】AGC随包资源staging归位-2026-09-26.md | 1 + 6 files changed, 720 insertions(+), 80 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 00f144de6..f007e8bd4 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -11,6 +11,7 @@ import { } from './agc-global-version.mjs'; import { defaultEditorFeatures, + readCargoTarget, resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; @@ -20,7 +21,10 @@ import { resolveReleaseChannel, } from './channel-identity.mjs'; import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs'; -import { prepareBundledResources } from './prepare-bundled-resources.mjs'; +import { + prepareBundledResources, + supportedHostTarget, +} from './prepare-bundled-resources.mjs'; import { stageNodeRuntime } from './stage-node-runtime.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -42,26 +46,7 @@ function defaultTarget() { } function explicitBuildTarget(args) { - let target; - const separator = args.indexOf('--'); - const options = separator < 0 ? args : args.slice(0, separator); - for (let index = 0; index < options.length; index += 1) { - const argument = options[index]; - let value; - if (argument === '--target' || argument === '-t') { - value = options[++index]; - } else if (argument.startsWith('--target=')) { - value = argument.slice('--target='.length); - } else { - continue; - } - if (!value?.trim() || value.startsWith('-')) { - throw new Error('--target 缺少有效目标'); - } - if (target !== undefined) throw new Error('不能重复指定 --target'); - target = value.trim(); - } - return target; + return readCargoTarget(args); } function validateReleaseTarget(target) { @@ -494,6 +479,8 @@ export function runTauriBuild( spawn = spawnSync, stageRuntime = stageNodeRuntime, stageBundled = stageBundledResources, + platform = process.platform, + arch = process.arch, } = {}, ) { if ( @@ -502,19 +489,35 @@ export function runTauriBuild( ) { throw new Error('构建参数与发布上下文目标不一致'); } - const tauriArguments = buildTauriBuildArguments(args, context.target); - const { channel, target } = context; - const features = resolveEditorFeatures({ argv: args, target }); - // --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。 - if (!args.includes('--no-bundle')) { - stageRuntime(target); - } - stageBundled(target, { features }); - const configPath = writeChannelConfigFile( - channel, - target, - !args.includes('--no-bundle'), + const tauriArguments = buildTauriBuildArguments( + args, + context.target, + platform, ); + const bundling = !args.includes('--no-bundle'); + // 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。 + const resourceTarget = + explicitBuildTarget(args) || + (bundling ? context.target : supportedHostTarget(platform, arch)); + const features = resolveEditorFeatures({ + argv: tauriArguments, + target: resourceTarget ?? platform, + env: {}, + }); + const { channel } = context; + if (bundling) { + stageRuntime(resourceTarget); + } + if (resourceTarget) { + stageBundled(resourceTarget, { features }); + } else { + console.log( + '[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备', + ); + } + // Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。 + const target = resourceTarget ?? context.target; + const configPath = writeChannelConfigFile(channel, target, bundling); console.log( `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, ); diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index e6438ed7f..4b6fc20cc 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -38,6 +38,7 @@ import { AGC_PRODUCT_NAME, resolveChannelInstallIdentity, } from './channel-identity.mjs'; +import { supportedHostTarget } from './prepare-bundled-resources.mjs'; const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; @@ -1032,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for ); }); +const windowsEditorFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', +]; + +function uniqueSorted(values) { + return [...new Set(values ?? [])].sort(); +} + +function splitFeatureList(value) { + return String(value ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + +/// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f` +/// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数, +/// 随包资源准备必须拿到同一集合。 +function cargoFeaturesFromCommand(command) { + const argv = command.slice(command.indexOf('--') + 1); + const features = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = String(argv[index]); + if (argument.startsWith('--features=')) { + features.push(...splitFeatureList(argument.slice('--features='.length))); + } else if (argument === '--features' || argument === '-f') { + features.push(...splitFeatureList(argv[index + 1])); + index += 1; + } else if (/^-f.+/u.test(argument)) { + features.push(...splitFeatureList(argument.slice(2))); + } + } + return uniqueSorted(features); +} + +/** + * PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台, + * 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走, + * 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败), + * 还会出现「cargo 没开 feature、staging 开了」的错配。 + */ +test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + assert.equal(context.target, windowsTarget); + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform: 'linux', + arch: 'x64', + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`); + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建'); + assert.ok(!command.includes('--target'), '宿主构建不得打显式 target'); + assert.deepEqual( + cargoFeaturesFromCommand(command), + [], + 'Linux 宿主不得注入 Windows 编辑器 feature', + ); +}); + +test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + for (const [platform, arch, hostTarget, expectedFeatures] of [ + ['darwin', 'arm64', 'aarch64-apple-darwin', []], + ['darwin', 'x64', 'x86_64-apple-darwin', []], + ['win32', 'x64', windowsTarget, windowsEditorFeatures], + ]) { + const label = `${platform}/${arch}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal( + staged?.target, + hostTarget, + `${label} 必须按宿主三元组 staging`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与宿主不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + assert.ok( + !command.includes('--target'), + `${label} 宿主构建不得打显式 target`, + ); + } +}); + +test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => { + for (const [platform, arch, target, expectedFeatures] of [ + ['linux', 'x64', windowsTarget, windowsEditorFeatures], + ['darwin', 'arm64', 'x86_64-apple-darwin', []], + ]) { + const args = ['--no-bundle', '--target', target]; + const context = resolveReleaseContext(args, {}); + const label = `${platform}/${arch} → ${target}`; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(args, context, { + platform, + arch, + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(stageTarget, options) { + staged = { target: stageTarget, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, target, `${label} 必须按显式 target staging`); + assert.ok( + command.includes(target), + `${label} 必须把显式 target 传给 Tauri`, + ); + assert.deepEqual( + uniqueSorted(staged?.features), + uniqueSorted(expectedFeatures), + `${label} staging feature 与预期不一致`, + ); + assert.deepEqual( + cargoFeaturesFromCommand(command), + uniqueSorted(expectedFeatures), + `${label} Cargo feature 与 staging 不一致`, + ); + } +}); + +test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => { + const crossArch = 'x86_64-apple-darwin'; + const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch }); + assert.equal(context.target, crossArch); + let runtimeTarget; + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild([], context, { + platform: 'darwin', + arch: 'arm64', + stageRuntime(target) { + runtimeTarget = target; + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, args) { + command = args; + return { status: 0 }; + }, + }); + }); + assert.equal(runtimeTarget, crossArch); + assert.equal( + staged?.target, + crossArch, + '打包构建必须按发布上下文目标 staging', + ); + assert.deepEqual(uniqueSorted(staged?.features), []); + assert.ok( + command.includes(crossArch), + '打包构建必须把发布上下文目标传给 Tauri', + ); + assert.deepEqual(cargoFeaturesFromCommand(command), []); +}); + +test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => { + const context = resolveReleaseContext(['--no-bundle'], {}); + const cases = [ + { + args: ['--no-bundle', '--features', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '--features=custom-a,custom-b'], + expected: ['custom-a', 'custom-b'], + }, + { + args: ['--no-bundle', '-f', 'custom-editor-feature'], + expected: ['custom-editor-feature'], + }, + { + args: ['--no-bundle', '-fcustom-editor-feature'], + expected: ['custom-editor-feature'], + }, + // `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行 + // (`tauri build --help`:Command line arguments passed to the runner), + // 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。 + { + args: ['--no-bundle', '--', '--features=app-runner'], + expected: [...windowsEditorFeatures, 'app-runner'], + }, + // 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。 + { + args: ['--no-bundle'], + env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' }, + }, + ]; + for (const entry of cases) { + const { args, expected } = entry; + const label = args.join(' '); + let staged; + let command; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => { + runTauriBuild(args, context, { + platform: 'win32', + arch: 'x64', + stageRuntime() { + assert.fail(`${label} 不得准备 Node 运行时`); + }, + stageBundled(target, options) { + staged = { target, features: options?.features }; + }, + spawn(_binary, argv) { + command = argv; + return { status: 0 }; + }, + }); + }); + assert.equal(staged?.target, windowsTarget, label); + const cargoFeatures = cargoFeaturesFromCommand(command); + assert.deepEqual( + uniqueSorted(staged?.features), + cargoFeatures, + `${label}:staging feature 必须等于 Cargo 收到的 feature`, + ); + if (expected) { + assert.deepEqual( + cargoFeatures, + uniqueSorted(expected), + `${label}:Cargo feature 与预期不一致`, + ); + } + } +}); + +test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => { + const hostTarget = supportedHostTarget(process.platform, process.arch); + const context = resolveReleaseContext(['--no-bundle'], {}); + const events = []; + withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => { + runTauriBuild(['--no-bundle'], context, { + stageRuntime() { + assert.fail('--no-bundle 不得准备 Node 运行时'); + }, + stageBundled(target) { + events.push(target); + }, + spawn() { + return { status: 0 }; + }, + }); + }); + assert.deepEqual(events, hostTarget ? [hostTarget] : []); +}); + test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index c8303d42b..289b0c515 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -1,69 +1,134 @@ /** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */ export function withDefaultCargoFeatures(argv, features) { - const separator = argv.indexOf('--'); - const cargoArgs = separator < 0 ? argv : argv.slice(0, separator); - if ( - !features.length || - cargoArgs.some( - (value) => - value === '--features' || - value === '-f' || - value.startsWith('--features=') || - /^-f.+/u.test(value), - ) - ) { + // 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的 + // 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。 + if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) { return argv; } return [`--features=${features.join(',')}`, ...argv]; } +/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。 +function tauriOptions(argv) { + const separator = argv.indexOf('--'); + return separator < 0 ? argv : argv.slice(0, separator); +} + +/** + * 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是 + * runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。 + * dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布 + * 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。 + */ +function cargoArguments(argv) { + const separator = argv.indexOf('--'); + if (separator < 0) return argv; + const applicationStart = argv.indexOf('--', separator + 1); + return applicationStart < 0 ? argv : argv.slice(0, applicationStart); +} + +/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。 +function hasExplicitFeatures(value) { + return ( + value === '--features' || + value === '-f' || + value.startsWith('--features=') || + /^-f.+/u.test(value) + ); +} + +function parseFeatureNames(raw) { + return String(raw) + .split(',') + .map((name) => name.trim()) + .filter(Boolean); +} + export function defaultEditorFeatures(target) { return target === 'win32' || target.includes('windows') ? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'] : []; } -/// 单一声明式解析:本次构建实际生效的编辑器 feature 集。 -/// 解析顺序:环境变量 `AGC_DEV_CARGO_FEATURES` > 命令行 `--features` > 目标平台默认值。 -/// dev 入口、发布入口的 cargo 参数与随包资源准备步骤都必须用这里的返回值, -/// 否则会出现「cargo 开了 feature、staging 没开(或反之)」的窗口。 +/** + * 显式 Cargo 目标解析:`--target ` / `--target=` / `-t `。 + * 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、 + * 取到另一个选项或重复指定都失败关闭。 + * dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标 + * 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。 + */ +export function readCargoTarget(argv = []) { + const options = cargoArguments(argv); + let target; + for (let index = 0; index < options.length; index += 1) { + const argument = String(options[index]); + let value; + if (argument === '--target' || argument === '-t') { + value = options[index + 1]; + index += 1; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else if (argument.startsWith('-t') && argument.length > 2) { + value = argument.slice(2).replace(/^=/u, ''); + } else { + continue; + } + if (!value?.trim() || String(value).startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = String(value).trim(); + } + return target; +} + +/** + * 单一声明式解析:本次构建实际生效的编辑器 feature 集。 + * 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖) + * > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature) + * > 目标平台默认值。 + * 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源 + * 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。 + */ export function resolveEditorFeatures({ argv = [], target, env = process.env, } = {}) { - // 顺序:环境变量(dev 的显式覆盖)> 命令行 --features > 目标平台默认值。 - const fromEnv = String(env.AGC_DEV_CARGO_FEATURES ?? '').trim(); - if (fromEnv) { - return fromEnv - .split(',') - .map((name) => name.trim()) - .filter(Boolean); - } const fromArgv = featuresFromArgv(argv); if (fromArgv) { return fromArgv; } + // 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。 + if (env.AGC_DEV_CARGO_FEATURES !== undefined) { + return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES); + } return defaultEditorFeatures(target); } -/// 从 argv 里解析 `--features a,b` / `--features=a,b`;没有则返回 null。 +/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并); +/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。 export function featuresFromArgv(argv = []) { - for (let index = 0; index < argv.length; index += 1) { - const value = String(argv[index]); + const options = cargoArguments(argv); + const features = []; + let found = false; + for (let index = 0; index < options.length; index += 1) { + const value = String(options[index]); if (value.startsWith('--features=')) { - return value - .slice('--features='.length) - .split(',') - .map((name) => name.trim()) - .filter(Boolean); - } - if (value === '--features' && argv[index + 1] !== undefined) { - return String(argv[index + 1]) - .split(',') - .map((name) => name.trim()) - .filter(Boolean); + found = true; + features.push(...parseFeatureNames(value.slice('--features='.length))); + } else if (value === '--features' || value === '-f') { + found = true; + index += 1; + const next = options[index]; + // 变参 flag:后面不是选项时才是它的取值。 + if (next !== undefined && !String(next).startsWith('-')) { + features.push(...parseFeatureNames(next)); + } + } else if (value.startsWith('-f') && value.length > 2) { + found = true; + features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, ''))); } } - return null; + return found ? features : null; } diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index a5be013ac..7cc06d82a 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs'; import { + readCargoTarget, resolveEditorFeatures, withDefaultCargoFeatures, } from './cargo-features.mjs'; @@ -56,9 +57,14 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { } // 开发和发行构建使用同一平台编辑器 feature 集合。 -// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。 +// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭; +// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。 +// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。 function readDevCargoFeatures(argv = []) { - return resolveEditorFeatures({ argv, target: process.platform }); + return resolveEditorFeatures({ + argv, + target: readCargoTarget(argv) ?? process.platform, + }); } function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { @@ -67,11 +73,19 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) { /// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。 /// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。 +/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源: +/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过, +/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。 function prepareBundledResourcesBeforeTauri( - features = readDevCargoFeatures(), - { prepare = prepareBundledResources, log = console.log } = {}, + features, + argv = [], + { + prepare = prepareBundledResources, + log = console.log, + hostTarget = supportedHostTarget(), + } = {}, ) { - const target = supportedHostTarget(); + const target = readCargoTarget(argv) ?? hostTarget; if (!target) { log( '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备', @@ -80,7 +94,7 @@ function prepareBundledResourcesBeforeTauri( } const summaries = prepare({ target, - features: new Set(features), + features: new Set(features ?? readDevCargoFeatures(argv)), log: (line) => log(`[ai-game-creator-shell] ${line}`), }); for (const summary of summaries) { @@ -152,8 +166,11 @@ async function runTauriDev( } try { - const devFeatures = readDevCargoFeatures(); - prepareResources(devFeatures); + // 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数, + // 不能参与解析;注入默认 feature 也不改变这份解析结果。 + const devTauriArguments = buildTauriArguments(argv, endpoint.url); + const devFeatures = readDevCargoFeatures(devTauriArguments); + prepareResources(devFeatures, devTauriArguments); // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。 const preparation = prepareFrontend(endpoint, { signal: preparationAbort.signal, diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts index 1becc9303..b3de87823 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -5,6 +5,10 @@ import { join } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; +import { + readCargoTarget, + resolveEditorFeatures, +} from '../scripts/cargo-features.mjs'; import { isProcessGroupAlive, spawnChild, @@ -13,6 +17,7 @@ import { import { buildTauriArguments, buildTauriDevProcessEnv, + prepareBundledResourcesBeforeTauri, runTauriDev as runTauriDevImpl, withDevCargoFeatures, } from '../scripts/start-tauri-dev.mjs'; @@ -363,3 +368,260 @@ describe('AI 游戏创作 Tauri dev 进程环境', () => { } }); }); + +describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => { + const windowsFeatures = [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ]; + // buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。 + const devConfig = + '{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}'; + + test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => { + const calls: [string, string[]][] = []; + const hostTarget = 'aarch64-apple-darwin'; + const prepare = (options: { + target: string; + features: Iterable; + }) => { + calls.push([options.target, [...options.features]]); + return []; + }; + + for (const argv of [ + ['dev', '--config', devConfig], + ['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig], + ['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig], + // 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。 + [ + 'dev', + '--config', + devConfig, + '--', + '--', + '--target=x86_64-pc-windows-msvc', + ], + ]) { + prepareBundledResourcesBeforeTauri(windowsFeatures, argv, { + hostTarget, + prepare, + log: () => {}, + }); + } + + expect(calls.map(([target]) => target)).toEqual([ + hostTarget, + 'x86_64-apple-darwin', + 'x86_64-pc-windows-msvc', + hostTarget, + ]); + expect(calls[0][1]).toEqual(windowsFeatures); + }); + + test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => { + const prepare = vi.fn(); + const log = vi.fn(); + + prepareBundledResourcesBeforeTauri( + windowsFeatures, + ['dev', '--config', devConfig], + { hostTarget: null, prepare, log }, + ); + + expect(prepare).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备'); + }); + + async function runCapturingDev(argv: string[]) { + const prepared: unknown[][] = []; + const spawned: string[][] = []; + const child = Object.assign(new EventEmitter(), { + pid: 1234, + exitCode: 0, + signalCode: null, + kill: vi.fn(), + }); + const result = await runTauriDev(argv, { + resolveDevEndpoint: resolveTestEndpoint, + preflight: async () => {}, + prepareResources: (...args: unknown[]) => { + prepared.push(args); + }, + spawnCli: (args: string[]) => { + spawned.push(args); + return child; + }, + waitForCli: async () => ({ type: 'exit', code: 0, signal: null }), + terminateTree: async () => ({ stopped: true, forced: false }), + }); + return { result, prepared, spawned }; + } + + test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '-f', + 'custom-feature', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([ + ['custom-feature'], + ]); + expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']); + expect(spawned[0]).not.toContain( + `--features=${windowsFeatures.join(',')}`, + ); + // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。 + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--target', + 'x86_64-apple-darwin', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(prepared[0][1]).toEqual([ + 'dev', + '--target', + 'x86_64-apple-darwin', + '--config', + devConfig, + ]); + expect(spawned[0]).toEqual(prepared[0][1]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([ + '--', + '--features=app-tool', + ]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect(spawned[0]).toEqual([ + 'dev', + '--config', + devConfig, + '--', + '--', + '--features=app-tool', + ]); + expect(prepared[0][1]).toEqual(spawned[0]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', ''); + try { + const { result, prepared, spawned } = await runCapturingDev([]); + + expect(result).toBe(0); + expect(prepared.map(([features]) => features)).toEqual([[]]); + expect( + spawned[0].some((argument) => argument.startsWith('--features')), + ).toBe(false); + } finally { + vi.unstubAllEnvs(); + } + }); + + test('环境变量只在不与显式 CLI 冲突时生效', () => { + vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures'); + try { + expect( + resolveEditorFeatures({ + argv: ['dev', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['template-library-fixtures']); + expect( + resolveEditorFeatures({ + argv: ['dev', '-f', 'cli-feature', '--config', devConfig], + target: 'win32', + }), + ).toEqual(['cli-feature']); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => { + test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => { + expect(readCargoTarget([])).toBeUndefined(); + expect(readCargoTarget(['--no-watch'])).toBeUndefined(); + expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe( + 'x86_64-apple-darwin', + ); + // runner 参数由 Tauri 追加给 cargo:仍然算构建目标。 + expect( + readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']), + ).toBe('aarch64-apple-darwin'); + // 第二个 `--` 之后是应用参数。 + expect( + readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']), + ).toBeUndefined(); + expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标'); + expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow( + '缺少有效目标', + ); + expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow( + '不能重复指定', + ); + }); + + test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => { + const cases: [string[], string[]][] = [ + [ + ['-f', 'a,b'], + ['a', 'b'], + ], + [['-fa'], ['a']], + [['--features=explicit'], ['explicit']], + // Cargo 会合并多个 feature flag:staging 必须拿到同一集合。 + [ + ['--features', 'a', '-f', 'b'], + ['a', 'b'], + ], + // 显式给空集合即关闭默认,而不是回落默认值。 + [['--features='], []], + // 第二个 `--` 之后是应用参数。 + [ + ['dev', '--config', '{}', '--', '--', '-f', 'app-feature'], + [ + 'cocos-editor-execute', + 'unity-editor-execute', + 'godot-editor-execute', + ], + ], + ]; + + for (const [argv, expected] of cases) { + expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual( + expected, + ); + } + }); +}); diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md index a72d6d1fa..6a6f157bd 100644 --- a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md +++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md @@ -102,6 +102,7 @@ build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要 6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。 7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。 8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。 +9. **实际构建目标**:资源准备与 Cargo 必须使用同一目标和 feature 集。正式发布使用发布目标;无显式 `--target` 的 `--no-bundle` 使用宿主平台,Windows/macOS 仍须准备资源,Linux 编译 smoke 不准备桌面平台专属资源。feature 从最终 Tauri/Cargo 参数解析,不能被开发环境变量单独覆盖;dev 启动器转交的应用参数不参与构建参数解析。 ### 4.4 build.rs 退化后的职责 From e7fa90ae18323a3d4824952b1ad5f4694825e9be Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 11:35:27 +0800 Subject: [PATCH 26/26] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E9=9A=8F=E5=8C=85?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E6=A0=A1=E9=AA=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复变参 feature 解析、Windows Codex 缓存幂等性、插件缓存产物检查和插件根目录链接校验。 补齐 staging/backup 忽略规则与 feature 回归测试。 --- .gitignore | 4 +++ .../scripts/build-release.mjs | 9 +++++++ .../scripts/cargo-features.mjs | 13 +++++----- .../scripts/cargo-features.test.mjs | 18 ++++++++++++- .../scripts/prepare-bundled-resources.mjs | 26 ++++++++++++++++--- .../src-tauri/build_support/package_layout.rs | 11 ++++++-- 6 files changed, 68 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index e4b2363a4..652820e39 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,10 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/ /apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/plugins-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/*-backup-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-staging-*/ +/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-backup-*/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index f007e8bd4..bfb958806 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -499,6 +499,15 @@ export function runTauriBuild( const resourceTarget = explicitBuildTarget(args) || (bundling ? context.target : supportedHostTarget(platform, arch)); + if ( + !bundling && + !resourceTarget && + defaultEditorFeatures(platform).length > 0 + ) { + throw new Error( + `当前宿主 ${platform}/${arch} 不在随包资源声明覆盖内;请用 --target 指定受支持的构建目标`, + ); + } const features = resolveEditorFeatures({ argv: tauriArguments, target: resourceTarget ?? platform, diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs index 289b0c515..588912ac1 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs @@ -39,7 +39,7 @@ function hasExplicitFeatures(value) { function parseFeatureNames(raw) { return String(raw) - .split(',') + .split(/[\s,]+/u) .map((name) => name.trim()) .filter(Boolean); } @@ -119,11 +119,12 @@ export function featuresFromArgv(argv = []) { features.push(...parseFeatureNames(value.slice('--features='.length))); } else if (value === '--features' || value === '-f') { found = true; - index += 1; - const next = options[index]; - // 变参 flag:后面不是选项时才是它的取值。 - if (next !== undefined && !String(next).startsWith('-')) { - features.push(...parseFeatureNames(next)); + while ( + index + 1 < options.length && + !String(options[index + 1]).startsWith('-') + ) { + index += 1; + features.push(...parseFeatureNames(options[index])); } } else if (value.startsWith('-f') && value.length > 2) { found = true; diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs index 3358524b9..baf462966 100644 --- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs +++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { buildTauriBuildArguments } from './build-release.mjs'; -import { withDefaultCargoFeatures } from './cargo-features.mjs'; +import { + featuresFromArgv, + withDefaultCargoFeatures, +} from './cargo-features.mjs'; test('Windows release includes the same editor feature as development', () => { assert.deepEqual( @@ -47,3 +50,16 @@ test('explicit Cargo features override defaults in every supported spelling', () ['--features=cocos-editor-execute', '--', '--features=app'], ); }); + +test('features 变参会完整消费空格与逗号分隔的值', () => { + assert.deepEqual( + featuresFromArgv([ + '-f', + 'cocos-editor-execute,unity-editor-execute', + 'godot-editor-execute', + '--target', + 'x86_64-pc-windows-msvc', + ]), + ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'], + ); +}); diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs index f22e34c00..1b25f2585 100755 --- a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs @@ -374,7 +374,11 @@ function unitMatchesExisting(unitPath, declaration, unit, desired) { if (info.size !== file.size || sha256File(filePath) !== file.sha256) { return false; } - if (relative === member.layout.executable && (info.mode & 0o111) === 0) { + if ( + process.platform !== 'win32' && + relative === member.layout.executable && + (info.mode & 0o111) === 0 + ) { return false; } } @@ -1084,6 +1088,10 @@ function pluginTreeMatches( sourceRoot, '插件资源', )) { + const staged = path.join(stagedDirectory, relative); + if (!existsSync(staged)) { + return false; + } allowedFiles.add(`${relativePrefix}${relative}`); } continue; @@ -1117,6 +1125,14 @@ function pluginTreeMatches( libraryStagingEnabled(staging, target, features) ) { for (const relative of staging.files) { + const staged = path.join( + pluginDestination, + staging.sourceSubdirectory, + relative, + ); + if (!existsSync(staged)) { + return false; + } allowedFiles.add( `${plugin.name}/${staging.sourceSubdirectory}/${relative}`, ); @@ -1128,9 +1144,11 @@ function pluginTreeMatches( plugin.name === payload.plugin && nativePayloadEnabled(payload, target, features) ) { - allowedFiles.add( - `${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`, - ); + const relative = `${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`; + if (!existsSync(path.join(pluginDestination, relative))) { + return false; + } + allowedFiles.add(`${plugin.name}/${relative}`); } } } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs index b334fe265..ddd54ee55 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs @@ -308,12 +308,19 @@ pub fn validate_staged_plugins( if !plugin_staging_applies(target) { return Ok(()); } - if !destination_root.is_dir() { + let metadata = std::fs::symlink_metadata(destination_root).map_err(|error| { + format!( + "插件随包资源目录不可读 {}:{error}", + destination_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { return Err(format!( - "插件随包资源目录缺失:{}", + "插件随包资源目录缺失或不允许符号链接:{}", destination_root.display() )); } + let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?; let declared_names = declared_plugins .iter()