From faa510e99975ae9b81a2df1f35e78da6b1082b36 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 27 Sep 2026 23:33:48 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E7=BC=96=E8=BE=91=E5=99=A8=E5=88=86?= =?UTF-8?q?=E6=94=AF=E4=BA=A7=E7=89=A9=E5=BD=92=E4=BD=8D=EF=BC=9A=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E8=84=9A=E6=9C=AC=E5=BD=BB=E5=BA=95=E9=80=80=E5=87=BA?= =?UTF-8?q?=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 构建缓存与磁盘上限