Compare commits
277 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0b5a84d61 | |||
| 411f49a65d | |||
| a8aacb27bd | |||
| e8403e547b | |||
| 90d216e241 | |||
| c799d00a93 | |||
| 2b5ce24a97 | |||
| c485ce94a1 | |||
| a70ef2d6e1 | |||
| 0fea75952a | |||
| 25769ad5e8 | |||
| b7100c7188 | |||
| cf4a275d26 | |||
| fed9960684 | |||
| a3918c89f0 | |||
| f955661146 | |||
| 5cf5b541af | |||
| dcaa1a2dd5 | |||
| b082addc34 | |||
| 2566ae705f | |||
| 86b830cf2d | |||
| f9bd0adae1 | |||
| 11beb65c36 | |||
| 20f109027a | |||
| 4e553bb15d | |||
| ad8f42a68f | |||
| a0325d653e | |||
| b617873f46 | |||
| 513c25898c | |||
| 9856bb7057 | |||
| 16db7ae8aa | |||
| 0ea7c7745c | |||
| 6baf358bc7 | |||
| 462fc856aa | |||
| 274bb1a3d2 | |||
| 8a96190db8 | |||
| 584da5b76b | |||
| d1a03aec0e | |||
| b90d12c5fb | |||
| 8135b4c374 | |||
| 124d94de93 | |||
| 103fe6fcd6 | |||
| 1370094c52 | |||
| be8155c4a8 | |||
| 9052ff14f8 | |||
| 9d9f1b8164 | |||
| 8629a228a1 | |||
| a92121e09d | |||
| e49d6741ca | |||
| f7666339fe | |||
| 891cf50c0e | |||
| b15a95e774 | |||
| e0f743b608 | |||
| a19684a5f5 | |||
| 46b0871e6d | |||
| 5c40abce47 | |||
| 47d09d51ef | |||
| f05f6e49ab | |||
| c7fe531b16 | |||
| d44c6ab62c | |||
| 6764950aec | |||
| 0944667c37 | |||
| bb2f080d3b | |||
| 413707da33 | |||
| 6235fa3298 | |||
| 8a9d37d5ad | |||
| 9bbfc56cd7 | |||
| e9d0180054 | |||
| 664bdd6877 | |||
| 39a64a864a | |||
| f055961714 | |||
| 3bcabe36fa | |||
| 2bfc870854 | |||
| b0e9741e81 | |||
| b52a33cc28 | |||
| 398edd6e86 | |||
| 9788087271 | |||
| 329de5fce9 | |||
| e4d70fbe1b | |||
| aaa9d3fb6f | |||
| bd127846f7 | |||
| b1751784ba | |||
| 379611d552 | |||
| ef04a19d5a | |||
| 206b4830a1 | |||
| 4b8e268816 | |||
| 7e7d6f1563 | |||
| a4b1d81123 | |||
| 983b15c3a7 | |||
| cdb6fcfe97 | |||
| d8a4e35232 | |||
| 0726106818 | |||
| 8beade5b4d | |||
| 46984a4945 | |||
| a8a30ecbc8 | |||
| 48e669d167 | |||
| 23fdc005fe | |||
| b8a4523a4f | |||
| 9c04561419 | |||
| 551fb6609a | |||
| d05440dcca | |||
| 7edf4a8f8b | |||
| afc9ecbd51 | |||
| fc2f4b71ce | |||
| 545f64cf9e | |||
| 2a50614c11 | |||
| 915006c98f | |||
| 69ce232a74 | |||
| 319aa69d6e | |||
| bec468bdc9 | |||
| eada20c199 | |||
| e8cf773aac | |||
| 12fbb61fad | |||
| 478e08fb92 | |||
| 0e67fbf80a | |||
| ed2cfbad36 | |||
| 560422286b | |||
| e04075fd95 | |||
| d7776f2a17 | |||
| 4d9e69841e | |||
| 49e88bbd9f | |||
| 86c268eca3 | |||
| c86221e3da | |||
| e7af7cf4e4 | |||
| e7cd008d7c | |||
| c743ca146e | |||
| 8c8b6cc75a | |||
| 4cf3642b53 | |||
| f933db169e | |||
| a5e936dd27 | |||
| 53008959c5 | |||
| 57dbb3de52 | |||
| e189aff485 | |||
| 248c094da9 | |||
| e66eb2fb55 | |||
| d455dc18a9 | |||
| 04a0d21351 | |||
| a9358bf3ce | |||
| c3de2ce958 | |||
| 287d2f94da | |||
| 1343f487d0 | |||
| 73fb89a8f4 | |||
| bf2ffd2bf9 | |||
| 3f55f043a1 | |||
| 26677b193c | |||
| b8691add3a | |||
| bfe010a192 | |||
| 6db6de5b99 | |||
| 87a7294a04 | |||
| 3774d7c1e1 | |||
| 8d15125f12 | |||
| cf68b8212d | |||
| 8d7ccc0d75 | |||
| 2af3d9a965 | |||
| 4749339bad | |||
| 3e85c71060 | |||
| da19bde99a | |||
| 4a32ee37c4 | |||
| 31fd1da495 | |||
| 4c88e5e924 | |||
| 99f4961c5f | |||
| 0cebc39b6a | |||
| 842d080a75 | |||
| 8cd6e34af0 | |||
| b357d36262 | |||
| 48c5638893 | |||
| 46df12c086 | |||
| 4bdbab29c0 | |||
| 9acc23b848 | |||
| b9b4ebd38e | |||
| 8f680ef376 | |||
| 7c7d55dcab | |||
| 1303592a86 | |||
| 1db7597141 | |||
| 13701d0144 | |||
| 5c83142fec | |||
| 95ed49f264 | |||
| b8d8072267 | |||
| 462caa33c5 | |||
| 4e1064266f | |||
| a50cbfb6c8 | |||
| 1eb4bd5ec0 | |||
| 5b441d389c | |||
| f200cb4d3c | |||
| ae2f5fc80b | |||
| b69bd88e5e | |||
| c5c7339c4a | |||
| 4bbc77fc32 | |||
| 8c521ca891 | |||
| 69a0804c03 | |||
| 74f726e65d | |||
| 1509235c1b | |||
| 1c992c69fe | |||
| d9b153ed78 | |||
| 30c469e9a1 | |||
| 51f629b2f9 | |||
| d6c720ecdd | |||
| 086aa91d70 | |||
| 3733e86272 | |||
| 5d0f3e6be6 | |||
| 989440173f | |||
| 22d9958d18 | |||
| 56f8c9a6a1 | |||
| c84df3d88f | |||
| 761d32cebf | |||
| b3d6543d73 | |||
| 8cd7c43b9e | |||
| 7fb7d246c0 | |||
| 5844afa14d | |||
| 615d1a28cd | |||
| 3e1e172f9c | |||
| 53a919e39b | |||
| d4b5f9fc14 | |||
| 6e4f9a6f1f | |||
| 8a0dcf8be7 | |||
| aa2a78f7e0 | |||
| 7aa96e8438 | |||
| feb6bc0ad4 | |||
| f349350d60 | |||
| df3a172bc9 | |||
| 9b4924c73e | |||
| d30bc462c5 | |||
| 26b9ad7fad | |||
| bc5398894e | |||
| 11d4d3d930 | |||
| 418d5732de | |||
| 592b6b4ee7 | |||
| 99c19e3ed9 | |||
| 15e30acb0f | |||
| 56fff8325b | |||
| 884c5ec5ee | |||
| 207b0820dd | |||
| 19f3d62d78 | |||
| 3c1e7bd599 | |||
| dda6b3946a | |||
| aff40f794d | |||
| 187faa607d | |||
| 109cc81740 | |||
| f796aef7e2 | |||
| 4cc7f4dd64 | |||
| b2d4690f94 | |||
| 0400103a19 | |||
| c049d68b8e | |||
| acfcf0c158 | |||
| 5bbedd9a5c | |||
| 40316d82c4 | |||
| 6c77de4a36 | |||
| b3693765ef | |||
| f7edc9d0ee | |||
| f60771dd00 | |||
| f7cccd8592 | |||
| 5ee2d940c1 | |||
| c75a7725ce | |||
| 3a95715990 | |||
| ed86963de2 | |||
| 65e73eee02 | |||
| 6068eeda83 | |||
| 0e244c64ea | |||
| ad7c059603 | |||
| 55d2a393d1 | |||
| e7f66ce950 | |||
| 35dcfea3ef | |||
| cea8bf77fa | |||
| 0136bfe564 | |||
| 22785b4d7b | |||
| a86a2ee4d2 | |||
| b22eae2726 | |||
| 35d0db6377 | |||
| f087ba3e2b | |||
| 7fc1b841be | |||
| a576fdcc37 | |||
| 4dcf38715b | |||
| d1f9f4e1ef | |||
| 5836aaec26 | |||
| 18d60feb04 | |||
| 4e1316e272 | |||
| 302b4addae |
+335
-84
@@ -27,9 +27,28 @@ env:
|
||||
RUSTC_WRAPPER: ''
|
||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||
|
||||
# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面,
|
||||
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
||||
#
|
||||
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
||||
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
||||
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
||||
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
||||
# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell
|
||||
# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
||||
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
||||
# `npm run check:native-shells` 仍是同一条串行序列。
|
||||
#
|
||||
# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1`
|
||||
# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。
|
||||
# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定
|
||||
# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。
|
||||
jobs:
|
||||
repository-checks:
|
||||
name: Repository checks
|
||||
# AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持
|
||||
# `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,
|
||||
# 也只需要 AGC 壳自己那份锁定依赖。
|
||||
ai-game-creator-shell-rust-shard-1:
|
||||
name: AI game creator shell Rust shard 1/4
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout full history from Gitea
|
||||
@@ -41,76 +60,228 @@ 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: Resolve comparison base
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
||||
')"
|
||||
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
||||
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
||||
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
||||
# 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
|
||||
}
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
|
||||
head_ref="$(git rev-parse HEAD)"
|
||||
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
|
||||
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${resolved_base_ref}" ]]; then
|
||||
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
|
||||
exit 1
|
||||
fi
|
||||
base_ref="${resolved_base_ref}"
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
- name: Run AI game creator shell Rust shard 1/4
|
||||
run: npm run check:native-shells:agc-rust-shard-1
|
||||
|
||||
- name: Run repository checks
|
||||
run: npm run check:repository-ci
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
ai-game-creator-shell-rust-shard-2:
|
||||
name: AI game creator shell Rust shard 2/4
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout source from Gitea
|
||||
- name: Checkout full history from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '1'
|
||||
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: 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 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 frontend and script tests
|
||||
run: npm run test
|
||||
- name: Run AI game creator shell Rust shard 2/4
|
||||
run: npm run check:native-shells:agc-rust-shard-2
|
||||
|
||||
- name: Run BgFilter worker smoke harness tests
|
||||
run: npm run bgfilter-worker:smoke-test
|
||||
ai-game-creator-shell-rust-shard-3:
|
||||
name: AI game creator shell Rust shard 3/4
|
||||
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 production health patrol behavior
|
||||
run: npm run check:production-health-patrol
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Validate production API release behavior
|
||||
run: npm run check:production-api-release
|
||||
- name: Prepare AI game creator shell 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 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: Validate production API deploy behavior
|
||||
run: npm run check:production-api-deploy
|
||||
- name: Run AI game creator shell Rust shard 3/4
|
||||
run: npm run check:native-shells:agc-rust-shard-3
|
||||
|
||||
ai-game-creator-shell-rust-shard-4:
|
||||
name: AI game creator shell Rust shard 4/4
|
||||
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 AI game creator shell 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 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 shard 4/4
|
||||
run: npm run check:native-shells:agc-rust-shard-4
|
||||
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
|
||||
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
||||
ai-game-creator-shell-rust-smoke:
|
||||
name: AI game creator shell Rust smoke
|
||||
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 AI game creator shell 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 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 agent-run smoke
|
||||
run: npm run check:native-shells:agc-rust-smoke
|
||||
|
||||
# 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
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
|
||||
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
|
||||
# 而 `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)。
|
||||
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||
# 解析,不再触碰 registry index。
|
||||
for manifest_path in \
|
||||
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path "${manifest_path}"; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
done
|
||||
|
||||
- name: Run AI game creator shell shared crate gates
|
||||
run: npm run check:native-shells:agc-rust-crates
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
@@ -194,6 +365,8 @@ jobs:
|
||||
- name: Check SpacetimeDB module
|
||||
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||
|
||||
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
|
||||
# 以及依赖发布产物的构建 smoke。
|
||||
native-shell-tests:
|
||||
name: Native shell tests
|
||||
runs-on: genarrative-ci
|
||||
@@ -215,7 +388,6 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for manifest_path in \
|
||||
server-rs/Cargo.toml \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
|
||||
for attempt in $(seq 1 5); do
|
||||
@@ -232,39 +404,118 @@ jobs:
|
||||
done
|
||||
done
|
||||
|
||||
- name: Prepare standalone Rust crate dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的
|
||||
# exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们;
|
||||
# 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path`
|
||||
# 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己
|
||||
# `Updating crates.io index`,crates.io 一抖动整条 native shell 作业就红
|
||||
# (见 #327 / PR #316 run 1950)。
|
||||
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||
# 解析,不再触碰 registry index。
|
||||
for manifest_path in \
|
||||
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path "${manifest_path}"; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
done
|
||||
- name: Run native shell contract gates
|
||||
run: npm run check:native-shells:contract
|
||||
|
||||
- name: Run native shell gates
|
||||
run: npm run check:native-shells
|
||||
run: npm run check:native-shells:shells
|
||||
|
||||
- name: Run native shell release build smoke
|
||||
run: npm run check:native-shells:release
|
||||
|
||||
- name: Ensure native lockfiles are unchanged
|
||||
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
runs-on: genarrative-ci
|
||||
steps:
|
||||
- name: Checkout source from Gitea
|
||||
env:
|
||||
GENARRATIVE_GITEA_FETCH_DEPTH: '1'
|
||||
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: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Run frontend and script tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run BgFilter worker smoke harness tests
|
||||
run: npm run bgfilter-worker:smoke-test
|
||||
|
||||
- name: Validate production health patrol behavior
|
||||
run: npm run check:production-health-patrol
|
||||
|
||||
- name: Validate production API release behavior
|
||||
run: npm run check:production-api-release
|
||||
|
||||
- name: Validate production API deploy behavior
|
||||
run: npm run check:production-api-deploy
|
||||
|
||||
repository-checks:
|
||||
name: Repository checks
|
||||
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: Resolve comparison base
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base_ref="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? "");
|
||||
')"
|
||||
if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then
|
||||
git cat-file -e "${base_ref}^{commit}" 2>/dev/null || {
|
||||
echo "comparison base commit is unavailable: ${base_ref}" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)"
|
||||
fi
|
||||
resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)"
|
||||
head_ref="$(git rev-parse HEAD)"
|
||||
if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then
|
||||
resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${resolved_base_ref}" ]]; then
|
||||
echo 'comparison base must resolve to a commit distinct from HEAD.' >&2
|
||||
exit 1
|
||||
fi
|
||||
base_ref="${resolved_base_ref}"
|
||||
if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \
|
||||
&& ! git merge-base --is-ancestor "${base_ref}" HEAD; then
|
||||
echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Run repository checks
|
||||
run: npm run check:repository-ci
|
||||
|
||||
# 客户端的 AI 游戏创作壳前端门禁:typecheck 与壳内测试,不触碰 Cargo。
|
||||
ai-game-creator-shell-web-tests:
|
||||
name: AI game creator shell web tests
|
||||
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: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Run AI game creator shell web gates
|
||||
run: npm run check:native-shells:agc-web
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.29",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
@@ -108,6 +108,8 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||
'bind_components',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
#!/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 片:
|
||||
// - CI 用 `--shard-index=<i>` 让**每个 job 只跑一片**,靠多个 job 并发把整套用例摊开;
|
||||
// - 本地不传 `--shard-index` 时把 N 片放进 N 个**独立进程**并行(--concurrency 可调),
|
||||
// 保留一条命令跑全量的入口。
|
||||
// 片并集必须等于全集、且不得重复,数量不符即失败,防止分片规则改动后静默漏跑;该校验
|
||||
// 与「只跑一片」无关,因此在每个 job 上都会执行。
|
||||
//
|
||||
// 注意:同一容器内多进程并行这套用例(共享 HOME、target、固定临时路径)实测会互相拖慢,
|
||||
// 比串行还慢,所以 CI 走「一个 job 一片」而不是单 job 内并行。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=2 # CI:只跑第 2 片
|
||||
// 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
|
||||
// --shard-index=<i> 只跑第 i 片(1..shards);不传则跑全部分片
|
||||
// --concurrency=<n> 同时运行的片数,默认等于分片数;--shard-index 时恒为 1
|
||||
// --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,
|
||||
shardIndex: undefined,
|
||||
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 'shard-index':
|
||||
options.shardIndex = parsePositiveInteger('--shard-index', 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}`);
|
||||
}
|
||||
|
||||
if (options.shardIndex !== undefined && options.shardIndex > options.shards) {
|
||||
fail(
|
||||
`--shard-index (${options.shardIndex}) must be within --shards (${options.shards})`,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const selectedShards =
|
||||
options.shardIndex === undefined
|
||||
? shards.map((shardTestNames, index) => ({ index, shardTestNames }))
|
||||
: [
|
||||
{
|
||||
index: options.shardIndex - 1,
|
||||
shardTestNames: shards[options.shardIndex - 1],
|
||||
},
|
||||
];
|
||||
|
||||
if (options.shardIndex === undefined) {
|
||||
console.log(
|
||||
`[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`,
|
||||
);
|
||||
for (const { index, shardTestNames } of selectedShards) {
|
||||
console.log(
|
||||
`[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), running shard ${options.shardIndex}/${shards.length} (${selectedShards[0].shardTestNames.length} test(s))`,
|
||||
);
|
||||
}
|
||||
|
||||
const results = await runWithConcurrency(
|
||||
selectedShards,
|
||||
({ index, shardTestNames }) =>
|
||||
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));
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const SKILL_PACK_SCHEMA_VERSION = 'agc-skill-pack.v1';
|
||||
export const EXPECTED_SKILL_NAMES = Object.freeze([
|
||||
'agc-browser-playtest',
|
||||
'agc-client-projection',
|
||||
'agc-game-production-workflow',
|
||||
'agc-project-structure',
|
||||
'agc-web-game-development',
|
||||
'taonier-art-assets',
|
||||
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.27"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.27"
|
||||
version = "0.1.29"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: agc-game-production-workflow
|
||||
description: Orchestrate a complete AGC game delivery from an approved brief to a playable, art-integrated, browser-validated product. Use when creating a new game, implementing a substantial game brief, or turning a planning document into a finished game.
|
||||
---
|
||||
|
||||
# AGC Game Production Workflow
|
||||
|
||||
Use this Skill as the top-level SOP for a new game or a substantial game brief. The tools are stages in one delivery chain, not independent suggestions. Do not stop after producing a plan, after writing code, or after generating an image.
|
||||
|
||||
## Stage flow
|
||||
|
||||
1. **Brief and scope** — Read the current planning output and project instructions. Extract the game loop, player actions, entities, visual requirements, target viewports, and the completion evidence. If the brief is incomplete, ask focused questions before side effects.
|
||||
2. **Project and asset inventory** — Inspect the existing project structure and call `agc_list_registered_assets` (and `agc_list_project_files` when needed). Record which requested visuals already have usable registered identities and which are missing. Do not invent asset identities from filenames.
|
||||
3. **Visual production** — For missing or unsuitable visuals, call the reviewed `agc_tools` workflow: use `taonier_prepare_game_art` for a complete package, or `agc_generate_image` / `agc_edit_image` for focused assets. Read returned paths, identities, and warnings. A warning or partial package requires a narrower retry or independent assets before continuing.
|
||||
4. **Game implementation** — Implement the complete playable loop and wire the returned project-relative asset paths into the actual runtime. Every required character, object, background, effect, and UI visual must have a real source or an explicit brief-level decision to remain code-native. Generated assets that are unused, documentation-only, or replaced by emoji/CSS placeholders do not satisfy this stage.
|
||||
5. **Build and local verification** — Run the project’s bootstrap/install and verify/build commands. Confirm the actual playable entry under `dist` (or the editor runtime for a supported editor project) and fix build or asset-loading failures before preview.
|
||||
6. **Browser playtest** — Call `agc_browser_playtest` for desktop and mobile evidence after meaningful changes. Check the game loop, input, layout, asset loading, and visible use of the generated art. Fix findings and repeat stages 4–6 until the evidence is clean.
|
||||
7. **Delivery** — Report the implemented behavior, real asset paths and identities used, build result, playtest evidence, warnings, and any explicit remaining gap. Do not claim complete while a required stage is failed, skipped without the brief’s justification, or missing evidence.
|
||||
|
||||
## Stage transitions
|
||||
|
||||
Advance only when the current stage has its output: brief → inventory; inventory → art decision; art decision → usable registered assets or an explicit no-art decision; implementation → source references to those assets; build → playable entry; playtest → evidence; delivery → truthful report. If a tool fails, preserve its error and stop or repair at that stage instead of silently substituting a later-stage placeholder.
|
||||
|
||||
For a small edit to an existing game where the brief and suitable assets are unchanged, use the focused edit path and do not regenerate art. This exception does not apply to a new game or a substantial planning brief.
|
||||
|
||||
Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`.
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "完整游戏生产流程"
|
||||
short_description: "从策划案到真实美术接入和试玩验收的连续交付"
|
||||
default_prompt: "Use $agc-game-production-workflow to take the current game brief through inventory, art, implementation, build, playtest, and delivery."
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
# Workflow contract
|
||||
|
||||
The production Skill owns sequencing and transition evidence. Specialist Skills own the detailed safety and data rules for each tool family. A specialist tool result is never a delivery result by itself: image generation must be followed by registered identity inspection and runtime integration; code writing must be followed by build verification; a successful preview launch must be followed by desktop and mobile playtest evidence when the brief targets both.
|
||||
|
||||
The no-art exception is valid only when the brief explicitly requests a code-native visual treatment or the inventory proves that all required visuals are already registered and suitable. Emoji, CSS primitives, random local files, and generated files that are not referenced by the runtime are not evidence of an integrated art package.
|
||||
+1
-1
@@ -14,7 +14,7 @@ Implement the user's actual game request in the current project as an npm-manage
|
||||
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
|
||||
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
|
||||
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
|
||||
6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art.
|
||||
6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art.
|
||||
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
|
||||
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
|
||||
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.12",
|
||||
"version": "2026-08-26.13",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
"purpose": "把完整游戏从策划案按阶段推进到真实素材接入、构建、试玩和交付",
|
||||
"triggers": [
|
||||
"从策划案创建完整游戏",
|
||||
"实现完整游戏交付",
|
||||
"需要衔接策划、素材、代码、构建和试玩"
|
||||
],
|
||||
"requiredTools": [
|
||||
"agc_tools.agc_list_registered_assets",
|
||||
"agc_tools.agc_generate_image",
|
||||
"agc_tools.agc_edit_image",
|
||||
"agc_tools.taonier_prepare_game_art",
|
||||
"agc_tools.agc_browser_playtest"
|
||||
],
|
||||
"files": [
|
||||
"SKILL.md",
|
||||
"agents/openai.yaml",
|
||||
"references/workflow-contract.md"
|
||||
],
|
||||
"sha256": "91082fdff4123f1e1fcf930af433cbea51a8c9d26991678b19028b344ea49f39"
|
||||
},
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
"purpose": "约束当前项目根、游戏代码、美术素材与客户端状态的职责边界",
|
||||
@@ -31,6 +53,7 @@
|
||||
"已有陶泥儿素材需要接入玩法"
|
||||
],
|
||||
"requiredTools": [
|
||||
"agc_tools.agc_list_registered_assets",
|
||||
"agc_tools.agc_generate_image",
|
||||
"agc_tools.agc_edit_image",
|
||||
"agc_tools.taonier_prepare_game_art"
|
||||
@@ -40,7 +63,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/platform-art-contract.md"
|
||||
],
|
||||
"sha256": "82e4b2ee8ca8147b51ca206b0565b3cc244dc5d3cddb8343875001c0beb4711f"
|
||||
"sha256": "bd1e415aac0cd0f97090296f34c67898dd731d1e177ec91a56027f9b68a88b37"
|
||||
},
|
||||
{
|
||||
"name": "agc-web-game-development",
|
||||
@@ -57,7 +80,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/game-quality-checklist.md"
|
||||
],
|
||||
"sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a"
|
||||
"sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd"
|
||||
},
|
||||
{
|
||||
"name": "agc-browser-playtest",
|
||||
|
||||
+12
-4
@@ -5,7 +5,15 @@ description: Prepare, recover, inspect, and integrate real Taonier platform game
|
||||
|
||||
# Taonier Art Assets
|
||||
|
||||
Use real platform assets only through the reviewed `agc_tools` MCP server. Use
|
||||
Use real platform assets only through the reviewed `agc_tools` MCP server. When
|
||||
building a new game from a brief that names characters, objects, backgrounds,
|
||||
effects, or other visual elements, this Skill is an execution step: inspect
|
||||
existing assets, generate or reuse suitable art, process it when needed, and
|
||||
integrate the returned paths into the playable game before reporting the game
|
||||
complete. Do not treat the art step as optional merely because the user did
|
||||
not repeat “生图” in the latest message.
|
||||
|
||||
Use
|
||||
`agc_generate_image` for a single ordinary image, character image, visual-spec
|
||||
image, UI design image, or publication material; use `agc_edit_image` for an
|
||||
edit of an existing registered image; use `taonier_prepare_game_art` only for
|
||||
@@ -17,11 +25,11 @@ the complete game-art package and its canonical slices.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when the user did not ask to regenerate them.
|
||||
1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when they satisfy the current brief. If the brief requires visual elements that are absent or unsuitable, call the appropriate generation tool during the same game implementation task; do not continue with placeholder art and silently defer generation.
|
||||
2. For one new image, call `agc_generate_image` with `kind="image"` (or `character`, `icon-spec`, `ui-prototype`, or `publication-material` when that is the explicit intent). For changes to an existing registered image, call `agc_edit_image` with its `sourceLocalAssetId`; do not fake an edit with a new-image request. For a complete game-art package, call `taonier_prepare_game_art` only when the current intent requires new or recoverable platform art. Use `mode="regenerate"` only after the latest User message is a standalone reviewed immediate-confirmation command such as `请重新生成美术`; punctuation may end it, but no brief, condition, negation, alternative, cost qualifier, deferral, or other text may accompany it. Describe the desired style and gameplay constraints in an earlier non-billable turn, then obtain the standalone confirmation turn; otherwise use `mode="reuse-or-create"`. Quoted UI copy or examples, explanations, questions, historical wording, model/MCP arguments do not authorize regeneration. Pass a concise game-specific visual brief that names the required gameplay entities, background exclusions, tiling needs, and viewport constraints. Do not call either generation tool for greetings, date questions, or text-only code fixes.
|
||||
3. Treat the tool result as authoritative. Read `mode`, `assetPaths`, `slicePaths`, `resources`, and every entry in both `warnings` and `sliceWarnings`. `resources` is the client's safe projection of registered Canvas identities; use only its returned relative paths and identities. Never invent a resource, slice, platform identity, warning-free result, or successful regeneration.
|
||||
4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement; stop and report the warning instead of guessing atlas coordinates or fabricating derivatives. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets.
|
||||
5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use.
|
||||
4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement: if a `sliceWarning` reports too many or unusable elements, narrow the edit/generation brief or generate the needed independent images and continue the integration; do not guess atlas coordinates, fabricate derivatives, or silently fall back to placeholders. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets.
|
||||
5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. The implementation is incomplete while generated assets remain unused, are referenced only by documentation, or are replaced by emoji, CSS shapes, or other placeholders where the brief requires the generated art. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use.
|
||||
6. Preserve warning details in the final report. If the tool reports missing credentials, uncertain operation state, invalid provenance, download failure, or decode failure, stop and report the actionable reason; do not substitute generated CSS shapes and call the platform step complete.
|
||||
|
||||
Before interpreting async recovery, source-preserved warnings, or slice warnings, read `references/platform-art-contract.md`.
|
||||
|
||||
@@ -4999,7 +4999,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es
|
||||
printf '%s\n' '{"id":2,"result":{}}'
|
||||
IFS= read -r skills_list
|
||||
case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac
|
||||
printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}'
|
||||
printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}'
|
||||
while IFS= read -r line; do :; done
|
||||
"#,
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1156,6 +1156,10 @@ mod tests {
|
||||
assert!(with_canvas.contains("根据当前玩法需求编写规格和界面建议"));
|
||||
assert!(with_canvas.contains("用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet"));
|
||||
assert!(with_canvas.contains("再调用 canvas.asset_generate"));
|
||||
assert!(with_canvas.contains("调用 canvas.asset_generate"));
|
||||
assert!(with_canvas.contains("不要使用固定图片合同"));
|
||||
assert!(with_canvas.contains("不修改 game/index.html"));
|
||||
assert!(!with_canvas.contains("不调用 canvas.asset_generate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -801,7 +801,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
|
||||
let initial_step = route.get("initialStep")?.as_str()?;
|
||||
let render_mode = route.get("renderMode")?.as_str()?;
|
||||
if resource_id.is_empty()
|
||||
|| initial_step != "visual-binding"
|
||||
|| initial_step != "asset-separation"
|
||||
|| render_mode != "final-preview"
|
||||
{
|
||||
return None;
|
||||
|
||||
@@ -6,15 +6,16 @@ use std::path::{Component, Path};
|
||||
|
||||
const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json");
|
||||
const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1";
|
||||
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 5] = [
|
||||
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 6] = [
|
||||
"agc-browser-playtest",
|
||||
"agc-client-projection",
|
||||
"agc-game-production-workflow",
|
||||
"agc-project-structure",
|
||||
"agc-web-game-development",
|
||||
"taonier-art-assets",
|
||||
];
|
||||
|
||||
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [
|
||||
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 18] = [
|
||||
(
|
||||
"agc-browser-playtest/SKILL.md",
|
||||
include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"),
|
||||
@@ -43,6 +44,20 @@ const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [
|
||||
"../../resources/agc-skills/agc-client-projection/references/projection-contract.md"
|
||||
),
|
||||
),
|
||||
(
|
||||
"agc-game-production-workflow/SKILL.md",
|
||||
include_bytes!("../../resources/agc-skills/agc-game-production-workflow/SKILL.md"),
|
||||
),
|
||||
(
|
||||
"agc-game-production-workflow/agents/openai.yaml",
|
||||
include_bytes!("../../resources/agc-skills/agc-game-production-workflow/agents/openai.yaml"),
|
||||
),
|
||||
(
|
||||
"agc-game-production-workflow/references/workflow-contract.md",
|
||||
include_bytes!(
|
||||
"../../resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md"
|
||||
),
|
||||
),
|
||||
(
|
||||
"agc-project-structure/SKILL.md",
|
||||
include_bytes!("../../resources/agc-skills/agc-project-structure/SKILL.md"),
|
||||
@@ -291,10 +306,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_skill_pack_is_exactly_the_five_reviewed_skills() {
|
||||
fn bundled_skill_pack_is_exactly_the_six_reviewed_skills() {
|
||||
let manifest = validated_skill_pack_manifest().expect("validated manifest");
|
||||
assert_eq!(manifest.schema_version, "agc-skill-pack.v1");
|
||||
assert_eq!(manifest.skills.len(), 5);
|
||||
assert_eq!(manifest.skills.len(), 6);
|
||||
assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64));
|
||||
let serialized = serde_json::to_string(
|
||||
&manifest
|
||||
|
||||
@@ -339,6 +339,41 @@ async fn recognize_ui(
|
||||
ui_editor::commands::recognize_ui_impl(project_path, state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn separate_ui(
|
||||
project_path: String,
|
||||
asset_id: String,
|
||||
state: ui_editor::state::State,
|
||||
) -> Result<ui_editor::commands::SeparationDTO, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn inspect_separation_recovery(
|
||||
project_path: String,
|
||||
asset_id: String,
|
||||
) -> Result<ui_editor::commands::SeparationRecoveryDTO, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.list")?;
|
||||
ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
ui_editor::commands::separation::finalize_separation(root, &asset_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
ui_editor::commands::separation::discard_separation_recovery(root, &asset_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands::MergeDTO, String> {
|
||||
ui_editor::commands::merge_ui_impl(state).await
|
||||
@@ -2689,6 +2724,10 @@ fn main() {
|
||||
check_ui_editor_font_glyph_coverage,
|
||||
suggest_ui_design_semantic,
|
||||
recognize_ui,
|
||||
separate_ui,
|
||||
inspect_separation_recovery,
|
||||
finalize_separation,
|
||||
discard_separation_recovery,
|
||||
merge_ui,
|
||||
bind_components,
|
||||
load_ui_design_state,
|
||||
|
||||
@@ -14,16 +14,59 @@ const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30;
|
||||
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
|
||||
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
|
||||
|
||||
/// 本进程内真正落盘持有项目写锁的线程登记表。
|
||||
///
|
||||
/// `.agent/project.lock` 的 `pid` 只能证明“锁由本进程的某条写通道持有”,它分不清
|
||||
/// 两种完全不同的局面:
|
||||
/// - **同一条调用链再次取锁**:持锁方就是自己,必须放行,否则每次嵌套项目写入都要
|
||||
/// 白等一个等待预算再报“项目正在被其他写操作占用”;
|
||||
/// - **本进程另一条写通道正在写**:项目 revision 侧车、steer 序号、一致快照读、
|
||||
/// pending sidecar 复核和恢复安装都靠这把锁串行化,必须照旧等待。
|
||||
///
|
||||
/// 复用判据因此不能停在 `pid`:只有**当前线程**就是真实持锁线程时才返回 advisory
|
||||
/// guard,本进程其余争用继续走有界等待与终态占用。登记按路径进行、按路径注销:
|
||||
/// guard 可能被移到别的线程再 Drop(例如写入路径把锁交给阻塞线程池的持有者),
|
||||
/// 按线程注销会漏项,让后续的重入判断失真。
|
||||
static PROJECT_WRITE_LOCK_THREAD_OWNERS: std::sync::Mutex<Vec<(PathBuf, std::thread::ThreadId)>> =
|
||||
std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn project_write_lock_thread_owners(
|
||||
) -> std::sync::MutexGuard<'static, Vec<(PathBuf, std::thread::ThreadId)>> {
|
||||
// 登记表只是复用判据的加速器:中毒时继续用内部值,不能让一次取锁失败升级成
|
||||
// 整个进程再也写不了项目。
|
||||
PROJECT_WRITE_LOCK_THREAD_OWNERS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn register_project_write_lock_thread_owner(path: &Path) {
|
||||
let mut owners = project_write_lock_thread_owners();
|
||||
if owners.iter().any(|(owner, _)| owner == path) {
|
||||
return;
|
||||
}
|
||||
owners.push((path.to_path_buf(), std::thread::current().id()));
|
||||
}
|
||||
|
||||
fn unregister_project_write_lock_thread_owner(path: &Path) {
|
||||
project_write_lock_thread_owners().retain(|(owner, _)| owner != path);
|
||||
}
|
||||
|
||||
/// 当前线程是否就是这条锁路径上真实落盘的持有者(同线程重入)。
|
||||
fn project_write_lock_reentered_by_current_thread(path: &Path) -> bool {
|
||||
let thread = std::thread::current().id();
|
||||
project_write_lock_thread_owners()
|
||||
.iter()
|
||||
.any(|(owner, owner_thread)| owner == path && *owner_thread == thread)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProjectWriteLock {
|
||||
path: PathBuf,
|
||||
content: String,
|
||||
/// In the free-form autonomous lane a single Runtime process may have
|
||||
/// several specialist actions in flight at once. A file lock is still
|
||||
/// useful across processes, but making same-process contenders fail turns
|
||||
/// ordinary parallel work into a dead run (and can deadlock nested tool
|
||||
/// calls). Such a contender receives an in-process/advisory guard instead
|
||||
/// of deleting the real holder's lock on drop.
|
||||
/// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条
|
||||
/// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种
|
||||
/// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有
|
||||
/// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。
|
||||
bypassed_same_process: bool,
|
||||
}
|
||||
|
||||
@@ -47,6 +90,7 @@ impl Drop for ProjectWriteLock {
|
||||
if self.bypassed_same_process {
|
||||
return;
|
||||
}
|
||||
unregister_project_write_lock_thread_owner(&self.path);
|
||||
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
@@ -815,6 +859,7 @@ pub(crate) fn acquire_project_write_lock_failure(
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
register_project_write_lock_thread_owner(&path);
|
||||
return Ok(ProjectWriteLock {
|
||||
path,
|
||||
content: content.clone(),
|
||||
@@ -860,11 +905,15 @@ pub(crate) fn acquire_project_write_lock_failure(
|
||||
}
|
||||
}
|
||||
}
|
||||
if project_write_lock_is_owned_by_current_process(&path) {
|
||||
// A project lock is the client-use lock. Nested calls in
|
||||
// the same client process must reuse that ownership instead
|
||||
// of waiting on their own durable marker. Cross-process
|
||||
// contenders still take the normal retryable path.
|
||||
if project_write_lock_is_owned_by_current_process(&path)
|
||||
&& (crate::agent::autonomous_game_build_root_run_active_at(root)
|
||||
|| project_write_lock_reentered_by_current_thread(&path))
|
||||
{
|
||||
// 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一
|
||||
// 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory
|
||||
// guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走
|
||||
// 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装
|
||||
// 的串行化。
|
||||
return Ok(ProjectWriteLock {
|
||||
path,
|
||||
content: String::new(),
|
||||
|
||||
@@ -5818,8 +5818,27 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
||||
},
|
||||
)
|
||||
.expect("allow direct file write");
|
||||
let lock = acquire_project_write_lock(&root, "persistent-writer")
|
||||
.expect("acquire persistent project writer");
|
||||
// 持锁方必须是**另一条线程**:本用例验证的是“别的写通道正在写时 file.write 必须
|
||||
// 走满等待预算并失败关闭”,同一条调用链自持锁属于重入复用,不会失败。
|
||||
let holder_root = root.clone();
|
||||
let (release_sender, release_receiver) = mpsc::channel::<()>();
|
||||
let holder = std::thread::spawn(move || {
|
||||
let lock = acquire_project_write_lock(&holder_root, "persistent-writer")
|
||||
.expect("acquire persistent project writer");
|
||||
let _ = release_receiver.recv();
|
||||
drop(lock);
|
||||
});
|
||||
let lock_path = root.join(PROJECT_WRITE_LOCK_PATH);
|
||||
for _ in 0..400 {
|
||||
if lock_path.is_file() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(
|
||||
lock_path.is_file(),
|
||||
"persistent writer must hold the project write lock"
|
||||
);
|
||||
|
||||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||||
&root,
|
||||
@@ -5837,7 +5856,8 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
||||
)
|
||||
.await;
|
||||
|
||||
drop(lock);
|
||||
let _ = release_sender.send(());
|
||||
holder.join().expect("join persistent project writer");
|
||||
assert_eq!(observation.status, "failed");
|
||||
assert!(!observation
|
||||
.summary
|
||||
|
||||
@@ -5,11 +5,9 @@ use crate::ui_editor::commands::utils::{
|
||||
strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::component::{Component, NodeComponent};
|
||||
use crate::ui_editor::layout::node::{Node, StageStatus};
|
||||
use crate::ui_editor::persistence::{
|
||||
UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE, UI_DESIGN_STATE_MAX_NODES,
|
||||
};
|
||||
use crate::ui_editor::persistence::UI_DESIGN_STATE_MAX_NODES;
|
||||
use crate::ui_editor::state::State;
|
||||
use crate::ui_editor::utils::{FontAssetId, NodeId, SpriteAssetId};
|
||||
use platform_llm::{
|
||||
@@ -31,10 +29,10 @@ const SYSTEM_PROMPT: &str = r#"
|
||||
你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。
|
||||
|
||||
* 只对视觉上确实需要改变组件的节点返回 changes;
|
||||
* 每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。
|
||||
* 对每个 Component,直接完整返回其全部参数.
|
||||
* 每个 change 的 component 是该节点完整的新组件;纯结构节点返回 "PureNode",有组件返回 {"WithComponent": <完整 Component>}。
|
||||
* 对 Component,直接完整返回其全部参数.
|
||||
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
|
||||
* 纯结构节点可以返回空数组并标为 NoProblem。
|
||||
* 纯结构节点可以返回 "PureNode" 并标为 NoProblem。
|
||||
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
|
||||
* 面向用户的 reason 使用中文。
|
||||
|
||||
@@ -53,8 +51,8 @@ enum DraftStatus {
|
||||
#[schemars(deny_unknown_fields)]
|
||||
struct BindingChangeDraft {
|
||||
node_id: NodeId,
|
||||
components: Vec<Component>,
|
||||
components_status: DraftStatus,
|
||||
component: NodeComponent,
|
||||
component_status: DraftStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
@@ -68,8 +66,8 @@ struct BindingResponse {
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct BindingChange {
|
||||
pub node_id: NodeId,
|
||||
pub components: Vec<Component>,
|
||||
pub components_status: StageStatus,
|
||||
pub component: NodeComponent,
|
||||
pub component_status: StageStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
@@ -83,7 +81,7 @@ struct EditableNodeContext<'a> {
|
||||
node_id: &'a NodeId,
|
||||
name: &'a str,
|
||||
description: &'a str,
|
||||
components: &'a [Component],
|
||||
component: Option<&'a Component>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -106,7 +104,7 @@ fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec<EditableNodeConte
|
||||
node_id: &node.id,
|
||||
name: &node.metadata.name,
|
||||
description: &node.metadata.description,
|
||||
components: &node.components,
|
||||
component: node.component.as_ref(),
|
||||
});
|
||||
}
|
||||
for child in &node.children {
|
||||
@@ -172,14 +170,21 @@ fn validate_binding_response_shape(
|
||||
return Err(format!("组件绑定 changes 不能超过 {max_changes} 条"));
|
||||
}
|
||||
for change in changes {
|
||||
let components = change
|
||||
.get("components")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "组件绑定 change 缺少 components 数组".to_string())?;
|
||||
if components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE {
|
||||
return Err(format!(
|
||||
"单个组件绑定栈不能超过 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件"
|
||||
));
|
||||
let Some(object) = change.as_object() else {
|
||||
return Err("组件绑定 change 缺少 component 字段".to_string());
|
||||
};
|
||||
let Some(component) = object.get("component") else {
|
||||
return Err("组件绑定 change 缺少 component 字段".to_string());
|
||||
};
|
||||
let valid_component = component == "PureNode"
|
||||
|| component
|
||||
.as_object()
|
||||
.and_then(|value| value.get("WithComponent"))
|
||||
.is_some_and(serde_json::Value::is_object);
|
||||
if !valid_component {
|
||||
return Err(
|
||||
"组件绑定 change 的 component 必须是 PureNode 或 WithComponent 对象".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -212,7 +217,7 @@ fn validate_and_materialize(
|
||||
if !changed_ids.insert(change.node_id.clone()) {
|
||||
return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str()));
|
||||
}
|
||||
for component in &change.components {
|
||||
if let NodeComponent::WithComponent(component) = &change.component {
|
||||
match component {
|
||||
Component::Image(image) => {
|
||||
if image
|
||||
@@ -232,18 +237,22 @@ fn validate_and_materialize(
|
||||
}
|
||||
}
|
||||
}
|
||||
let components = change.components;
|
||||
let components_status = match change.components_status {
|
||||
let component_status = match change.component_status {
|
||||
DraftStatus::NoProblem => StageStatus::NoProblem,
|
||||
DraftStatus::NeedReview(reason) if reason.trim().is_empty() => {
|
||||
return Err("组件待审状态必须包含原因".to_string())
|
||||
}
|
||||
DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason),
|
||||
DraftStatus::NeedReview(reason) => {
|
||||
if matches!(&change.component, NodeComponent::PureNode) {
|
||||
return Err("纯结构节点不能标记为组件待审".to_string());
|
||||
}
|
||||
StageStatus::NeedReview(reason)
|
||||
}
|
||||
};
|
||||
materialized.push(BindingChange {
|
||||
node_id: change.node_id,
|
||||
components,
|
||||
components_status,
|
||||
component: change.component,
|
||||
component_status,
|
||||
});
|
||||
}
|
||||
Ok(BindingDTO {
|
||||
@@ -440,8 +449,8 @@ mod tests {
|
||||
]);
|
||||
let unapproved = BindingChangeDraft {
|
||||
node_id: id("other"),
|
||||
components: Vec::new(),
|
||||
components_status: DraftStatus::NoProblem,
|
||||
component: NodeComponent::PureNode,
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
assert!(
|
||||
validate_and_materialize(vec![unapproved], &editable, &known, &HashSet::new()).is_err()
|
||||
@@ -450,7 +459,7 @@ mod tests {
|
||||
// References to sprites from another batch are allowed once they exist in the project.
|
||||
let other_batch = BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: vec![Component::Image(
|
||||
component: NodeComponent::WithComponent(Component::Image(
|
||||
crate::ui_editor::component::image::ImageComponent {
|
||||
target_graphic: Some(
|
||||
SpriteAssetId::new("other-batch-sprite").expect("valid sprite"),
|
||||
@@ -459,8 +468,8 @@ mod tests {
|
||||
preserve_aspect: false,
|
||||
},
|
||||
},
|
||||
)],
|
||||
components_status: DraftStatus::NoProblem,
|
||||
)),
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
assert!(
|
||||
validate_and_materialize(vec![other_batch], &editable, &known, &HashSet::new()).is_ok()
|
||||
@@ -469,15 +478,15 @@ mod tests {
|
||||
// References to sprites that do not exist in the project at all are still rejected.
|
||||
let unknown = BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: vec![Component::Image(
|
||||
component: NodeComponent::WithComponent(Component::Image(
|
||||
crate::ui_editor::component::image::ImageComponent {
|
||||
target_graphic: Some(SpriteAssetId::new("unknown").expect("valid sprite")),
|
||||
image_type: crate::ui_editor::component::image::ImageType::Simple {
|
||||
preserve_aspect: false,
|
||||
},
|
||||
},
|
||||
)],
|
||||
components_status: DraftStatus::NoProblem,
|
||||
)),
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
assert!(
|
||||
validate_and_materialize(vec![unknown], &editable, &known, &HashSet::new()).is_err()
|
||||
@@ -491,8 +500,8 @@ mod tests {
|
||||
text.font = FontSource::Bound(FontAssetId::new("unknown-font").expect("valid font"));
|
||||
let change = BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: vec![Component::Text(text)],
|
||||
components_status: DraftStatus::NoProblem,
|
||||
component: NodeComponent::WithComponent(Component::Text(text)),
|
||||
component_status: DraftStatus::NoProblem,
|
||||
};
|
||||
|
||||
let error = validate_and_materialize(
|
||||
@@ -506,13 +515,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_preserves_changed_only_empty_component_lists() {
|
||||
fn materialization_preserves_pure_node_change() {
|
||||
let editable = HashSet::from([id("editable")]);
|
||||
let result = validate_and_materialize(
|
||||
vec![BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
components: Vec::new(),
|
||||
components_status: DraftStatus::NoProblem,
|
||||
component: NodeComponent::PureNode,
|
||||
component_status: DraftStatus::NoProblem,
|
||||
}],
|
||||
&editable,
|
||||
&HashSet::new(),
|
||||
@@ -520,8 +529,28 @@ mod tests {
|
||||
)
|
||||
.expect("valid changed-only clear");
|
||||
assert_eq!(result.changes.len(), 1);
|
||||
assert!(result.changes[0].components.is_empty());
|
||||
assert_eq!(result.changes[0].components_status, StageStatus::NoProblem);
|
||||
assert!(matches!(
|
||||
result.changes[0].component,
|
||||
NodeComponent::PureNode
|
||||
));
|
||||
assert_eq!(result.changes[0].component_status, StageStatus::NoProblem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialization_rejects_problematic_pure_node() {
|
||||
let editable = HashSet::from([id("editable")]);
|
||||
let error = validate_and_materialize(
|
||||
vec![BindingChangeDraft {
|
||||
node_id: id("editable"),
|
||||
component: NodeComponent::PureNode,
|
||||
component_status: DraftStatus::NeedReview("缺少可确认的组件".to_string()),
|
||||
}],
|
||||
&editable,
|
||||
&HashSet::new(),
|
||||
&HashSet::new(),
|
||||
)
|
||||
.expect_err("pure node cannot carry a component review status");
|
||||
assert!(error.contains("纯结构节点"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -575,20 +604,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_response_bounds_changes_and_each_component_stack() {
|
||||
fn binding_response_bounds_changes_and_uses_single_component_shape() {
|
||||
let too_many_changes = serde_json::json!({
|
||||
"changes": [{"components": []}, {"components": []}]
|
||||
"changes": [{"component": "PureNode"}, {"component": "PureNode"}]
|
||||
});
|
||||
assert!(validate_binding_response_shape(&too_many_changes, 1).is_err());
|
||||
|
||||
let too_many_components = serde_json::json!({
|
||||
let one_component = serde_json::json!({
|
||||
"changes": [{
|
||||
"components": (0..=UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE)
|
||||
.map(|_| serde_json::Value::Null)
|
||||
.collect::<Vec<_>>()
|
||||
"node_id": "editable",
|
||||
"component": "PureNode",
|
||||
"component_status": "NoProblem"
|
||||
}]
|
||||
});
|
||||
assert!(validate_binding_response_shape(&too_many_components, 1).is_err());
|
||||
assert!(validate_binding_response_shape(&one_component, 1).is_ok());
|
||||
let parsed = parse_binding_response(&one_component.to_string(), 1)
|
||||
.expect("explicit PureNode payload should parse");
|
||||
assert!(matches!(
|
||||
parsed.changes[0].component,
|
||||
NodeComponent::PureNode
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -174,7 +174,7 @@ mod materialize {
|
||||
};
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
||||
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -230,12 +230,11 @@ mod materialize {
|
||||
Ok(best_index)
|
||||
}
|
||||
|
||||
fn random_node_id(occupied: &mut HashSet<NodeId>) -> Result<NodeId, String> {
|
||||
fn unique_random_node_id(occupied: &mut HashSet<NodeId>) -> NodeId {
|
||||
loop {
|
||||
let id = NodeId::new(uuid::Uuid::new_v4().simple().to_string())
|
||||
.map_err(|error| format!("生成合并容器节点 ID 失败:{error}"))?;
|
||||
let id = random_node_id();
|
||||
if occupied.insert(id.clone()) {
|
||||
return Ok(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,7 +285,7 @@ mod materialize {
|
||||
}
|
||||
Ok(BuiltNode {
|
||||
node: LayoutNode {
|
||||
id: random_node_id(occupied_ids)?,
|
||||
id: unique_random_node_id(occupied_ids),
|
||||
layout:
|
||||
crate::ui_editor::layout::control_layout::ControlLayout::with_transform(
|
||||
original_transform,
|
||||
@@ -295,12 +294,12 @@ mod materialize {
|
||||
name: container_name,
|
||||
description: container_description,
|
||||
layout_status: StageStatus::NoProblem,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
components: Vec::new(),
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Exclusive,
|
||||
children: members.into_iter().map(|member| member.node).collect(),
|
||||
},
|
||||
@@ -525,7 +524,6 @@ mod tests {
|
||||
materialize, validate_merge_input_state, validate_merge_plan_shape, MAX_MERGE_INPUT_DEPTH,
|
||||
MAX_MERGE_INPUT_NODES, MAX_MERGE_PLAN_DEPTH, MAX_MERGE_PLAN_NODES,
|
||||
};
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus};
|
||||
@@ -542,12 +540,12 @@ mod tests {
|
||||
name: id.to_string(),
|
||||
description: String::new(),
|
||||
layout_status: StageStatus::NoProblem,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Human,
|
||||
},
|
||||
components: Vec::<Component>::new(),
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
}
|
||||
@@ -586,7 +584,7 @@ mod tests {
|
||||
ChildrenDisplayMode::Exclusive
|
||||
);
|
||||
assert_eq!(
|
||||
result.root.metadata.components_status,
|
||||
result.root.metadata.component_status,
|
||||
StageStatus::NoProblem
|
||||
);
|
||||
assert_eq!(result.root.children.len(), 2);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod binding;
|
||||
pub mod merge;
|
||||
pub mod recognition;
|
||||
pub mod separation;
|
||||
pub mod ui_design_suggestion;
|
||||
pub mod utils;
|
||||
|
||||
@@ -10,5 +11,7 @@ pub use merge::MergeDTO;
|
||||
pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider};
|
||||
pub use recognition::RecognitionDTO;
|
||||
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
|
||||
pub(crate) use separation::separate_ui_impl;
|
||||
pub use separation::{SeparationDTO, SeparationRecoveryDTO};
|
||||
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
|
||||
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::ui_editor::commands::utils::{
|
||||
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
|
||||
strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::component::{Component, NodeComponent};
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::dimension::UIRect;
|
||||
@@ -11,7 +12,7 @@ use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSourc
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
||||
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
|
||||
use nalgebra::{Point2, Vector2};
|
||||
use platform_llm::{
|
||||
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
|
||||
@@ -27,25 +28,31 @@ const MAX_RECOGNITION_TREE_NODES: usize = 512;
|
||||
const MAX_RECOGNITION_TREE_DEPTH: usize = 32;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"
|
||||
角色:
|
||||
你是游戏 UI 多图结构识别器。
|
||||
|
||||
任务:
|
||||
同时分析同一 UI 系统的全部参考图,建立UI树
|
||||
|
||||
用户会给你一些UI截图(它们从属于同一个UI系统)和对应的元数据, 请用给定的工具描述UI结构
|
||||
|
||||
识别规则:
|
||||
* 只识别 UI,不识别场景人物、地形、建筑、光影和背景装饰。
|
||||
* 只识别 UI元素. 要区分动态内容, 不要白费力气识别应该由程序生成/绘制的内容.(此类内容应该用一个整体节点+自然语言描述) 除此之外必须完整包含所有元素,结构.
|
||||
* 无法确定类型、层级、关系时,在 UnSure 中写明原因。
|
||||
* 返回的 trees 必须与输入图片一一对应,每张输入图片只能有一棵树,不能合并多张图片的树。 每棵树的 src_ui_design_image_id 必须等于对应输入图片标注的 id。
|
||||
* 每棵树root的 global_pos_x_px、global_pos_y_px、width_px、height_px、local_anchor 仅为占位并会被忽略,给合法值即可.
|
||||
* 每棵树必须使用自己的输入图片原始像素坐标系(0,0 as left top)输出
|
||||
global_pos_x_px、global_pos_y_px、width_px、height_px;
|
||||
* 为了响应式布局, 我们提供了类似godot的Anchor参数, 可以使用语义化的预设或者可custom的直接操作min max, 请准确地根据父子布局的关系使用
|
||||
* 面向用户的字段如名称描述等请用中文
|
||||
* 由于每个截图未必是完整的, 可能是局部的, 每棵树描述清楚每个截图上UI的层次结构即可
|
||||
* 不同树的共用框架/层次/...请使用使用相同的名称描述. 不同状态/变体名称使用相同的前缀, 用后缀区别
|
||||
* 粒度要求: 尽可能细致, 最小单元举例: 进度条的底槽、填充和外框; slider的底槽, dragger等
|
||||
* 粒度要求: 尽可能细致, 以可交互,方便程序化控制的最小单位为准. 包括不限于: icon, 进度条的底槽、填充和外框; slider的底槽, dragger等.
|
||||
* 为每个节点直接返回 component.
|
||||
无背景的逻辑容器返回 "PureNode",不要返回 null.
|
||||
有背景的容器推荐使用Simple+不锁定宽高比的Image component.
|
||||
目前我们只做识别, 不要求图片字体的具体绑定参数.
|
||||
文字组件要求: 艺术字等作为图片组件, 其余正常文字要作为单独的节点识别.
|
||||
* 多行文本只使用一个节点.
|
||||
* 不鼓励兄弟节点相互重叠.
|
||||
* 对于面板等容器的背景等, 必须作为父节点的组件, 禁止新增冗余的所谓"背景节点". 例如:对于全局的背景直接作为root节点的图片组件, 禁止另外添加节点
|
||||
|
||||
"#;
|
||||
|
||||
@@ -109,13 +116,14 @@ struct RecognitionNode {
|
||||
description: String,
|
||||
children: Vec<RecognitionNode>,
|
||||
confidence: Confidence,
|
||||
component: NodeComponent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
struct RecognitionTree {
|
||||
src_ui_design_image_id: UIDesignImageId,
|
||||
children: Vec<RecognitionNode>,
|
||||
root: RecognitionNode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)]
|
||||
@@ -143,10 +151,14 @@ fn validate_recognition_response_shape(value: &serde_json::Value) -> Result<(),
|
||||
return Err(format!("识别结果最多包含 {MAX_REFERENCES} 棵界面树"));
|
||||
}
|
||||
for tree in trees {
|
||||
let children = tree
|
||||
let root = tree
|
||||
.get("root")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.ok_or_else(|| "识别树缺少 root 节点".to_string())?;
|
||||
let children = root
|
||||
.get("children")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "识别树缺少 children 数组".to_string())?;
|
||||
.ok_or_else(|| "识别树根节点缺少 children 数组".to_string())?;
|
||||
let mut stack = children
|
||||
.iter()
|
||||
.map(|node| (node, 1usize))
|
||||
@@ -215,11 +227,6 @@ fn anchor_ranges(anchor: &Anchor) -> Result<(Vector2<f32>, Vector2<f32>), String
|
||||
Ok((min, max))
|
||||
}
|
||||
|
||||
fn random_node_id() -> Result<NodeId, String> {
|
||||
NodeId::new(uuid::Uuid::new_v4().simple().to_string())
|
||||
.map_err(|error| format!("生成节点 ID 失败:{error}"))
|
||||
}
|
||||
|
||||
fn image_layout_size(image: &UIDesignImage) -> Result<Vector2<f32>, String> {
|
||||
let pixels_per_unit = image.pixels_per_unit.get();
|
||||
if !pixels_per_unit.is_finite() || pixels_per_unit <= 0.0 {
|
||||
@@ -301,21 +308,18 @@ fn convert_node(
|
||||
.map(|child| convert_node(child, image_id, image, target_rect))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(LayoutNode {
|
||||
id: random_node_id()?,
|
||||
id: random_node_id(),
|
||||
layout: ControlLayout::with_transform(transform),
|
||||
metadata: NodeMetadata {
|
||||
name: source.name.clone(),
|
||||
description: source.description.clone(),
|
||||
layout_status: status,
|
||||
components_status: StageStatus::NoProblem,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
// V1 有意把识别结果限定为“结构草稿”:组件绑定属于后续独立阶段。
|
||||
// 因此空组件不是丢失数据,而是等待 visual-binding 阶段补齐 Image/Text。
|
||||
// 约定见 docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md。
|
||||
components: Vec::new(),
|
||||
component: source.component.clone().into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
})
|
||||
@@ -323,6 +327,26 @@ fn convert_node(
|
||||
|
||||
fn validate_confidence(nodes: &[RecognitionNode]) -> Result<(), String> {
|
||||
for node in nodes {
|
||||
if let NodeComponent::WithComponent(component) = &node.component {
|
||||
if matches!(
|
||||
component,
|
||||
Component::Image(crate::ui_editor::component::image::ImageComponent {
|
||||
target_graphic: Some(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Err("识别阶段不能返回已绑定的 SpriteAssetId".to_string());
|
||||
}
|
||||
if matches!(
|
||||
component,
|
||||
Component::Text(crate::ui_editor::component::text::TextComponent {
|
||||
font: crate::ui_editor::component::text::FontSource::Bound(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
return Err("识别阶段不能返回已绑定的字体素材".to_string());
|
||||
}
|
||||
}
|
||||
if let Confidence::UnSure(reason) = &node.confidence {
|
||||
if reason.trim().is_empty() {
|
||||
return Err("UnSure 必须包含审阅原因".to_string());
|
||||
@@ -346,7 +370,7 @@ fn validate_tree_image_ids(
|
||||
if !seen.insert(tree.src_ui_design_image_id.clone()) {
|
||||
return Err("LLM 为同一界面图返回了重复 UI 树".to_string());
|
||||
}
|
||||
validate_confidence(&tree.children)?;
|
||||
validate_confidence(std::slice::from_ref(&tree.root))?;
|
||||
}
|
||||
if seen.len() != allowed.len() {
|
||||
return Err("LLM 未为当前识别上下文的每张界面图返回 UI 树".to_string());
|
||||
@@ -387,6 +411,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
children: Vec::new(),
|
||||
confidence: Confidence::Confident,
|
||||
component: NodeComponent::PureNode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,8 +450,8 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
let response = serde_json::json!({
|
||||
"trees": [
|
||||
{"children": leaves.clone()},
|
||||
{"children": leaves}
|
||||
{"root": {"children": leaves.clone()}},
|
||||
{"root": {"children": leaves}}
|
||||
]
|
||||
});
|
||||
validate_recognition_response_shape(&response)
|
||||
@@ -439,7 +464,7 @@ mod tests {
|
||||
.map(|_| serde_json::json!({"children": []}))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(validate_recognition_response_shape(&serde_json::json!({
|
||||
"trees": [{"children": oversized}]
|
||||
"trees": [{"root": {"children": oversized}}]
|
||||
}))
|
||||
.is_err());
|
||||
|
||||
@@ -448,7 +473,7 @@ mod tests {
|
||||
nested = serde_json::json!({"children": [nested]});
|
||||
}
|
||||
assert!(validate_recognition_response_shape(&serde_json::json!({
|
||||
"trees": [{"children": [nested]}]
|
||||
"trees": [{"root": {"children": [nested]}}]
|
||||
}))
|
||||
.is_err());
|
||||
}
|
||||
@@ -490,6 +515,31 @@ mod tests {
|
||||
description: String::new(),
|
||||
children: Vec::new(),
|
||||
confidence: Confidence::UnSure(String::new()),
|
||||
component: NodeComponent::PureNode,
|
||||
};
|
||||
assert!(validate_confidence(&[node]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_rejects_bound_font_references() {
|
||||
let mut text = crate::ui_editor::component::text::TextComponent::default();
|
||||
text.font = crate::ui_editor::component::text::FontSource::Bound(
|
||||
crate::ui_editor::utils::FontAssetId::new("font").expect("valid font id"),
|
||||
);
|
||||
let node = RecognitionNode {
|
||||
global_pos_x_px: 0,
|
||||
global_pos_y_px: 0,
|
||||
width_px: 1,
|
||||
height_px: 1,
|
||||
local_anchor: Anchor::Preset(PresetAnchor {
|
||||
horizontal: HorizontalAnchor::Left,
|
||||
vertical: VerticalAnchor::Top,
|
||||
}),
|
||||
name: "文本".to_string(),
|
||||
description: String::new(),
|
||||
children: Vec::new(),
|
||||
confidence: Confidence::Confident,
|
||||
component: NodeComponent::WithComponent(Component::Text(text)),
|
||||
};
|
||||
assert!(validate_confidence(&[node]).is_err());
|
||||
}
|
||||
@@ -506,7 +556,7 @@ mod tests {
|
||||
converted.layout.transform.resolve(&root_rect),
|
||||
UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)),
|
||||
);
|
||||
assert_eq!(converted.metadata.components_status, StageStatus::NoProblem);
|
||||
assert_eq!(converted.metadata.component_status, StageStatus::NoProblem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -542,7 +592,7 @@ mod tests {
|
||||
let slave = UIDesignImageId::new("slave").expect("valid image id");
|
||||
let tree = |id: UIDesignImageId| RecognitionTree {
|
||||
src_ui_design_image_id: id,
|
||||
children: Vec::new(),
|
||||
root: test_node(),
|
||||
};
|
||||
|
||||
assert!(validate_tree_image_ids(
|
||||
@@ -775,24 +825,37 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
.ok_or_else(|| format!("缺少界面图 {} 的识别树", image_id.as_str()))?;
|
||||
let size = image_layout_size(image)?;
|
||||
let root_rect = UIRect::new(Point2::origin(), size);
|
||||
let children = tree
|
||||
// The recognition root is a real UI node. Its pixel geometry and
|
||||
// anchor are intentionally ignored; the page root always fills
|
||||
// the design image while the other recognition fields take effect.
|
||||
let recognition_root = tree.root;
|
||||
let children = recognition_root
|
||||
.children
|
||||
.iter()
|
||||
.map(|node| convert_node(node, &image_id, image, root_rect))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let root_layout_status = match recognition_root.confidence {
|
||||
Confidence::Confident => StageStatus::NoProblem,
|
||||
Confidence::UnSure(reason) => StageStatus::NeedReview(reason),
|
||||
};
|
||||
let root_name = if recognition_root.name.trim().is_empty() {
|
||||
"页面根节点".to_string()
|
||||
} else {
|
||||
recognition_root.name
|
||||
};
|
||||
let root = LayoutNode {
|
||||
id: random_node_id()?,
|
||||
id: random_node_id(),
|
||||
layout: ControlLayout::with_transform(Transform::stretch()),
|
||||
metadata: NodeMetadata {
|
||||
name: "页面根节点".to_string(),
|
||||
description: String::new(),
|
||||
layout_status: StageStatus::NoProblem,
|
||||
components_status: StageStatus::NoProblem,
|
||||
name: root_name,
|
||||
description: recognition_root.description,
|
||||
layout_status: root_layout_status,
|
||||
component_status: StageStatus::NoProblem,
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: NodeSource::System,
|
||||
source: NodeSource::Llm,
|
||||
},
|
||||
components: Vec::new(),
|
||||
component: recognition_root.component.into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user