AGC 壳 Rust 套件分片并行,客户端 Rust 关键路径压到 7 分钟以内
Project CI / AI game creator shell Rust crates (push) Successful in 3m17s
Project CI / Native shell tests (push) Successful in 6m1s
Project CI / Backend tests (push) Successful in 6m39s
Project CI / Frontend tests (push) Successful in 3m59s
Project CI / Repository checks (push) Successful in 2m37s
Project CI / AI game creator shell web tests (push) Successful in 2m37s
Project CI / AI game creator shell Rust tests (push) Has been cancelled

run 2097 显示 AI game creator shell Rust tests 是客户端 CI 的关键路径(15 分 27 秒):
前置 5 分 30 秒(checkout + npm ci + Cargo fetch)、编译 1 分 39 秒、AGC 壳 bin target
的 2466 条单测一条命令串行 507 秒、agent-run smoke 51 秒。

- 新增 apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs:cargo test
  --no-run 编译一次拿到测试可执行文件,用 --list 取全部用例名,排序后按 index % shards
  切 4 片,每片作为独立进程执行 <bin> --exact <名单> --test-threads=1,并给每片独立
  TMPDIR。片并集必须等于 --list 全集且互斥(assertShardsCoverEveryTest),改分片规则
  不会静默漏跑。串行口径只从「整套串行」放宽到「片内串行」:当年 libtest 线程并行会
  互相干扰的是进程内后台锁与异步终态,进程分片不共享这些状态,因此不必放宽断言。
- package.json:ai-game-creator-shell:check:rust 拆成 :rust:crates(agent-runtime-core /
  orchestration / platform-llm / shared-contracts)与 :rust:shell(分片运行器),聚合
  脚本保持同序,ai-game-creator-shell:check 与本地全量入口语义不变。
- check-native-shells.mjs:分组由五个变六个(新增 agc-rust-crates、agc-rust-shell,
  移除 agc-rust);smoke 与壳测试同组以共享 AGC 壳的依赖预热。
- project-ci.yml:新增第 7 个 job AI game creator shell Rust crates(预热 server-rs 与
  两个无锁独立 crate 后跑四条 crate 测试);AI game creator shell Rust tests 只保留壳
  分片与 agent-run smoke,并只预热 AGC 壳自己那份 manifest(其 Cargo.lock 的 path 依赖
  已覆盖 platform-llm / platform-agent / agent-runtime-core / shared-contracts)。
  这两个 job 只用 cargo 与 node 内建模块,因此都不再执行 npm ci(各省 1~3 分钟)。
- project-ci-workflow.test.ts:新增纯 cargo job 免 npm ci、分片运行器覆盖校验、crate 级
  job 预热顺序等断言;同步运维文档、development-workflow、decision-log、pitfalls。

验证:分片运行器本地以 agent-runtime-core(7 条 → 2/2/2/1)与 platform-llm(146 条 →
49/49/48)验证分片、--exact 与片 TMPDIR 隔离,参数负例 exit 1;vitest
scripts/project-ci-workflow.test.ts 12 passed;contract 分组、AGC 与 mobile 壳
check-config、eslint、prettier、check:encoding(13400 文件)、check:doc-index 全绿。
Gitea master 分支保护需补 Project CI / AI game creator shell Rust crates (pull_request)。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
This commit is contained in:
2026-09-14 17:02:34 +08:00
parent f9bd0adae1
commit 86b830cf2d
9 changed files with 620 additions and 61 deletions
+66 -27
View File
@@ -31,12 +31,20 @@ env:
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
#
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
# `Native shell tests` 一个 job 里,实测 18 分 37 秒,其中 AI 游戏创作壳的串行
# Rust 套件(2451 个用例,`--test-threads=1`)单独占 533 秒。现在按门禁组拆成
# `Native shell tests`、`AI game creator shell web tests`
# `AI game creator shell Rust tests` 三个 job,各自的命令与拆分前逐一对应。
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成三个 job
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
# `AI game creator shell web tests`typecheck + 壳内测试)
# `AI game creator shell Rust tests`(AGC 壳 bin 单测分片并行 + agent-run smoke),
# 再把 AGC 壳依赖的共享 / 平台 crate 测试拆成 `AI game creator shell Rust crates`。
# 各自的命令与拆分前逐一对应,本地 `npm run check:native-shells` 仍是同一条串行序列。
#
# AGC 壳的 bin 单测(2466 条)按名单分 4 片、每片一个进程并行执行,片内保持
# `--test-threads=1`:当年线程并行会互相干扰的是进程内后台锁与异步终态,进程分片
# 天然隔离,每片另有独立 TMPDIR,因此不必再让整套用例串成一小时级的尾巴。
jobs:
# 该 job 最长:AI 游戏创作壳的共享 / 平台 crate 测试加串行壳测试。
# 客户端 AGC 壳自身的 Rust 门禁:bin target 单测按名单分 4 片并行(片内仍保持
# `--test-threads=1`)加 agent-run smoke。这里不装 npm 依赖:壳 Rust 门禁与 smoke
# 只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
ai-game-creator-shell-rust-tests:
name: AI game creator shell Rust tests
runs-on: genarrative-ci
@@ -50,28 +58,59 @@ jobs:
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Prepare AI game creator shell Rust dependencies
shell: bash
run: |
set -euo pipefail
for manifest_path in \
server-rs/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "Cargo dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
# AGC 壳有独立 Cargo.lock,其 path 依赖已含 platform-llm、platform-agent、
# agent-runtime-core 与 shared-contracts,因此只锁这一份 manifest 就能覆盖壳测试
# 与 smoke 的全部第三方依赖;server-rs 那次预热归 crate 级 job,不在这里重复。
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
fi
sleep $((attempt * 2))
done
- name: Run AI game creator shell Rust gates
run: npm run check:native-shells:agc-rust-shell
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
ai-game-creator-shell-rust-crates:
name: AI game creator shell Rust crates
runs-on: genarrative-ci
steps:
- name: Checkout full history from Gitea
env:
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: genarrative-gitea-checkout
- name: Validate preinstalled CI job image and sandbox
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
- name: Prepare server-rs Rust dependencies
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path server-rs/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'server-rs Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
fi
sleep $((attempt * 2))
done
- name: Prepare standalone Rust crate dependencies
@@ -80,7 +119,7 @@ jobs:
set -euo pipefail
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
# 而 `npm run ai-game-creator-shell:check:rust` 会用
# 而 `npm run ai-game-creator-shell:check:rust:crates` 会用
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
# 会在测试阶段自己 `Updating crates.io index`crates.io 一抖动整条 job 就红
# (见 #327 / PR #316 run 1950)。
@@ -105,8 +144,8 @@ jobs:
done
done
- name: Run AI game creator shell Rust gates
run: npm run check:native-shells:agc-rust
- name: Run AI game creator shell shared crate gates
run: npm run check:native-shells:agc-rust-crates
backend-tests:
name: Backend tests
@@ -325,7 +364,7 @@ jobs:
- name: Run repository checks
run: npm run check:repository-ci
# 客户端的 AI 游戏创作壳前端门禁:typecheck壳内测试与本地 provider agent-run smoke
# 客户端的 AI 游戏创作壳前端门禁:typecheck壳内测试,不触碰 Cargo
ai-game-creator-shell-web-tests:
name: AI game creator shell web tests
runs-on: genarrative-ci
@@ -0,0 +1,426 @@
#!/usr/bin/env node
// AGC 壳 Rust 套件的分片运行器。
//
// 背景:AGC 壳的 Rust 单测集中在 src/main.rs 的 bin target(实测 2466 条),仓库口径用
// `--test-threads=1` 跑,理由是并行调度会让共享 Agent Runtime 后台锁与异步终态的用例在
// **同一进程内**互相干扰(见 development-workflow 的 Tauri suite 单线程口径)。代价是
// 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。
//
// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片、让每片在**独立进程**里并行:
// 当年线程并行的两个根因(进程内全局锁、异步终态)在多进程下不存在,每片还会拿到自己的
// TMPDIR,避免 tempfile 目录互相踩。片并集必须等于全集、且不得重复,数量不符即失败,
// 防止分片规则改动后静默漏跑。
//
// 用法:
// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4]
// node scripts/run-rust-shell-test-shards.mjs --manifest=<Cargo.toml> --target-kind=lib --no-locked
//
// 参数:
// --shards=<n> 分片数,默认 4
// --concurrency=<n> 同时运行的片数,默认等于分片数
// --manifest=<path> Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本)
// --target-kind=<k> bin | lib,默认 bin(本地自测小 crate 时用 lib
// --bin=<name> bin target 名,默认 genarrative-ai-game-creator-shell
// --package=<name> target-kind=lib 时要跑的包名(配合 lib 目标使用)
// --no-locked 传给 cargo 时不带 --locked(只对没有提交 Cargo.lock 的 crate 需要)
// --shard-tmp-root=<path> 片专属 TMPDIR 的父目录,默认 <系统临时目录>/agc-rust-shards
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const shellRoot = path.resolve(scriptDirectory, '..');
function fail(message) {
console.error(`[rust-shards] ${message}`);
process.exit(1);
}
function parsePositiveInteger(name, rawValue) {
if (!/^[1-9][0-9]*$/.test(rawValue)) {
fail(`${name} must be a positive integer, received: ${rawValue}`);
}
return Number(rawValue);
}
const options = {
shards: 4,
concurrency: undefined,
manifestPath: path.join(shellRoot, 'src-tauri', 'Cargo.toml'),
targetKind: 'bin',
binName: 'genarrative-ai-game-creator-shell',
packageName: undefined,
locked: true,
shardTmpRoot: path.join(os.tmpdir(), 'agc-rust-shards'),
};
for (const rawArgument of process.argv.slice(2)) {
if (rawArgument === '--no-locked') {
options.locked = false;
continue;
}
const separatorIndex = rawArgument.indexOf('=');
if (!rawArgument.startsWith('--') || separatorIndex === -1) {
fail(`unexpected argument: ${rawArgument}`);
}
const name = rawArgument.slice(2, separatorIndex);
const value = rawArgument.slice(separatorIndex + 1);
switch (name) {
case 'shards':
options.shards = parsePositiveInteger('--shards', value);
break;
case 'concurrency':
options.concurrency = parsePositiveInteger('--concurrency', value);
break;
case 'manifest':
options.manifestPath = path.resolve(process.cwd(), value);
break;
case 'target-kind':
if (value !== 'bin' && value !== 'lib') {
fail(`--target-kind must be bin or lib, received: ${value}`);
}
options.targetKind = value;
break;
case 'bin':
options.binName = value;
break;
case 'package':
options.packageName = value;
break;
case 'shard-tmp-root':
options.shardTmpRoot = path.resolve(process.cwd(), value);
break;
default:
fail(`unexpected argument: ${rawArgument}`);
}
}
if (!fs.existsSync(options.manifestPath)) {
fail(`manifest does not exist: ${options.manifestPath}`);
}
const concurrency = options.concurrency ?? options.shards;
const crateRoot = path.dirname(options.manifestPath);
function buildCargoArguments(target) {
const cargoArguments = ['test'];
if (options.locked) {
cargoArguments.push('--locked');
}
cargoArguments.push('--manifest-path', options.manifestPath);
if (target.kind === 'lib') {
if (options.packageName !== undefined) {
cargoArguments.push('-p', options.packageName);
}
cargoArguments.push('--lib');
return cargoArguments;
}
cargoArguments.push('--bin', target.name);
return cargoArguments;
}
function formatDuration(milliseconds) {
return `${(milliseconds / 1000).toFixed(1)}s`;
}
// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo,
// 免得 N 个 cargo 去争 package cache 与 target 目录锁。
function resolveTestExecutable() {
return new Promise((resolve, reject) => {
const cargoArguments = buildCargoArguments({
kind: options.targetKind,
name: options.binName,
});
cargoArguments.push('--no-run', '--message-format=json');
console.log(`[rust-shards] cargo ${cargoArguments.join(' ')}`);
const child = spawn('cargo', cargoArguments, {
cwd: crateRoot,
env: process.env,
stdio: ['ignore', 'pipe', 'inherit'],
});
let buffered = '';
const executables = [];
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
buffered += chunk;
const lines = buffered.split('\n');
buffered = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('{')) {
continue;
}
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (
message.reason === 'compiler-artifact' &&
typeof message.executable === 'string'
) {
executables.push(message.executable);
}
}
});
child.on('error', (error) => {
reject(new Error(`unable to start cargo: ${error.message}`));
});
child.on('close', (code) => {
if (code !== 0) {
reject(
new Error(
`cargo ${cargoArguments.join(' ')} failed with exit code ${code}`,
),
);
return;
}
const uniqueExecutables = [...new Set(executables)];
if (uniqueExecutables.length !== 1) {
reject(
new Error(
`expected exactly one test executable for the ${options.targetKind} target, found ${uniqueExecutables.length}: ${uniqueExecutables.join(', ')}`,
),
);
return;
}
resolve(uniqueExecutables[0]);
});
});
}
function listTestNames(executable) {
return new Promise((resolve, reject) => {
const child = spawn(executable, ['--list'], {
cwd: crateRoot,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (error) => {
reject(new Error(`unable to list tests: ${error.message}`));
});
child.on('close', (code) => {
if (code !== 0) {
reject(
new Error(
`listing tests failed with exit code ${code}: ${stderr.trim()}`,
),
);
return;
}
const names = [];
for (const line of stdout.split('\n')) {
const match = /^(.*): test$/.exec(line.trim());
if (match !== null && match[1].length > 0) {
names.push(match[1]);
}
}
resolve(names);
});
});
}
function splitTestNames(testNames, shardCount) {
const sortedNames = [...testNames].sort();
const shards = Array.from({ length: shardCount }, () => []);
sortedNames.forEach((testName, index) => {
shards[index % shardCount].push(testName);
});
return shards;
}
function assertShardsCoverEveryTest(testNames, shards) {
const flattened = shards.flat();
if (flattened.length !== testNames.length) {
fail(
`shard split covered ${flattened.length} of ${testNames.length} tests; the split rule must be exhaustive`,
);
}
if (new Set(flattened).size !== flattened.length) {
fail(
'shard split selected the same test more than once; the split rule must be disjoint',
);
}
const missing = testNames.filter((testName) => !flattened.includes(testName));
if (missing.length > 0) {
fail(
`shard split is missing tests, for example: ${missing.slice(0, 5).join(', ')}`,
);
}
}
function runShard(executable, shardIndex, shardCount, shardTestNames) {
const label = `shard ${shardIndex + 1}/${shardCount}`;
const shardTmpDirectory = path.join(
options.shardTmpRoot,
`shard-${shardIndex + 1}`,
);
fs.rmSync(shardTmpDirectory, { recursive: true, force: true });
fs.mkdirSync(shardTmpDirectory, { recursive: true });
const startedAt = Date.now();
return new Promise((resolve) => {
const child = spawn(
executable,
['--exact', ...shardTestNames, '--test-threads=1'],
{
cwd: crateRoot,
env: {
...process.env,
TMPDIR: shardTmpDirectory,
TMP: shardTmpDirectory,
TEMP: shardTmpDirectory,
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const failureLines = [];
let inFailureList = false;
let stderr = '';
const consumeLine = (rawLine) => {
const line = rawLine.replace(/\r$/, '');
if (line.includes('failures:')) {
inFailureList = true;
return;
}
if (inFailureList) {
if (line.trim().length === 0) {
inFailureList = false;
return;
}
failureLines.push(line.trim());
}
};
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
let stdoutBuffer = '';
child.stdout.on('data', (chunk) => {
stdoutBuffer += chunk;
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
for (const line of lines) {
consumeLine(line);
}
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (error) => {
resolve({
label,
ok: false,
durationMs: Date.now() - startedAt,
testCount: shardTestNames.length,
failures: [`unable to start test binary: ${error.message}`],
stderr,
});
});
child.on('close', (code) => {
resolve({
label,
ok: code === 0,
durationMs: Date.now() - startedAt,
testCount: shardTestNames.length,
failures: failureLines,
stderr,
});
});
});
}
async function runWithConcurrency(shards, runner) {
const results = new Array(shards.length);
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(concurrency, shards.length) },
async () => {
while (nextIndex < shards.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await runner(shards[index], index);
}
},
);
await Promise.all(workers);
return results;
}
async function main() {
const executable = await resolveTestExecutable();
const testNames = await listTestNames(executable);
if (testNames.length === 0) {
fail(
`no tests discovered in ${options.manifestPath} (${options.targetKind})`,
);
}
const shards = splitTestNames(testNames, options.shards);
assertShardsCoverEveryTest(testNames, shards);
console.log(
`[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`,
);
shards.forEach((shardTestNames, index) => {
console.log(
`[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`,
);
});
const results = await runWithConcurrency(shards, (shardTestNames, index) =>
runShard(executable, index, shards.length, shardTestNames),
);
let failed = false;
for (const result of results) {
if (result.ok) {
console.log(
`[rust-shards] ${result.label} ok: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
);
continue;
}
failed = true;
console.error(
`[rust-shards] ${result.label} FAILED: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`,
);
for (const failure of result.failures) {
console.error(`[rust-shards] ${failure}`);
}
if (result.stderr.trim().length > 0) {
console.error(
`[rust-shards] stderr: ${result.stderr.trim().split('\n').slice(-20).join('\n[rust-shards] ')}`,
);
}
}
if (failed) {
process.exit(1);
}
console.log('[rust-shards] OK');
}
main().catch((error) => {
fail(error instanceof Error ? error.message : String(error));
});
@@ -3,6 +3,18 @@
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
## 2026-09-14 AGC 壳 Rust 套件改为「分片并行 + 片内串行」,客户端 Rust 关键路径压到 7 分钟以内
- 背景:`AI game creator shell Rust tests` 是客户端 CI 的关键路径(run 2097 实测 15 分 27 秒)。拆开来看:前置 5 分 30 秒(checkout 10s + `npm ci` 2m45s + Cargo fetch 2m35s)、编译 1m39s、**AGC 壳 bin target 的 2466 条单测串行 507s**、`agent-run` smoke 51s。这 2466 条全在 `apps/ai-game-creator-shell/src-tauri/src/main.rs` 的 bin target 里,一条 `cargo test … -- --test-threads=1` 跑完。
- 为什么原本整套串行:2026-07-21 的 `a273377b1`(「稳定AI原生壳全量测试」)把 Tauri suite 固定为 `--test-threads=1`,理由是**共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰**——即同进程内的全局锁、异步终态与进程级 static 被交叉触发;当时的口径是「修正 suite 调度口径,不放宽断言」。另有少量用例自身会 spawn `cargo test`,需要独占 cargo 的 package cache / target 锁。
- 决策:**只把「整套串行」放宽到「片内串行」**。新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs``cargo test --no-run` 编译一次拿到测试可执行文件,用 `--list` 取全部用例名,排序后按 `index % shards` 切 4 片,每片作为**独立进程**执行 `<bin> --exact <名单> --test-threads=1`,并给每片独立 `TMPDIR`。进程分片不共享当年出问题的进程内状态,也不共享 tempfile 目录,因此可以并行;片内仍严格串行,不退回整套并行,也不需要放宽任何断言。
- 不变量:片并集必须等于 `--list` 的全集且互斥,数量或成员不符立即失败(`assertShardsCoverEveryTest`),防止分片规则改动后静默漏跑门禁。
- 配套拆分:`npm run ai-game-creator-shell:check:rust` 拆成 `:rust:crates``agent-runtime-core``agent-runtime-orchestration``platform-llm``shared-contracts`)与 `:rust:shell`(分片运行器),聚合脚本保持同序,因而 `ai-game-creator-shell:check` 与本地 `npm run check:native-shells` 语义不变。CI 相应新增 `AI game creator shell Rust crates` job(第 7 个),`AI game creator shell Rust tests` 只保留壳分片与 smoke。
- 前置瘦身:AGC 壳有独立 `Cargo.lock`,其 path 依赖已包含 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`,所以壳 job 只需预热 AGC 壳这一份 manifest;壳 Rust 门禁与 `agent-run` smoke 只用 cargo 与 node 内建模块,因此 **`AI game creator shell Rust tests``AI game creator shell Rust crates` 都不再执行 `npm ci`**(各省 1~3 分钟)。
- 影响范围:`.gitea/workflows/project-ci.yml`(七个 job)、`scripts/check-native-shells.mjs`(分组由五个变六个:新增 `agc-rust-crates``agc-rust-shell`,移除 `agc-rust`)、根 `package.json``scripts/project-ci-workflow.test.ts`(新增纯 cargo job 免 `npm ci` 与分片运行器断言)、开发运维文档与共享记忆。Gitea `master` 分支保护的 required context 是追加式的,需补上 `Project CI / AI game creator shell Rust crates (pull_request)`
- 验证方式:`npx vitest run scripts/project-ci-workflow.test.ts`;分片运行器本地以 `agent-runtime-core`7 条)与 `platform-llm`146 条)验证分片、`--exact` 与片 TMPDIR 隔离;`node scripts/check-native-shells.mjs --groups=contract` 回归。预期 `AI game creator shell Rust tests` 收敛到 6 分钟左右(前置 1m30s + 编译 1m39s + 分片约 2 分钟 + smoke),整轮 wall clock 由 `Backend tests`8 分 36 秒)与 runner 并发(4)决定。
- 关联文档:[开发运维](../../【开发运维】本地开发验证与生产运维-2026-05-15.md)、[踩坑记录](pitfalls.md)。
## 2026-09-14 客户端 CI 按门禁组拆成三个 jobAGC 的 web / rust 两段并行
- 背景:`Project CI / Native shell tests` 把微信壳、Expo 移动壳、Tauri 桌面壳、H5 HostBridge 与 AI 游戏创作壳的全部门禁串在一个 job 里,实测 18 分 37 秒;同一次运行的 Repository / Frontend / Backend 分别只要 3 分 21 秒、4 分 16 秒、6 分 14 秒,其余三个 job 结束后客户端 job 还要再跑十几分钟。日志时间戳显示门禁段 932 秒里:AGC `ai-game-creator-shell:check` 占 654 秒(其中壳内 Rust 套件 2451 个用例 `--test-threads=1` 单跑 441.58 秒、编译 79 秒),AGC vitest 75 秒,两个发布构建 smoke 加落盘断言 230 秒,而 h5 / 微信 / 移动 / 桌面壳的全部运行时门禁加起来不到 50 秒。
@@ -74,4 +74,4 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
## Gitea CI 依赖闭合
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust tests` `cargo fetch --locked` 预取 `server-rs/Cargo.toml` 与 AGC 壳 manifest`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此必须同 job),`Native shell tests` 预取桌面壳与 AGC 壳 manifest`AI game creator shell web tests` 不触碰 Cargo,不预热。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate`agent-runtime-core``agent-runtime-orchestration`)只能在 `AI game creator shell Rust tests` 里用不带锁标志的 fetch。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module``spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm``shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成个 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust tests` 只预取 AGC 壳 manifestAGC 壳那份 `Cargo.lock` 的 path 依赖已含 `platform-llm``platform-agent``agent-runtime-core``shared-contracts``agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,因此必须同 job),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与两个独立 crate`Native shell tests` 预取桌面壳与 AGC 壳 manifest`AI game creator shell web tests` 不触碰 Cargo,不预热。前两个 job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate`agent-runtime-core``agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译一次后按 `--list` 名单分 4 片、每片一个进程并行跑,片内保持 `--test-threads=1` 并各自使用独立 `TMPDIR`:当年 libtest 线程并行会互相干扰的是进程内后台锁与异步终态,进程分片不共享这些状态,因此可以并行而无需放宽断言。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module``spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm``shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
@@ -1,5 +1,14 @@
# 踩坑与排障记录
## 2026-09-14 AGC 壳 Rust 套件的「整套单线程」只放宽到「片内单线程」,且分片必须自校验覆盖
- **现象**`AI game creator shell Rust tests` 一直是客户端 CI 的关键路径。run 2097 实测 15 分 27 秒,其中 `apps/ai-game-creator-shell/src-tauri/src/main.rs` 的 bin target 单测(2466 条)一条 `cargo test -- --test-threads=1` 串行占 507 秒。
- **为什么原本是整个 suite 串行**2026-07-21 `a273377b1` 的判据是「共享 Agent Runtime 后台锁与异步终态在 libtest 并行调度下互相干扰」,即**同进程内**的全局后台锁、异步终态与进程级 static 被交叉触发(另有少数用例自身 spawn `cargo test`,要独占 cargo 的 package cache/target 锁)。这是进程内并行的问题,不是用例之间的数据依赖。
- **处理**:新增 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs``cargo test --no-run` 编译一次后用 `--list` 名单把用例切成 4 片,每片一个**独立进程**跑 `--exact <名单> --test-threads=1`,片内串行不变。配套把 `ai-game-creator-shell:check:rust` 拆成 `:rust:crates``:rust:shell`CI 新增第 7 个 job `AI game creator shell Rust crates`
- **易错点**:① 分片规则必须自校验「片并集等于 `--list` 全集且互斥」,否则改分片方式会静默漏跑门禁;② 每片要拿独立 `TMPDIR``tempfile::tempdir()` 默认落在它下面,否则同容器内多片会互踩临时目录(测试里的硬编码 `/tmp/...` 多是「必须拒绝」的负向断言,不是真实读写);③ 不要因为"反正要跑测试"就给分片 job 装 `npm ci`——AGC 壳 Rust 门禁与 `agent-run` smoke 只用 cargo 与 node 内建模块,两份 `npm ci` 正好是达标 7 分钟的主要障碍;④ 壳 job 只需预热 AGC 壳自己的 manifest(其 `Cargo.lock` 的 path 依赖已覆盖 `platform-llm` / `platform-agent` / `agent-runtime-core` / `shared-contracts`),`server-rs` 那份预热属于 crate 级 job;⑤ 分片后 `--test-threads=1` 不再出现在 workflow 里,但它是分片运行器的片内参数,别再往 workflow 里补整套串行命令。
- **不要做的事**:不要退回「整套 `--test-threads=1`」(507 秒长尾回来了),也不要放开成整套并行(进程内后台锁与异步终态会再互相干扰),更不要用逐项单线程通过来替代整套门禁的稳定性结论。
- **关联**`apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs``.gitea/workflows/project-ci.yml``scripts/check-native-shells.mjs``agc-rust-shell` / `agc-rust-crates` 分组)、`package.json`
## 2026-09-14 根门禁的 `[check:native-shells] <label>` 是别处按字面量校验的契约
- **现象**:客户端 CI 拆成六个 job 后,`Native shell tests``desktop-shell:typecheck` 步骤红了:`Error: root native shell gate must keep desktop release artifact check console.log('[check:native-shells] desktop-release-binary-artifact')`
@@ -262,7 +262,7 @@ npm run check
### Gitea Actions PR 门禁
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此最长尾的 `AI game creator shell Rust tests` 排在最前:并发槽位不足时,它必须最先开始,wall clock 才由它而不是由排队决定。
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此最长尾的 `AI game creator shell Rust tests` 排在最前:并发槽位不足时,它必须最先开始,wall clock 才由它而不是由排队决定。
所有 CI job 和 Jenkins Web Build 在根 workspace 安装前都必须确认 `npm --version``10.9.7`。Gitea job 使用预构建镜像内的固定版本;Jenkins Web Build 在每个独立 `bash -lc` 中 source `scripts/jenkins-prepare-npm-env.sh`,首次为 Jenkins 运行用户的版本隔离目录引导同版 npm,后续复用并把该 `bin` 放到 `PATH` 首位。旧固定镜像缺少版本元数据时只能报告 `npm_version=partial` 并由当前 job 的根 `npm ci` 继续校验 lock,不能把过渡状态当作工具链已闭合。
@@ -271,9 +271,10 @@ npm run check
- `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd``cargo test --locked --workspace --exclude spacetime-module --no-fail-fast``cargo test --locked -p spacetime-module --no-fail-fast``api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。
- `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract``npm run check:native-shells:shells``npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke,最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。
- `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。
- `AI game creator shell Rust tests`:预热 `server-rs/Cargo.toml`、AGC 壳 manifest 与两个无锁独立 crate 后执行 `npm run check:native-shells:agc-rust`(即 `npm run ai-game-creator-shell:check:rust``npm run ai-game-creator-shell:agent-run:smoke`,覆盖共享 / 平台 crate 测试、AGC 壳串行 Rust 套件和本地 provider agent-run smoke。它会用 `src-tauri/Cargo.toml` spawn `cargo`,所以必须与 Rust 依赖预热同 job。共享 Agent Runtime 后台锁 suite 固定 `--test-threads=1`,不能用并行偶发失败后的逐项通过替代整套稳定门禁
- `AI game creator shell Rust tests`预热 AGC 壳自己那份锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm``platform-agent``agent-runtime-core``shared-contracts`),然后执行 `npm run check:native-shells:agc-rust-shell`(即 `npm run ai-game-creator-shell:check:rust:shell``npm run ai-game-creator-shell:agent-run:smoke`AGC 壳 bin target 的 Rust 单测按 `--list` 名单分 4 片、每片一个进程并行跑,片内保持 `--test-threads=1`,片间各自独立 `TMPDIR`smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,所以必须与 AGC 壳的依赖预热同 job。两者都只用 cargo 与 node 内建模块(smoke 脚本只 import `node:*`),因此该 job 不装 npm 依赖。共享 Agent Runtime 后台锁 suite 的串行口径只放宽到「片内串行」:进程分片不会共享进程内后台锁与异步终态,这是它与当年 libtest 线程并行的关键区别,不能退回整套并行
- `AI game creator shell Rust crates`:预热 `server-rs/Cargo.toml` 与两个无锁独立 crate`agent-runtime-core``agent-runtime-orchestration`)后执行 `npm run check:native-shells:agc-rust-crates`(即 `npm run ai-game-creator-shell:check:rust:crates`),覆盖 `agent-runtime-core``agent-runtime-orchestration``platform-llm``shared-contracts`。这四条命令用的是 server-rs workspace 与独立 crate 的 manifest,属另一套依赖图,因此单独一个 job,也只跑 cargo、不装 npm 依赖。
个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs``--groups=`个分组(`contract``shells``agc-web``agc-rust``release`)各自对应一个 `check:native-shells:<group>` 根脚本,并在 workflow 的某个 job 里被恰好调用一次;不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容job 覆盖,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs``--groups=`个分组(`contract``shells``agc-web``agc-rust-crates``agc-rust-shell``release`)各自对应一个 `check:native-shells:<group>` 根脚本,并在 workflow 的某个 job 里被恰好调用一次;不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容job 覆盖与分片运行器,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。
PR checkout 必须保留完整 Git 历史,并把 PR base SHA 传给 `SPACETIME_SCHEMA_BASE_REF``check:spacetime-schema` 依赖该基线识别已有表字段删除、改名、重排和改类型;事件给出的基线缺失或本地不可解析时必须直接失败,不能退化为空差异检查。Gitea 的 PR checkout 是 PR head,不是与目标分支的预合并 commit,因此 workflow 还会验证 PR head 包含事件中的最新 base commit;分支保护必须继续开启“PR 过期禁止合并”,过期分支先更新再重跑。向 `master` 直接推送时使用 push before SHA;手工触发先尝试 `origin/master`,若它与 `HEAD` 相同则改用 `HEAD^`,仍无法得到不同提交时失败关闭。
@@ -298,9 +299,9 @@ bash scripts/gitea-ci-job-image.sh load-runner
站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)``Project CI / Frontend tests (pull_request)``Project CI / Backend tests (pull_request)``Project CI / Native shell tests (pull_request)``Project CI / AI game creator shell web tests (pull_request)``Project CI / AI game creator shell Rust tests (pull_request)`个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。客户端 CI 拆分的迁移是**追加式**的:旧四个 job 名继续上报,但 `Native shell tests` 的内容已收窄到壳级与发布构建门禁,因此新增的两个 AGC context 必须补进必需检查,否则 AGC 门禁在合并前不生效。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。
workflow 首次成功运行后,在 Gitea `master` 分支保护中把 `Project CI / Repository checks (pull_request)``Project CI / Frontend tests (pull_request)``Project CI / Backend tests (pull_request)``Project CI / Native shell tests (pull_request)``Project CI / AI game creator shell web tests (pull_request)``Project CI / AI game creator shell Rust tests (pull_request)``Project CI / AI game creator shell Rust crates (pull_request)`个完整 context 都设为合并必需检查,并从最近一周已上报 context 表复核名称后再保存。客户端 CI 拆分的迁移是**追加式**的:旧四个 job 名继续上报,但 `Native shell tests` 的内容已收窄到壳级与发布构建门禁,因此新增的 AGC context 必须补进必需检查,否则 AGC 门禁在合并前不生效。不能只填裸 job 名,否则无法匹配 Gitea 实际上报的 `<workflow> / <job> (<event>)`。只提交 workflow 文件不会自动创建 runner,也不会自动修改分支保护;如果 Actions 长时间停留在等待状态,先到仓库或组织的 Actions runner 页面确认存在在线、带 `genarrative-ci` 标签的 runner,再检查精确 Image ID 是否已装入内层 Docker。
master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci <base> <head>` 后回读 push CI。
master 日常交付必须禁止直接 push,只允许经 PR 在当前 head 的个 required context 全绿后合并;本地 `pre-commit` 的 staged ESLint/Prettier 和 master `pre-push` 的 Repository checks parity 只用于提前发现问题,可被 `--no-verify` 绕过,不能充当服务端权威门禁。紧急直推白名单如需保留,应按人员和时限最小化,并要求执行同一 `npm run check:repository-ci <base> <head>` 后回读 push CI。
SpacetimeDB bindings
+5 -2
View File
@@ -198,13 +198,16 @@
"agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml",
"ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",
"ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests",
"ai-game-creator-shell:check:rust": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1",
"ai-game-creator-shell:check:rust:crates": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app",
"ai-game-creator-shell:check:rust:shell": "node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4",
"ai-game-creator-shell:check:rust": "npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell",
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke",
"check:native-shells": "node scripts/check-native-shells.mjs",
"check:native-shells:contract": "node scripts/check-native-shells.mjs --groups=contract",
"check:native-shells:shells": "node scripts/check-native-shells.mjs --groups=shells",
"check:native-shells:agc-web": "node scripts/check-native-shells.mjs --groups=agc-web",
"check:native-shells:agc-rust": "node scripts/check-native-shells.mjs --groups=agc-rust",
"check:native-shells:agc-rust-crates": "node scripts/check-native-shells.mjs --groups=agc-rust-crates",
"check:native-shells:agc-rust-shell": "node scripts/check-native-shells.mjs --groups=agc-rust-shell",
"check:native-shells:release": "node scripts/check-native-shells.mjs --groups=release"
},
"dependencies": {
+22 -11
View File
@@ -85,14 +85,18 @@ const aiGameCreatorViteConfigSource = fs.readFileSync(
// - contract:纯源码 / 契约 / 清单断言,不需要任何构建产物。
// - shells:微信壳、Expo 移动壳、Tauri 桌面壳与 H5 HostBridge 的现役运行时门禁。
// - agc-webAI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
// - agc-rust:AI 游戏创作壳的 Rust 门禁(共享 / 平台 crate 测试、串行壳测试和
// 会用 `src-tauri/Cargo.toml` spawn `cargo` 的 agent-run smoke
// - agc-rust-cratesAGC 壳依赖的共享 / 平台 crate 测试(server-rs workspace 加两个
// 无锁独立 crate),只需 server-rs 侧的依赖预热
// - agc-rust-shellAGC 壳自身的 Rust 门禁(bin target 单测按名单分片并行、片内保持
// `--test-threads=1`)与会用 `src-tauri/Cargo.toml` spawn `cargo run` 的 agent-run
// smoke,只需 AGC 壳自己的依赖预热。
// - release:发布构建 smoke 和依赖发布产物的落盘断言。
const nativeShellGateGroups = [
'contract',
'shells',
'agc-web',
'agc-rust',
'agc-rust-crates',
'agc-rust-shell',
'release',
];
const requestedNativeShellGroups = readRequestedNativeShellGroups(
@@ -2310,8 +2314,9 @@ const steps = [
},
// AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和
// 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成
// webrust段,顺序与 `npm run ai-game-creator-shell:check` 完全一致,
// 但允许 CI 并行执行;本地全量运行仍然是 web -> rust -> smoke 原顺序。
// webrust-crates、rust-shell 三段,顺序与 `npm run ai-game-creator-shell:check`
// 完全一致,但允许 CI 把三段放进不同的 job 并行执行;本地全量运行仍然是
// web -> rust(crates -> shell) -> smoke 原顺序。
{
group: 'agc-web',
label: 'ai-game-creator-shell-check-web',
@@ -2319,15 +2324,21 @@ const steps = [
args: ['run', 'ai-game-creator-shell:check:web'],
},
{
group: 'agc-rust',
label: 'ai-game-creator-shell-check-rust',
group: 'agc-rust-crates',
label: 'ai-game-creator-shell-check-rust-crates',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:rust'],
args: ['run', 'ai-game-creator-shell:check:rust:crates'],
},
// agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此与 Rust 段同组,
// 保证它落在已经预热 AGC Cargo 依赖的 job 里。
{
group: 'agc-rust',
group: 'agc-rust-shell',
label: 'ai-game-creator-shell-check-rust-shell',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:rust:shell'],
},
// agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,因此与壳测试同组,
// 保证它落在已经预热 AGC 壳 Cargo 依赖的 job 里。
{
group: 'agc-rust-shell',
label: 'ai-game-creator-shell-agent-run-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:agent-run:smoke'],
+73 -15
View File
@@ -42,8 +42,18 @@ const jobNames = [
'native-shell-tests',
'ai-game-creator-shell-web-tests',
'ai-game-creator-shell-rust-tests',
'ai-game-creator-shell-rust-crates',
] as const;
// 这两个 job 是纯 cargo 门禁:AGC 壳的 Rust 套件与 agent-run smoke 只用 cargo 与
// node 内建模块(smoke 脚本只 import `node:*`),crate 级测试只跑 cargo 命令,
// 都不需要 node_modules。省掉两份 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。
const jobsWithoutNpmInstall: readonly string[] = [
'ai-game-creator-shell-rust-tests',
'ai-game-creator-shell-rust-crates',
];
const rootPackageJson = JSON.parse(
readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
@@ -58,7 +68,8 @@ const nativeShellGateGroupScripts = {
contract: 'npm run check:native-shells:contract',
shells: 'npm run check:native-shells:shells',
'agc-web': 'npm run check:native-shells:agc-web',
'agc-rust': 'npm run check:native-shells:agc-rust',
'agc-rust-crates': 'npm run check:native-shells:agc-rust-crates',
'agc-rust-shell': 'npm run check:native-shells:agc-rust-shell',
release: 'npm run check:native-shells:release',
} as const;
@@ -130,6 +141,10 @@ describe('project CI workflow', () => {
expect(checkout).toBeGreaterThanOrEqual(0);
expect(validateImage).toBeGreaterThan(checkout);
if (jobsWithoutNpmInstall.includes(jobName)) {
expect(installDependencies).toBe(-1);
continue;
}
expect(installDependencies).toBeGreaterThan(validateImage);
}
@@ -160,8 +175,12 @@ describe('project CI workflow', () => {
);
});
it('retries the single root workspace clean install in every job as a bounded whole command', () => {
it('retries the single root workspace clean install in every job that needs node_modules', () => {
for (const jobName of jobNames) {
if (jobsWithoutNpmInstall.includes(jobName)) {
expect(jobSection(jobName)).not.toContain('ci-npm-ci-with-retry.sh');
continue;
}
const install = stepSection(jobName, 'Install npm dependencies');
expect(install).toContain('bash scripts/ci-npm-ci-with-retry.sh');
expect(install).not.toContain('--prefix');
@@ -381,36 +400,75 @@ describe('project CI workflow', () => {
expect(workflow).not.toMatch(/^ {8}run: npm run check:native-shells$/mu);
});
it('splits the AI game creator shell gates into web and Rust jobs', () => {
it('splits the AI game creator shell gates into web, shell shard, and shared crate jobs', () => {
const webJob = jobSection('ai-game-creator-shell-web-tests');
expect(webJob).toContain('run: npm run check:native-shells:agc-web');
expect(webJob).not.toContain('cargo fetch');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
expect(rustJob).toContain('run: npm run check:native-shells:agc-rust');
expect(rustJob).toContain('server-rs/Cargo.toml');
expect(rustJob).toContain(
'run: npm run check:native-shells:agc-rust-shell',
);
expect(rustJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(rustJob).toContain('cargo fetch --locked');
// 壳 job 只预热 AGC 壳自己那份锁定依赖:server-rs 那份归 crate 级 job。
expect(rustJob).not.toContain('server-rs/Cargo.toml');
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列。
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
expect(cratesJob).toContain(
'run: npm run check:native-shells:agc-rust-crates',
);
expect(cratesJob).toContain('server-rs/Cargo.toml');
expect(cratesJob).toContain('cargo fetch --locked');
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列,
// rust 段再拆成 crate 级与壳分片两段后在聚合脚本里保持同序。
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check']).toBe(
'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke',
);
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe(
'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests',
);
expect(
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust'],
).toContain(
'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1',
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:rust']).toBe(
'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell',
);
expect(
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust:crates'],
).toContain(
'cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app',
);
expect(
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust:shell'],
).toBe(
'node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4',
);
// 整套用例不再作为一条 `cargo test -- --test-threads=1` 命令串行跑:CI 与本地都走
// 分片运行器,片内串行、片间并行。
expect(workflow).not.toContain('-- --test-threads=1');
});
it('prefetches the excluded standalone Rust crates before the AI game creator shell Rust gates', () => {
it('keeps the shell shard runner exhaustive over discovered tests', () => {
const shardRunner = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs',
),
'utf8',
);
// 分片必须对 `--list` 的全集做覆盖与互斥校验,否则改分片规则会静默漏跑门禁。
expect(shardRunner).toContain('function assertShardsCoverEveryTest(');
expect(shardRunner).toContain('must be exhaustive');
expect(shardRunner).toContain('must be disjoint');
expect(shardRunner).toContain("'--test-threads=1'");
expect(shardRunner).toContain('TMPDIR: shardTmpDirectory');
expect(shardRunner).toContain("'--exact'");
});
it('prefetches the excluded standalone Rust crates before the shared crate gates', () => {
const standaloneStep = stepSection(
'ai-game-creator-shell-rust-tests',
'ai-game-creator-shell-rust-crates',
'Prepare standalone Rust crate dependencies',
);
for (const manifest of [
@@ -424,11 +482,11 @@ describe('project CI workflow', () => {
expect(standaloneStep).toContain('cargo fetch \\');
expect(standaloneStep).not.toContain('cargo fetch --locked');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
expect(
rustJob.indexOf('Prepare standalone Rust crate dependencies'),
cratesJob.indexOf('Prepare standalone Rust crate dependencies'),
).toBeLessThan(
rustJob.indexOf('run: npm run check:native-shells:agc-rust'),
cratesJob.indexOf('run: npm run check:native-shells:agc-rust-crates'),
);
});
});