AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

- 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 所有权,决策日志与排障经验同步
This commit is contained in:
2026-09-27 18:56:44 +08:00
parent a4b103a1b0
commit f6dad1950f
19 changed files with 661 additions and 420 deletions
@@ -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,
@@ -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(
@@ -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)],
@@ -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;
});
}
}
@@ -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(
@@ -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,
+57 -287
View File
@@ -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::<Vec<_>>()
.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, &notice);
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("复制插件资源失败");
}
@@ -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}"
);
}
}
}
@@ -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"],
@@ -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"]
}
@@ -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<Vec<PluginDirectory>, 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<BTreeSet<String>, 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::<Vec<_>>();
let build = PLUGINS
.subdirectories
.iter()
.filter(|entry| subdirectory_is_build_derived(entry))
.map(|entry| entry.path)
.collect::<Vec<_>>();
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::<Vec<_>>(), ["src/entry.mjs"]);
}
}
@@ -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;
@@ -28,7 +28,13 @@ const resolveTestEndpoint = async () => testEndpoint;
const runTauriDev = (
argv: string[],
options: Parameters<typeof runTauriDevImpl>[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',
@@ -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` |
@@ -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` |
## 目标
@@ -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 启动链路。
@@ -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 常量不一致」。
@@ -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`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。
@@ -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 身份。