Compare commits

..

21 Commits

Author SHA1 Message Date
k88936 c7fc9a1b57 移除识别绑定旧计算命名
清理请求错误、结果校验和日志中的旧计算及联合文案
2026-09-07 19:18:37 +08:00
k88936 aba688de90 更新并简化 UI 识别与绑定系统提示 2026-09-07 19:18:37 +08:00
k88936 8d312d59fb 更新 UI workflow 阶段决策记录
移除历史阶段描述并记录现行阶段与无迁移约束
2026-09-07 19:18:37 +08:00
k88936 5a41afa406 统一 UI workflow 阶段并拒绝未知值
现行阶段原地使用并移除旧阶段 fallback 与迁移兼容
2026-09-07 19:18:37 +08:00
k88936 443662e98a 更正独立素材上限语义
将128限制恢复为独立素材数量且不计入页面参考图
2026-09-07 19:18:37 +08:00
k88936 d7d86436ba 限制联合图片上下文总量
将页面参考图与独立素材的单次请求总数限制为128张
2026-09-07 19:18:37 +08:00
k88936 91cb7d7f67 记录联合识别重跑与阶段排名语义
说明重复识别会生成新结果并明确 workflow_stage_rank 的后端持久化边界
2026-09-07 19:18:37 +08:00
k88936 8bcdd8c5b0 限制联合请求独立素材总量
将单次识别绑定请求的独立素材数量限制为128个
2026-09-07 19:18:37 +08:00
k88936 3857cde0f0 移除废弃的独立识别路径
让联合识别绑定成为 UI 编辑器唯一实现并清理旧状态投影
2026-09-07 19:18:37 +08:00
k88936 ef95f4f159 清理联合识别后的节点选择
替换生成新树后清除已失效的旧节点选中状态
2026-09-07 19:18:37 +08:00
k88936 1b3c95bc33 避免工作流渲染校验重复克隆状态
直接复用联合结果写入后的 State 完成组件校验
2026-09-07 19:18:37 +08:00
k88936 3b5d7009f8 明确联合结果按图片返回树
让提示与每张输入图一棵树的校验契约一致
2026-09-07 19:18:37 +08:00
k88936 c4eed791a1 消除联合请求初始化崩溃路径
将 provider client 与配置 unwrap 改为显式错误
2026-09-07 19:18:37 +08:00
k88936 ebe1aed349 校验联合结果素材引用
拒绝不存在的图片素材组件引用

拒绝不存在的字体绑定引用
2026-09-07 19:18:37 +08:00
k88936 7ccc0b8def 稳定联合识别图片上下文顺序
页面角色优先作为 ROOT

其余界面图按 ID 排序保持请求可复现
2026-09-07 19:18:37 +08:00
k88936 ab7f6511cd 简化联合工作流动作状态
移除旧识别状态回退与重复分支

统一联合步骤的运行中和完成态显示
2026-09-07 19:18:37 +08:00
k88936 3a752aeb43 归一化UI编辑器完成态入口
将旧 visual-binding 初始步骤归一到联合识别绑定步骤

简化工作流步骤索引计算
2026-09-07 19:18:37 +08:00
k88936 d09de37d51 修正联合概览状态定位
结构问题高亮 layout_status

组件问题高亮 components_status
2026-09-07 19:18:37 +08:00
k88936 68280992d0 统一UI编辑器联合计算入口
工作流导航收敛为参考分析与一键识别绑定两步

联合概览同时展示结构与组件状态
2026-09-07 19:18:37 +08:00
k88936 c4c643dd6c 正式合并UI识别与组件绑定
删除旧 recognition 与 binding 命令及前端 DTO

新增 recognition_and_binding 单次 workflow 与联合概览

移除 debug 超时特判并直达 binding-ready
2026-09-07 19:18:36 +08:00
k88936 b19b120357 重构UI工作流联合识别绑定契约
更新 UI workflow 以 recognition_and_binding 单次计算为正式识别路径

保留 merge 能力但移出联合识别绑定流程

声明联合提示词 placeholder 与 binding-ready 直达阶段
2026-09-07 19:18:36 +08:00
919 changed files with 76555 additions and 152307 deletions
-1
View File
@@ -5,7 +5,6 @@
## 目录约定
- `.codex/skills/` 是项目专属 skill 根目录。每个 skill 以目录中的 `SKILL.md` 为入口,配套的参考资料和脚本放在同一目录下。
- `spec-driven-development` 负责跨模块、公开契约、SpacetimeDB、AGC/Runtime 和复杂 UI 任务的规范先行与逐里程碑验收;具体规则以 `docs/` 下的 SDD 工作流和模板为准。
- `.codex/plugins/` 保存随仓库分发的项目插件资源及其参考资料。当前的 `game-studio` 插件提供浏览器游戏设计、原型、2D/3D 技术栈、素材管线和 playtest 工作流;是否启用遵循当前 Codex 的插件加载机制,不依赖旧工具的环境变量或个人配置脚本。
- `.codex/hooks/``.codex/environments/` 等目录保存项目工具链所需的 hooks 和环境模板;它们不替代项目代码中的运行时配置。
- 长期有效的产品、架构、接口、排障和协作知识统一放在 `docs/``docs/project-memory/`,不复制到本目录。
@@ -32,7 +32,6 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
@@ -52,7 +52,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result.
`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets.
## Common Values
@@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default).
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
@@ -881,14 +881,12 @@ def _self_test() -> None:
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
sliceMode="connected-components",
referenceId="must-not-override-explicit-reference",
iconDescriptions=["不得覆盖显式图标描述"],
)
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
assert calls[0]["body"]["referenceId"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["sliceMode"] == "connected-components"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
print("self-test ok")
@@ -1,43 +0,0 @@
---
name: spec-driven-development
description: 在 Genarrative 中处理跨模块功能、公开 API/DTO、SpacetimeDB schema、AGC/Runtime 或复杂 UI 状态链路时,按主规范、里程碑规范、单里程碑实现计划和验收证据推进;小型局部修复不触发。
license: MIT
metadata:
codex:
tags: [SDD, 规范驱动开发, 主规范, 里程碑, 验收]
---
# SDD 规范驱动开发
本 skill 把 Genarrative 的复杂任务路由到规范先行、单里程碑交付的工作流。权威规则和模板分别见:
- `docs/【协作规范】规范驱动开发工作流-2026-09-12.md`
- `docs/project-memory/shared-memory/【模板】规范驱动开发主规范与里程碑模板-2026-09-12.md`
## 触发条件
使用本 skill 的任务包括:
- 跨前端、`api-server``platform-*``spacetime-client``spacetime-module``module-*` 的功能。
- `/api/external/v1`、共享 DTO、OpenAPI、SpacetimeDB schema、迁移或跨版本重放合同变化。
- AGC / DirectProject、Agent Runtime、工具白名单、审批、持久化恢复或资源工作流变化。
- 复杂 UI 状态链路、入口/页面生命周期变化,或需要多个独立验收面的功能。
文案、单文件局部修复、无行为变化重构和一次性诊断继续使用轻量流程;执行中一旦触及公开行为或跨模块合同,切换到本 skill。
## 执行要求
1. 检查工作树,读取当前专题、源码、测试、契约和历史决策;写清交付结果、验收判据和不做项。
2. 找到或更新唯一主规范。主规范描述行为合同、边界、非目标、兼容/迁移和证据,不绑定实现细节。
3.`docs/project-memory/plans/` 创建一个或多个 `【里程碑】中文标题-YYYY-MM-DD.md`,填写 `Version``Status``Date``Parent Spec`、范围、依赖和验收标准;评审通过前不写业务代码。
4. 每次只选择一个已评审里程碑,创建对应的 `【实施计划】中文标题-YYYY-MM-DD.md`,明确修改边界、顺序、验证命令、风险和回滚点。
5. 发现行为需要变化时,严格按 `主规范 → 尚未实现的里程碑规范 → 当前实现计划 → 代码与测试` 更新。
6. 完成一个里程碑后,提供主规范/里程碑逐条对照、自动化验证、必要运行时 smoke、边界验证和未验证项;未验收不得推进下一个里程碑。
7. 全部验收通过后,将持久结论合并回主规范,删除已完成的临时计划,并检查文档、契约、测试和提交边界一致。
## 约束
- 里程碑和实现计划是开发期协调文件,完成或取消后删除;阶段编号和临时名称不得进入产品代码、用户文档、测试名称或提交标题。
- 活动计划可以提交以便团队同步,但不得把一次性实现步骤写进长期规范。
- 继续遵守现有 API、SpacetimeDB、AGC、编码和中文文档约束;本 skill 不替代专题门禁。
- 文档任务至少运行 `npm run check:doc-index``npm run check:encoding``git diff --check`;代码任务再运行范围匹配的测试、类型检查、schema/OpenAPI 或运行时 smoke。
+5 -10
View File
@@ -1,8 +1,8 @@
# Server-side OpenAI-compatible LLM endpoint base URL.
LLM_BASE_URL="https://api.tiantoken.com/v1"
LLM_BASE_URL="https://api.vectorengine.cn/v1"
# Server-side API key used by the local Vite proxy.
# Recommended: set `LLM_API_KEY` locally, or use `TIANTOKEN_API_KEY`
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
# through the Rust api-server proxy.
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
# but it should not be relied on by browser code.
@@ -122,7 +122,7 @@ WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
# Model name for chat completions.
VITE_LLM_MODEL="gpt-5.4-mini"
GENARRATIVE_LLM_PROVIDER="openai-compatible"
GENARRATIVE_LLM_BASE_URL="https://api.tiantoken.com/v1"
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
GENARRATIVE_LLM_API_KEY=""
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
@@ -130,15 +130,10 @@ GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# Tiantoken LLM and GPT-image-2 / Gemini image generation config.
TIANTOKEN_BASE_URL="https://api.tiantoken.com"
TIANTOKEN_API_KEY=""
TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS="1000000"
# VectorEngine is retained for Suno audio generation only.
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
VECTOR_ENGINE_API_KEY=""
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS="180000"
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
# ElevenLabs editor sound-effect generation is server-side only.
ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
-1
View File
@@ -164,7 +164,6 @@ module.exports = {
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
'target',
'src/main.tsx',
'src/App.tsx',
+54 -336
View File
@@ -27,28 +27,9 @@ 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:
# 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
repository-checks:
name: Repository checks
runs-on: genarrative-ci
steps:
- name: Checkout full history from Gitea
@@ -60,228 +41,76 @@ 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: Prepare AI game creator shell Rust dependencies
- name: Resolve comparison base
shell: bash
run: |
set -euo pipefail
# 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
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
fi
sleep $((attempt * 2))
done
}
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: Run AI game creator shell Rust shard 1/4
run: npm run check:native-shells:agc-rust-shard-1
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
ai-game-creator-shell-rust-shard-2:
name: AI game creator shell Rust shard 2/4
- name: Run repository checks
run: npm run check:repository-ci
frontend-tests:
name: Frontend tests
runs-on: genarrative-ci
steps:
- name: Checkout full history from Gitea
- name: Checkout source from Gitea
env:
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
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: 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: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run AI game creator shell Rust shard 2/4
run: npm run check:native-shells:agc-rust-shard-2
- name: Run frontend and script tests
run: npm run 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: Run BgFilter worker smoke harness tests
run: npm run bgfilter-worker:smoke-test
- 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 health patrol behavior
run: npm run check:production-health-patrol
- 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 release behavior
run: npm run check:production-api-release
- 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
- name: Validate production API deploy behavior
run: npm run check:production-api-deploy
backend-tests:
name: Backend tests
@@ -365,8 +194,6 @@ 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
@@ -388,6 +215,7 @@ 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
@@ -404,118 +232,8 @@ jobs:
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:shells
- name: Run native shell release build smoke
run: npm run check:native-shells:release
run: npm run check:native-shells
- 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
-2
View File
@@ -40,8 +40,6 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
/apps/ai-game-creator-shell/src-tauri/resources/plugins/
/plugins/agc-cocos-editor/native/payload/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
-3
View File
@@ -1,4 +1 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run format:staged
-3
View File
@@ -1,4 +1 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run check:pre-push-master -- "$@"
-1
View File
@@ -16,7 +16,6 @@
3. [`docs/project-memory/README.md`](docs/project-memory/README.md)、[`project-overview.md`](docs/project-memory/shared-memory/project-overview.md)、[`team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md)、[`development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md)。
4. 与任务相关的 [`decision-log.md`](docs/project-memory/shared-memory/decision-log.md)、[`pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md)、[`docs/README.md`](docs/README.md) 和当前专题文档。
- 落地工程修改前,先确认是否已有足够具体的 PRD、技术方案或当前融合文档;文档仍存在编码级歧义时,先补文档再编码。
- 跨模块功能、公开 API/DTO、SpacetimeDB schema、AGC/Runtime 或复杂 UI 状态链路必须按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](docs/【协作规范】规范驱动开发工作流-2026-09-12.md) 先完成主规范、里程碑规范和单里程碑实现计划;评审与验收门禁未通过前不得进入下一里程碑。局部修复、文案和无行为变化重构继续走轻量流程。
- 本仓库的本地 RAG 位于 [`scripts/rag/`](scripts/rag/);RAG 只作为候选上下文,不替代打开源文件核对。默认不安装 RAG 运行时依赖,需要启用时必须先询问用户,并只安装到 gitignored 的 `.rag/runtime/`
## 绝对约束
@@ -38,7 +38,6 @@ vi.mock('../api/adminApiClient', () => ({
interface MockIntersectionObserverController {
enter: (target: Element) => void;
enterAll: (targets: Element[]) => void;
isObserved: (target: Element) => boolean;
}
@@ -107,25 +106,6 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
);
});
},
enterAll(targets) {
act(() => {
for (const target of targets) {
const record = observed.get(target);
if (!record) {
throw new Error('目标缩略图尚未进入 IntersectionObserver');
}
record.callback(
[
{
isIntersecting: true,
target,
} as IntersectionObserverEntry,
],
record.observer,
);
}
});
},
isObserved(target) {
return observed.has(target);
},
@@ -773,10 +753,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
const thumbnails = entries.map((entry) =>
thumbnailElementForLabel(entry.label),
);
for (const thumbnail of thumbnails) {
thumbnails.forEach((thumbnail) => {
expect(observer.isObserved(thumbnail)).toBe(true);
}
observer.enterAll(thumbnails);
observer.enter(thumbnail);
});
await act(async () => {
await Promise.resolve();
});
@@ -796,7 +776,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
await vi.advanceTimersByTimeAsync(200);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
}, 10_000);
});
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
const observer = installIntersectionObserverMock();
-4
View File
@@ -1,4 +0,0 @@
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
# “构建 -> 监听 -> 再构建”的自触发循环。
resources/plugins/
@@ -2,8 +2,6 @@
"schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server",
"llm": {
"customEnabled": false,
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
@@ -18,5 +16,8 @@
"maxRetries": 2,
"retryBackoffMs": 500
},
"agentLlm": {}
"agentLlm": {},
"planning": {
"capabilityEnabled": true
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.47",
"version": "0.1.12",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -19,6 +19,8 @@
"config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
"test:plan": "node scripts/agent-swarm-test-chat.mjs --plan --task \"我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。\"",
"test:plan:manual": "node scripts/agent-swarm-test-chat.mjs --plan",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
@@ -51,13 +53,11 @@
"focus-trap-react": "^12.0.3",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"phaser": "^4.2.1",
"react": "^19.0.0",
"react-arborist": "^3.16.0",
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
"zustand": "^5.0.14"
@@ -36,6 +36,8 @@ export const ungeneratedGameEntryMarker =
'还没有生成游戏。回到聊天输入创意并确认生成后';
export const defaultRealSwarmTestTask =
'制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。';
export const defaultRealSwarmPlanTask =
'我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。';
export const swarmTurnReportPrefix = '[turn.report] ';
export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1';
@@ -138,10 +140,15 @@ export const usage = `用法:
--keep-project 保留自动创建的一次性项目
--no-open 手工模式启动预览但不自动打开浏览器
--task <需求> 通过 manual 入口非交互提交自定义需求
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时
--plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时
--dry-run 只检查目录发现和项目准备,不启动 LLM
-h, --help 显示帮助
`;
环境变量:
AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approverevise/reject 必须
同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见
AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`;
function readOptionValue(args, index, option) {
const value = args[index + 1]?.trim();
@@ -170,6 +177,7 @@ export function parseSwarmTestArguments(args) {
keepProject: false,
openBrowser: true,
task: null,
plan: false,
timeoutMinutes: null,
dryRun: false,
help: false,
@@ -194,6 +202,8 @@ export function parseSwarmTestArguments(args) {
if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符');
options.task = task;
index += 1;
} else if (argument === '--plan') {
options.plan = true;
} else if (argument === '--timeout-minutes') {
if (options.timeoutMinutes !== null) {
throw new Error('--timeout-minutes 只能指定一次');
@@ -212,11 +222,16 @@ export function parseSwarmTestArguments(args) {
}
export function shouldStartPersistentPreview(options) {
return !options.task;
// 立项策划链路只出 GDD,没有可试玩产物,任何模式都不该起预览。
return !options.task && !options.plan;
}
export function resolveSwarmTestTimeoutMs(options) {
const minutes = options.timeoutMinutes ?? (options.task ? 50 : null);
// 立项策划的设计目标是五分钟出方案,给一分钟余量;再久就是卡住了,早失败
// 比让 harness 空等更有用。做游戏那条链路的 50 分钟不变。
const planMinutes = options.plan ? 6 : null;
const minutes =
options.timeoutMinutes ?? planMinutes ?? (options.task ? 50 : null);
return minutes === null ? null : minutes * 60_000;
}
@@ -371,15 +386,7 @@ async function runMissingConfigWizard(setActiveChild, explicitConfigDir) {
}
}
async function secureWindowsPrivateRuntimePath(
targetPath,
options,
secureWindowsPath = null,
) {
if (secureWindowsPath) {
await secureWindowsPath(targetPath, options);
return;
}
async function secureWindowsPrivateRuntimePath(targetPath, options) {
const { secureWindowsGameCreatorPathForCurrentUser } = await import(
'./game-creator-config-wizard.mjs'
);
@@ -391,7 +398,6 @@ async function copyPrivateRuntimeConfigEntry(
runtimeConfigDir,
fileName,
required,
secureWindowsPath = null,
) {
const sourcePath = path.join(sourceConfigDir, fileName);
const sourceMetadata = await lstat(sourcePath).catch((error) => {
@@ -411,11 +417,9 @@ async function copyPrivateRuntimeConfigEntry(
const sourceBytes = await readFile(sourcePath);
const destinationFile = await open(destinationPath, 'wx', 0o600);
try {
await secureWindowsPrivateRuntimePath(
destinationPath,
{ isDirectory: false },
secureWindowsPath,
);
await secureWindowsPrivateRuntimePath(destinationPath, {
isDirectory: false,
});
await destinationFile.writeFile(sourceBytes);
await destinationFile.sync();
} finally {
@@ -448,11 +452,7 @@ async function copyPrivateRuntimeConfigEntry(
return true;
}
export async function prepareSwarmTestRuntimeConfig(
sourceConfigDir,
tempRoot,
{ secureWindowsPath = null } = {},
) {
export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
if (!path.isAbsolute(sourceConfigDir)) {
throw new Error('配置来源目录必须是绝对路径');
}
@@ -472,11 +472,9 @@ export async function prepareSwarmTestRuntimeConfig(
);
try {
if (process.platform === 'win32') {
await secureWindowsPrivateRuntimePath(
runtimeConfigDir,
{ isDirectory: true },
secureWindowsPath,
);
await secureWindowsPrivateRuntimePath(runtimeConfigDir, {
isDirectory: true,
});
} else {
await chmod(runtimeConfigDir, 0o700);
}
@@ -494,14 +492,12 @@ export async function prepareSwarmTestRuntimeConfig(
runtimeConfigDir,
configFileName,
true,
secureWindowsPath,
);
await copyPrivateRuntimeConfigEntry(
canonicalSourceConfigDir,
runtimeConfigDir,
localConfigFileName,
false,
secureWindowsPath,
);
return {
path: await realpath(runtimeConfigDir),
@@ -983,12 +979,25 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
}
// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input
// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settleCLI 也就永远
// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。
// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案,
// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次
// --plan-gdd-status,没有待决定审批时不会有任何写入。
const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u;
export function swarmOutputAwaitsPlanGddApproval(line) {
return planGddApprovalWaitPattern.test(line);
}
async function runTaskCargo(
cliArguments,
task,
setActiveChild,
timeoutMs,
autoPilot = false,
onPlanGddApprovalWait = null,
) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: ['pipe', 'pipe', 'inherit'],
@@ -1000,6 +1009,25 @@ async function runTaskCargo(
let taskSubmitted = false;
let promptsSeen = 0;
let sittingAtPrompt = false;
let planGddApproval = null;
let planGddApprovalError = null;
let planGddApprovalStarted = false;
let planGddApprovalPromise = null;
const startPlanGddApproval = () => {
planGddApprovalStarted = true;
console.log(
`[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`,
);
planGddApprovalPromise = onPlanGddApprovalWait()
.then((value) => {
planGddApproval = value;
})
.catch((error) => {
planGddApprovalError = error;
// 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。
void terminateChildTree(child).catch(() => {});
});
};
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
@@ -1012,6 +1040,13 @@ async function runTaskCargo(
reportLines.push(normalizedLine);
settled = true;
}
if (
onPlanGddApprovalWait &&
!planGddApprovalStarted &&
swarmOutputAwaitsPlanGddApproval(normalizedLine)
) {
startPlanGddApproval();
}
}
if (!autoPilot || child.stdin.writableEnded) return;
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
@@ -1050,9 +1085,12 @@ async function runTaskCargo(
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
reportLines.push(normalizedPendingLine);
}
await planGddApprovalPromise;
if (planGddApprovalError) throw planGddApprovalError;
return {
...result,
turnReportOutput: reportLines.join('\n'),
planGddApproval,
};
} finally {
setActiveChild(null);
@@ -1742,6 +1780,196 @@ export async function validateSwarmProjectArtifacts(projectPath, options) {
return inspection;
}
// 这四条路径的权威定义都在 Rust 侧 `planning_storage.rs``PLAN_SESSION_PATH`、
// `PLAN_GDD_INDEX_PATH`、`PLAN_STORAGE_ROOT`、`PLAN_FAST_GDD_PATH`)。跨语言没有共享
// 常量的通道,改路径时要连同 `GddApprovalCard.tsx` 一起动。
export const planningOutputPaths = [
'.agent/planning/session.json',
'.agent/planning/index.json',
'.agent/planning/pending.json',
'game/fast_gdd.md',
];
export async function inspectPlanningOutputs(projectPath) {
const outputs = [];
for (const relativePath of planningOutputPaths) {
const absolutePath = path.join(projectPath, ...relativePath.split('/'));
const metadata = await lstat(absolutePath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
outputs.push({
path: relativePath,
exists: Boolean(metadata?.isFile()),
bytes: metadata?.isFile() ? metadata.size : 0,
});
}
return outputs;
}
async function reportPlanningOutputs(projectPath) {
const outputs = await inspectPlanningOutputs(projectPath);
console.log('\n立项策划产物:');
for (const output of outputs) {
console.log(
output.exists
? ` [有] ${output.path}${output.bytes} 字节)`
: ` [无] ${output.path}`,
);
}
}
const planGddApprovalTimeoutMs = 60_000;
export const planGddStatusOutputPrefix = 'planGddStateJson=';
export const planGddDecisionOutputPrefix = 'planGddDecisionJson=';
function parsePrefixedJsonLine(output, prefix, label) {
const line = output
.split('\n')
.map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value))
.find((value) => value.startsWith(prefix));
if (!line) throw new Error(`${label}缺少 ${prefix} 输出`);
try {
return JSON.parse(line.slice(prefix.length));
} catch (error) {
throw new Error(`解析${label}失败:${error.message}`);
}
}
export function parsePlanGddStatusOutput(output) {
return parsePrefixedJsonLine(
output,
planGddStatusOutputPrefix,
'Fast GDD 审批状态',
);
}
export function parsePlanGddDecisionOutput(output) {
return parsePrefixedJsonLine(
output,
planGddDecisionOutputPrefix,
'Fast GDD 审批回执',
);
}
// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 approve,且只在投影确实有
// 一张待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把
// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时
// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着,
// 而它恰好是本进程持有的 CLI 子进程。
export function planGddAutoApprovalIsPending(state) {
return Boolean(state?.pendingApproval);
}
export function resolvePlanGddAutoDecision(env = process.env) {
const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim();
if (!['approve', 'revise', 'reject'].includes(action)) {
throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject');
}
const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim();
if (action === 'approve') return { action, comment: null };
if (!comment) {
throw new Error(
`${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`,
);
}
return { action, comment };
}
async function settlePlanGddApproval(
projectPath,
runtimeConfigPath,
setActiveChild,
) {
const readStatus = async () => {
const result = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批状态查询',
},
);
if (result.code !== 0 || result.signal) {
throw new Error(
`读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`,
);
}
return parsePlanGddStatusOutput(result.stdout);
};
const before = await readStatus();
if (!planGddAutoApprovalIsPending(before)) {
return { decided: false, state: before };
}
const { action, comment } = resolvePlanGddAutoDecision();
const decision = await runCapturedCargo(
[
'--config-dir',
runtimeConfigPath,
'--plan-gdd-decide',
projectPath,
action,
...(comment === null ? [] : ['--stdin']),
],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批决定',
stdin: comment,
},
);
if (decision.code !== 0 || decision.signal) {
throw new Error(
`提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`,
);
}
const receipt = parsePlanGddDecisionOutput(decision.stdout);
// 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending
// 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有
// 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。
let recovered = false;
if (receipt.recoveryPending) {
const resume = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--agent-resume', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批后恢复后台任务',
},
);
if (resume.code !== 0 || resume.signal) {
throw new Error(
`审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`,
);
}
recovered = true;
}
return { decided: true, receipt, recovered, state: await readStatus() };
}
async function reportPlanGddApproval(approval) {
const { state } = approval;
console.log('\nFast GDD 审批:');
if (!approval.decided) {
console.log(` [无待决定审批] 当前投影状态=${state.state}`);
return;
}
console.log(
` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
);
if (approval.recovered) {
console.log(
' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口',
);
}
if (state.session) {
console.log(
` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`,
);
}
}
export async function hasConfiguredEditorApiKey(configDir) {
let configured = false;
for (const fileName of [configFileName, localConfigFileName]) {
@@ -1894,7 +2122,13 @@ export async function runSwarmTestChat(options) {
const setActiveChild = (child) => {
activeChild = child;
};
// GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程
// 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。
const concurrentChildren = new Set();
const setConcurrentChild = (child) => {
if (child) concurrentChildren.add(child);
else concurrentChildren.clear();
};
const stopRequested = () => receivedSignal !== null;
const handleSignal = (signal) => {
const repeatedSignal = receivedSignal !== null;
@@ -1972,10 +2206,11 @@ export async function runSwarmTestChat(options) {
);
}
console.log('LLM 配置已就绪。');
const requirementNoun = options.plan ? '立项策划需求' : '游戏需求';
console.log(
options.task
? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n'
: '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n',
? `已提交一条非交互${requirementNoun},正在等待 Swarm 自主完成。\n`
: `输入一条${requirementNoun}并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n`,
);
phase = 'chat';
@@ -1985,7 +2220,8 @@ export async function runSwarmTestChat(options) {
runtimeConfig.path,
'--swarm-chat',
'--init',
'--autonomous-game-build',
// 做方案链路只能跑 standard 档,后端对 plan + autonomous 是硬否决。
options.plan ? '--plan' : '--autonomous-game-build',
project.path,
];
let chat;
@@ -1998,6 +2234,15 @@ export async function runSwarmTestChat(options) {
timeoutDeadline === null
? null
: Math.max(1, timeoutDeadline - Date.now()),
options.plan,
options.plan
? () =>
settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
)
: null,
)
: await runInteractiveCargo(chatArguments, setActiveChild);
} catch (error) {
@@ -2013,6 +2258,32 @@ export async function runSwarmTestChat(options) {
if (options.task) {
turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput);
}
if (options.plan) {
// 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况,
// 是否收束已经由 CLI 的退出码判过了。
// 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D
// 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。
phase = 'plan-approval';
const approval =
chat.planGddApproval ??
(await settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
));
if (receivedSignal) break session;
phase = 'plan-report';
await reportPlanGddApproval(approval);
await reportPlanningOutputs(project.path);
phase = 'complete';
console.log(
approval.decided
? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。'
: '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。',
);
break session;
}
phase = 'artifact-validation';
const requireEditorImages = await hasConfiguredEditorApiKey(
runtimeConfig.path,
);
@@ -4,11 +4,6 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
const releaseTarget =
@@ -164,30 +159,10 @@ export async function prepareReleaseVersion() {
return nextVersion;
}
export function buildTauriBuildArguments(
args = [],
target = releaseTarget,
platform = process.platform,
) {
const noBundle = args.includes('--no-bundle');
const targetIndex = args.indexOf('--target');
const explicitTarget =
targetIndex >= 0
? args[targetIndex + 1]
: args
.find((value) => value.startsWith('--target='))
?.slice('--target='.length);
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
const features = defaultEditorFeatures(
explicitTarget || (noBundle ? platform : target),
);
return [
'build',
...withDefaultCargoFeatures([...targetArgs, ...args], features),
];
}
export function runTauriBuild(args = []) {
const noBundle = args.includes('--no-bundle');
const hasTarget = args.includes('--target');
const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget];
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(
npmCommand,
@@ -197,7 +172,9 @@ export function runTauriBuild(args = []) {
'exec',
'tauri',
'--',
...buildTauriBuildArguments(args),
'build',
...targetArgs,
...args,
],
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
);
@@ -1,24 +0,0 @@
/** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */
export function withDefaultCargoFeatures(argv, features) {
const separator = argv.indexOf('--');
const cargoArgs = separator < 0 ? argv : argv.slice(0, separator);
if (
!features.length ||
cargoArgs.some(
(value) =>
value === '--features' ||
value === '-f' ||
value.startsWith('--features=') ||
/^-f.+/u.test(value),
)
) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute']
: [];
}
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildTauriBuildArguments } from './build-release.mjs';
import { withDefaultCargoFeatures } from './cargo-features.mjs';
test('Windows release includes the same editor feature as development', () => {
assert.deepEqual(
buildTauriBuildArguments([], 'x86_64-pc-windows-msvc', 'win32'),
[
'build',
'--features=cocos-editor-execute',
'--target',
'x86_64-pc-windows-msvc',
],
);
assert.deepEqual(
buildTauriBuildArguments(
['--no-bundle'],
'x86_64-pc-windows-msvc',
'linux',
),
['build', '--no-bundle'],
);
assert.deepEqual(
buildTauriBuildArguments(['--target=aarch64-apple-darwin']),
['build', '--target=aarch64-apple-darwin'],
);
});
test('explicit Cargo features override defaults in every supported spelling', () => {
for (const args of [
['--features', 'custom'],
['--features=custom'],
['-f', 'custom'],
['-fcustom'],
]) {
assert.deepEqual(
withDefaultCargoFeatures(args, ['cocos-editor-execute']),
args,
);
}
assert.deepEqual(
withDefaultCargoFeatures(
['--', '--features=app'],
['cocos-editor-execute'],
),
['--features=cocos-editor-execute', '--', '--features=app'],
);
});
@@ -107,34 +107,12 @@ const rustSharedContractSource = fs.readFileSync(
'utf8',
);
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',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
// `start_local_project_asset_generation`。
'generate_local_project_asset',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
'reset_design_agent_session',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
'list_agc_plugins',
'list_agc_extensions',
'refresh_agc_plugins',
'start_agc_plugin',
'stop_agc_plugin',
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled',
];
const sourceExtensions = new Set([
'.json',
@@ -1295,7 +1273,7 @@ for (const requiredSource of [
}
}
if (tauriConfig.productName !== '陶泥儿') {
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
throw new Error('AI game creator shell productName drifted');
}
@@ -1303,52 +1281,31 @@ if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
throw new Error('AI game creator shell identifier drifted');
}
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
};
const expectedBundledWindowsResources = {
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
const expectedBundledCodexResources = {
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'coding-agent/win-x64/bin/codex-code-mode-host.exe',
'codex/win-x64/bin/codex-code-mode-host.exe',
'resources/codex/win-x64/codex-path/rg.exe':
'coding-agent/win-x64/codex-path/rg.exe',
'codex/win-x64/codex-path/rg.exe',
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
'coding-agent/win-x64/codex-resources/codex-command-runner.exe',
'codex/win-x64/codex-resources/codex-command-runner.exe',
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
'codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
'resources/codex/win-x64/codex-package.json':
'coding-agent/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
'resources/plugins': 'plugins',
'codex/win-x64/codex-package.json',
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
};
assert.deepEqual(
tauriConfig.bundle?.resources,
expectedBundledDesignAgentResources,
'AI game creator shell base Tauri config must bundle the design-agent resource pack',
);
for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
if (String(key).includes('codex')) {
throw new Error(
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
);
}
if (tauriConfig.bundle?.resources !== undefined) {
throw new Error(
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
);
}
assert.deepEqual(
windowsTauriConfig.bundle?.resources,
expectedBundledWindowsResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
expectedBundledCodexResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
);
if (
Object.prototype.hasOwnProperty.call(
windowsTauriConfig.bundle?.resources ?? {},
'design-agent',
)
) {
throw new Error(
'design-agent resource pack must not be mixed into the Windows Codex sidecar bundle',
);
}
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error(
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
@@ -1744,16 +1701,10 @@ if (
runtimeConfigSetupStart === -1 ||
runtimeConfigSetupEnd === -1 ||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
!runtimeConfigSetupSource.includes('setup_log.fail(') ||
!runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') ||
!runtimeConfigSetupSource.includes(
'startup.appdata.configure.failed details={details}',
) ||
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
!tauriHandlerSource.includes(
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
) ||
!tauriHandlerSource.includes('early_startup_log_path(')
)
) {
throw new Error(
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
@@ -9,10 +9,6 @@ import {
const agcDevHost = '127.0.0.1';
const legacyAgcDevPort = 3080;
const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT';
const agcAdminWebHost = '127.0.0.1';
const legacyAgcAdminWebPort = 3102;
// 与 scripts/dev.mjs 的后台 Web 端口配置保持同一环境变量名。
const agcAdminWebPortEnvKey = 'ADMIN_WEB_PORT';
function readConfiguredAgcDevPort(env = process.env) {
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
@@ -103,100 +99,13 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) {
};
}
function readConfiguredAgcAdminWebPort(env = process.env) {
const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim();
if (!rawPort) {
return null;
}
const port = normalizePort(rawPort, -1);
if (port < 1024) {
throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`);
}
return port;
}
function createAgcAdminWebEndpoint(port, portRange = null) {
const origin = `http://${agcAdminWebHost}:${port}`;
return {
host: agcAdminWebHost,
port,
origin,
basePath: '/admin/',
url: `${origin}/admin/`,
portRange,
};
}
// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定:
// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。
async function resolveAgcAdminWebEndpoint({
env = process.env,
platform = process.platform,
strictConfigured = false,
reservedPorts = [],
reservePortRange = reserveLinuxDevPortRange,
findPort = findAvailablePort,
} = {}) {
const configuredPort = readConfiguredAgcAdminWebPort(env);
let portRange = null;
let preferredPort = configuredPort ?? legacyAgcAdminWebPort;
if (platform === 'linux') {
const allocation = await reservePortRange({ env });
if (!allocation?.range) {
throw new Error('无法取得当前 Linux 用户的 dev 端口段');
}
portRange = allocation.range;
const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort;
if (!Number.isInteger(mappedAdminWebPort)) {
throw new Error(
`当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`,
);
}
preferredPort = configuredPort ?? mappedAdminWebPort;
}
const reservedPortSet = new Set(
reservedPorts.filter((value) => Number.isInteger(value) && value > 0),
);
const port = await findPort({
host: agcAdminWebHost,
preferredPort,
portRange,
reservedPorts: reservedPortSet,
strict: strictConfigured && configuredPort != null,
});
console.log(
formatPortDecision({
name: 'ai-game-creator-shell-admin-web',
host: agcAdminWebHost,
preferredPort,
resolvedPort: port,
}),
);
if (portRange) {
console.log(
`[ai-game-creator-shell] admin-web port-range: ${portRange.label}`,
);
}
return createAgcAdminWebEndpoint(port, portRange);
}
export {
agcAdminWebHost,
agcAdminWebPortEnvKey,
agcDevHost,
agcVitePortEnvKey,
createAgcAdminWebEndpoint,
createAgcDevEndpoint,
legacyAgcAdminWebPort,
legacyAgcDevPort,
readAgcDevEndpoint,
readConfiguredAgcAdminWebPort,
readConfiguredAgcDevPort,
resolveAgcAdminWebEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
};
@@ -1,461 +0,0 @@
#!/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,7 +8,6 @@ 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',

Some files were not shown because too many files have changed in this diff Show More