From c6381f68e1f31a96fa684f0223cfbafd9f4f4e67 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 30 Sep 2026 18:11:17 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=A3=B3CI=E7=BC=BA?= =?UTF-8?q?=E5=B0=91Claude=20SDK=E4=BE=9D=E8=B5=96=E5=AE=89=E8=A3=85=20(#5?= =?UTF-8?q?58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为两条Rust测试lane及smoke任务补齐编译前npm安装 为Rust缓存预热补齐Claude SDK依赖安装 更新CI安装顺序验证及开发运维文档和共享记忆 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/558 Co-authored-by: Linghong Co-committed-by: Linghong --- .gitea/workflows/project-ci.yml | 15 ++++++++--- .../tests/prompt_source_boundaries.rs | 2 +- .../shared-memory/development-workflow.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 ++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +++--- scripts/build-gitea-rust-cache.sh | 2 ++ scripts/project-ci-workflow.test.ts | 25 +++++++++++++------ 7 files changed, 40 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 8338196cf..a5694037f 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -47,8 +47,8 @@ env: # 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢 # 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。 jobs: - # AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm - # 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。 + # AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片; + # build.rs 会准备 Claude Agent SDK sidecar,因此编译前必须安装根 workspace npm 依赖。 ai-game-creator-shell-rust-lane-1: name: AI game creator shell Rust lane 1/2 runs-on: genarrative-ci @@ -62,6 +62,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | @@ -118,6 +121,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | @@ -154,7 +160,7 @@ jobs: GENARRATIVE_GITEA_TOKEN: ${{ github.token }} run: python3 scripts/export-gitea-rust-cache.py - # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样需要 npm 随包依赖, # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 ai-game-creator-shell-rust-smoke: name: AI game creator shell Rust smoke @@ -169,6 +175,9 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | diff --git a/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs b/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs index 453b77e00..5ed98b980 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/prompt_source_boundaries.rs @@ -258,7 +258,7 @@ fn prompt_entry_points_load_their_prose_from_external_files() { ), ( "src/agent/direct_codex_user_item/wire.rs", - &["render_ui_design_code_context"], + &["ui_design_code_context_for"], ), ( "src/commands.rs", diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 6b9ba3efb..29b6c172d 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -114,4 +114,4 @@ Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-ga AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。 -`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 +`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane 与 smoke job 必须在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`:AGC 壳的 `build.rs` 会从 `node_modules` 准备 Claude Agent SDK 与目标平台原生运行时,镜像中的 npm 下载缓存不能替代安装。人工 `scripts/build-gitea-rust-cache.sh` bootstrap 同样在首次编译 AGC 壳前安装 npm 依赖;只有不构建壳的 crates job 继续省略 npm 安装。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5315d44f8..4f86ce479 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -740,6 +740,8 @@ Prompt 静态门禁必须断言上述 Bundle section 当前定义的权威语义 Agent 可见的系统指令、工具与参数说明、恢复指引和上下文模板统一由外置提示词文件维护。AGC 沿用 `prompts/runtime/manifest.json`:已有 composition/section 保持原有组合关系,独立调用的文本按职责登记在 `textCatalogs`,目录为 `prompts/runtime/texts/`,每份 JSON 是稳定文本键到正文的映射。构建期校验目录、文件、重复键和空正文,并生成可供 `format!` 使用的编译期文本宏;变量填充沿用 Rust 格式语法。Runtime 状态、用户内容、schema 类型与枚举、权限和校验继续由代码生成。服务端 Agent 的独立 crate 使用各自 `prompts/` 中的编译期文本文件。迁移以当前组装结果和工具 schema 等价为验收依据,源码门禁检查各提示词入口的内联正文与外置引用。 +`tests/prompt_source_boundaries.rs` 的入口清单随生产函数迁移同步更新。UI 设计文档代码上下文在入队冻结时由 `src/agent/direct_codex_user_item/wire.rs::ui_design_code_context_for` 生成,门禁检查该函数继续使用外置的 `projectContext.uiDesign.codeContext` 与 `projectContext.uiDesign.generationErrorContext`;旧 `render_ui_design_code_context` 已随冻结与纯投影拆分删除,不保留兼容入口。 + 2026-07-12 起,通用开发能力的 Runtime V1.1 增量以 [`【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`](<./【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md>) 为编码级事实源。它补充仓库启动上下文、同一发布二进制独立 Runner、受限本地预览浏览器验证、动态隔离子 Agent 和真实 Provider 全链路验收;本文件中“进程内 tokio task”“首轮不预加载项目内容”和“不创建动态执行实例”的旧口径由 V1.1 明确替代,未涉及能力继续沿用本文件。 同一文档的“V1.2 对标 Codex CLI 增量”继续作为受控命令与推理档位的事实源。对一次性 `command.exec` 而言,只接受 Runtime 白名单内的固定 `program` 和逐项 `args` argv,默认 `confirm`,可执行文件解析为项目外绝对路径且子进程只使用安全 PATH;不解析 shell 字符串,不提供管道、重定向、PTY 或后台进程。这里对 PTY 和后台进程的排除仅适用于 `command.exec`,不能用来否定 V1.10 的独立持久进程工具,也不能把 `command.exec` 自身改成长驻入口。`command.exec` 的 action、stdout / stderr、退出码、超时与源码指纹结果统一进入现有 `action / observation`、project revision、verification gate 和 `needs-reconciliation` 链路;只有明确验证型命令且退出码、源码指纹、命令日志、manifest 与 Agent DB 审计全通过才签发 passed gate,Git / rg / cargo metadata / 普通 npm run 只作诊断。首版只请求终止受控进程组,安全等级与 `project.verify` 相同,不宣称已具备完整 OS sandbox 或 detached-process 隔离。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 50ceb846d..7946645c1 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -329,8 +329,8 @@ Linux process-session 的 owner SIGKILL 用例必须在启动 owner 后立即建 - `Backend tests`:先对 `server-rs/Cargo.lock` 执行带 5 次整命令级有界重试的 `cargo fetch --locked`,再执行 `npm run check:server-rs-ddd`、`cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`、`cargo test --locked -p spacetime-module --no-fail-fast`、`api-server --all-targets` 编译和 `cargo check --locked -p spacetime-module`;普通 workspace host 测试排除 `spacetime-module` 以避免其 `spacetime-types` feature 统一污染领域 crate,模块自身的纯单元测试通过独立 package test 纳入门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,不能把 host 链接支持当作运行时替身。依赖准备必须位于会触发 Cargo build 的 DDD / 产物边界门禁之前,避免锁新增依赖未命中镜像缓存时绕过既有下载重试。runner 安装 `ffmpeg`,避免视频抽帧测试因工具缺失提前返回。依赖真实服务或密钥的测试必须显式 `ignored`,不能让普通 PR job访问现场环境。 - `Native shell tests`:按唯一根 workspace lockfile 安装全部 App 依赖后,用 `npm run check:native-shells:contract`、`npm run check:native-shells:shells` 和 `npm run check:native-shells:release` 分别执行静态契约、H5 / 微信 / Expo / Tauri 桌面壳运行时门禁,以及依赖发布产物的构建 smoke,最后确认桌面壳与 AI 游戏创作壳的 `Cargo.lock` 都没有被构建过程改写。 - `AI game creator shell web tests`:执行 `npm run check:native-shells:agc-web`(即 `npm run ai-game-creator-shell:check:web`:AGC 壳 typecheck 与壳内测试)。该分组不触碰 Cargo,因此不预热 Rust 依赖。 -- `AI game creator shell Rust lane 1/2`、`lane 2/2`:两条 lane 各自只预热一次 AGC 壳自己的锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),然后顺序执行两次 `npm run check:native-shells:agc-rust-shard-`(每次分片运行器使用对应的 `--shard-index=`):AGC 壳 bin target 的 2466 条 Rust 单测按 `--list` 名单排序后切 4 片,片内保持 `--test-threads=1`、各片独立 `TMPDIR`,两条 lane 之间靠 job 级并发摊开;每次分片调用都会自校验「片并集等于全集且互斥」。**不要**改回「一个 job 里多进程并行这几片」:同一容器内它们共享 `HOME`、target 与固定临时路径,实测(run 2102)比整套串行还慢。这些 lane 只用 cargo 与 node 内建模块,因此不装 npm 依赖。 -- `AI game creator shell Rust smoke`:同样只预热 AGC 壳那份锁定依赖,执行 `npm run check:native-shells:agc-rust-smoke`(即 `npm run ai-game-creator-shell:agent-run:smoke`)。smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,单独一个 job 以免把已经压到分钟级的片 job 拖长;只用 cargo 与 node 内建模块(脚本只 import `node:*`),因此不装 npm 依赖。 +- `AI game creator shell Rust lane 1/2`、`lane 2/2`:两条 lane 各自只预热一次 AGC 壳自己的锁定依赖(`apps/ai-game-creator-shell/src-tauri/Cargo.lock` 的 path 依赖已含 `platform-llm`、`platform-agent`、`agent-runtime-core` 与 `shared-contracts`),然后顺序执行两次 `npm run check:native-shells:agc-rust-shard-`(每次分片运行器使用对应的 `--shard-index=`):AGC 壳 bin target 的 2466 条 Rust 单测按 `--list` 名单排序后切 4 片,片内保持 `--test-threads=1`、各片独立 `TMPDIR`,两条 lane 之间靠 job 级并发摊开;每次分片调用都会自校验「片并集等于全集且互斥」。**不要**改回「一个 job 里多进程并行这几片」:同一容器内它们共享 `HOME`、target 与固定临时路径,实测(run 2102)比整套串行还慢。这些 lane 在编译前通过 `scripts/ci-npm-ci-with-retry.sh` 执行根 `npm ci`,为 `build.rs` 准备 Claude Agent SDK 与 Linux 原生运行时。 +- `AI game creator shell Rust smoke`:同样只预热 AGC 壳那份锁定依赖,执行 `npm run check:native-shells:agc-rust-smoke`(即 `npm run ai-game-creator-shell:agent-run:smoke`)。smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,单独一个 job 以免把已经压到分钟级的片 job 拖长;smoke 脚本自身只 import `node:*`,但壳的 `build.rs` 需要 Claude Agent SDK,因此同样在编译前执行根 `npm ci`。 - `AI game creator shell Rust crates`:预热 `server-rs/Cargo.toml` 与两个无锁独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)后执行 `npm run check:native-shells:agc-rust-crates`(即 `npm run ai-game-creator-shell:check:rust:crates`),覆盖 `agent-runtime-core`、`agent-runtime-orchestration`、`platform-llm` 与 `shared-contracts`。这四条命令用的是 server-rs workspace 与独立 crate 的 manifest,属另一套依赖图,因此单独一个 job,也只跑 cargo、不装 npm 依赖。 九个 job 合起来覆盖根 `npm run check`,并补齐根检查没有包含的 BgFilter worker smoke harness、无密钥生产巡检 / 发布 / 部署行为 fixture、server-rs DDD、正式 workspace Rust 测试与现役后端编译门禁。客户端门禁的拆分口径是 `scripts/check-native-shells.mjs` 的 `--groups=`:十个分组(`contract`、`shells`、`agc-web`、`agc-rust-crates`、`agc-rust-shard-1` ~ `agc-rust-shard-4`、`agc-rust-smoke`、`release`)各自对应一个 `check:native-shells:` 根脚本,并在 workflow 的某个 lane/job 里被恰好调用一次;每个 Rust lane 顺序调用两组,不带 `--groups=` 时脚本仍然串行跑全部分组,本地语义不变。`scripts/project-ci-workflow.test.ts` 会同时校验分组清单、根脚本内容、lane/job 覆盖与分片运行器,新增分组必须三处同步。普通 PR CI 不注入业务密钥,不启动真实 API、SpacetimeDB、OSS、支付、图片生成或生产 live smoke;需要现场环境、可变外部状态、Docker 编排或发布凭据的 `check:*` 继续按对应专题和 Jenkins 发布流程执行,不能遍历所有同名前缀脚本冒充 PR 门禁。 @@ -354,7 +354,7 @@ bash scripts/gitea-ci-job-image.sh load-runner 执行账号只要有权访问宿主 Docker API 并管理 runner 容器即可,不强制使用 root;无该权限时由 runner 运维人员执行。更新顺序必须是 `build/verify -> export 仓库外镜像归档与 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份当前 config -> 增加或替换 label -> docker restart --timeout 660 gitea-runner`。`--timeout 660` 只是停止宽限,不是 drain API;rootless DinD supervisor 可能同时停止内层 dockerd,因此重启前必须确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。config 和镜像归档只保存到仓库外受控位置,不在文档、仓库或日志中记录注册信息。重启后先重跑真实 PR 的九个 job,复核隔离边界并确认全部通过,再清理旧镜像。回滚时先把 workflow 的 `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。 -九个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。需要 `node_modules` 的 job 仍各自独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;两条 AGC 壳 Rust lane、`AI game creator shell Rust smoke` 与 `AI game creator shell Rust crates` 是纯 cargo 门禁(只用 cargo 与 node 内建模块),显式不装 npm 依赖,这也由 `scripts/project-ci-workflow.test.ts` 钉住。`npm ci` 统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。 +九个 job 先运行镜像内 `genarrative-gitea-checkout`,再以 `GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1` 执行 `scripts/check-gitea-ci-job-image.sh`,校验 Node 与 npm 固定版本、仓库 Rust toolchain、受信任 PATH、四份缓存锁命中状态、原生命令、pkg-config 依赖、完整 bwrap sandbox 和 Chrome headless。运行时发现锁不匹配时必须输出对应 `*_cache_lock=partial` 和 Actions warning,提示可信分支落地后刷新镜像,不能把陈旧缓存误报为闭合。`RUSTUP_AUTO_INSTALL=0`,因此仓库 `rust-toolchain.toml` 变更必须先更新镜像,不能让 job 现场下载。需要 `node_modules` 的 job 仍各自独立运行一次根 `npm ci`,以唯一 workspace lock 验证 PR 的全部 App 依赖;两条 AGC 壳 Rust lane 与 `AI game creator shell Rust smoke` 也必须在编译前安装 npm 依赖,因为 `build.rs` 会准备随包 Claude Agent SDK 与目标平台原生运行时;只有不构建壳的 `AI game creator shell Rust crates` 省略 npm 安装。安装覆盖与编译前顺序由 `scripts/project-ci-workflow.test.ts` 验证。`npm ci` 统一通过 `scripts/ci-npm-ci-with-retry.sh` 做最多 3 次整命令级有界重试,同时保留 `NPM_CONFIG_PREFER_OFFLINE=true` 和 npm 自身 10 次 fetch retry。命中镜像 cache 时只做干净解包,lock 变化时允许补齐差量。不在镜像内烘入 `node_modules`,也不挂载跨 PR 可写缓存。任何 job 的 sandbox canary 失败都必须停止,不允许跳过。Cargo 通过受控 proxy 下载 lock 差量时继续关闭 HTTP multiplexing,并设置 `CARGO_NET_RETRY=10`。 站点 stack 仍由宿主受控目录管理,`.env`、runner 注册文件和数据库凭据不进入仓库。Compose 必须在 helper/container 内把该目录挂到与宿主相同的绝对路径再执行;挂载到不同路径会让相对 bind source 被 Docker daemon 解析到错误的宿主目录并启动空数据。升级或 runner 迁移前先停止 Gitea 写入,并把 Gitea 冷快照、数据库导出、compose/env 与 runner config/.runner 保存到仓库外受控备份位置。备份文件、绝对宿主配置和注册 token 不得提交 Git,也不在共享文档中记录具体路径或注册内容。 @@ -368,7 +368,7 @@ master 日常交付必须禁止直接 push,只允许经 PR 在最近一次 Pro AGC Rust 两条 lane、crates、agent-run smoke、Backend 和 Native shell 的桌面壳测试均启用 sccache。Native shell 的 release build smoke 显式清空两个 wrapper,保持发布构建原有 profile/features 与资源 staging;前端和 repository checks 不启用对象缓存。继续设置 `CARGO_INCREMENTAL=0`,不共享 target、不恢复 Actions 可写缓存。可信快照通过已有固定 Image ID 分发:镜像只增加固定版本的 sccache、编译对象和来源元数据,不包含源码、target 或凭据;容器写时复制层承接本 job 的新增对象,job 删除后丢弃,PR 没有 Docker API 或快照发布权限。该权限边界由 runner 基础设施保证,不能仅用 workflow 的分支条件替代。 -人工 bootstrap 在没有可消费快照时使用 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`;自动维护不调用它。bootstrap 固定 master 归档,在无宿主挂载、无凭据、有资源限额的容器中只编译预热,不执行测试/应用,Cargo features 和工作目录保持实际 CI 口径。日常更新由 master CI 导出新 key,命中继承对象只传使用时间;PR 不导出、不扫描。宿主验证来源、大小和哈希,不从上传产物执行程序,只从可信来源镜像复制固定 sccache。工具链或基础镜像输入变化时重建无对象缓存基础镜像,缓存工具链必须匹配。公共快照不接受 PR,不复用 Jenkins 发布缓存。 +人工 bootstrap 在没有可消费快照时使用 `bash scripts/build-gitea-rust-cache.sh <已验证基础镜像> <候选镜像tag> [完整master-SHA]`;自动维护不调用它。bootstrap 固定 master 归档,在无宿主挂载、无凭据、有资源限额的容器中先通过 `scripts/ci-npm-ci-with-retry.sh` 安装根 npm 依赖,再编译预热 AGC 壳,以满足 Claude Agent SDK 随包资源的构建要求;不执行测试/应用,Cargo features 和工作目录保持实际 CI 口径。日常更新由 master CI 导出新 key,命中继承对象只传使用时间;PR 不导出、不扫描。宿主验证来源、大小和哈希,不从上传产物执行程序,只从可信来源镜像复制固定 sccache。工具链或基础镜像输入变化时重建无对象缓存基础镜像,缓存工具链必须匹配。公共快照不接受 PR,不复用 Jenkins 发布缓存。 各 Rust job 在编译前执行 `scripts/ci-rust-cache.sh prepare`:检查快照与 rustc 身份,隔离 sccache 配置和 daemon,限时探测真实 wrapper。旧镜像没有快照或探测失败时保留空 wrapper,输出 fallback 原因;缓存故障不得把真实编译/测试失败改成成功,也不允许重跑整个测试组掩盖失败。结束时 `report` 输出命中统计;分片日志单独记录 Cargo 编译耗时。对象缓存上限为 4 GiB,快照构建完成后输出实际体积;最终测试 bin 的链接仍须执行。全组开启时必须生成覆盖全部目标的新快照,不能把旧 AGC 单目标快照当作后端/桌面壳的预热验收。 diff --git a/scripts/build-gitea-rust-cache.sh b/scripts/build-gitea-rust-cache.sh index 1bd6a22aa..ea96b2bcb 100644 --- a/scripts/build-gitea-rust-cache.sh +++ b/scripts/build-gitea-rust-cache.sh @@ -107,6 +107,8 @@ done echo '[rust-cache] warming desktop tests and AGC prompt contracts' cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run +# AGC build.rs 从 node_modules 准备 Claude Agent SDK 与目标平台原生运行时。 +bash scripts/ci-npm-ci-with-retry.sh cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \ --test runtime_prompt_bundle_build --test prompt_source_boundaries --no-run diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts index 6f793634a..283f6a53a 100644 --- a/scripts/project-ci-workflow.test.ts +++ b/scripts/project-ci-workflow.test.ts @@ -67,14 +67,9 @@ const jobNames = [ 'ai-game-creator-shell-rust-crates', ] as const; -// 这几个 job 是纯 cargo 门禁:AGC 壳的 Rust 分片与 agent-run smoke 只用 cargo 与 -// node 内建模块(smoke 脚本只 import `node:*`),crate 级测试只跑 cargo 命令, -// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径 -// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。 +// crate 级测试只跑共享 / 平台 / 编辑器插件的 Cargo manifest,不构建 AGC 壳。 +// 壳的 Rust 分片与 agent-run smoke 会执行 build.rs,需要 npm 安装的 Claude Agent SDK。 const jobsWithoutNpmInstall: readonly string[] = [ - 'ai-game-creator-shell-rust-lane-1', - 'ai-game-creator-shell-rust-lane-2', - 'ai-game-creator-shell-rust-smoke', 'ai-game-creator-shell-rust-crates', ]; @@ -199,6 +194,13 @@ describe('project CI workflow', () => { section.indexOf('Checkout full history from Gitea'), ); expect(prepare).toBeLessThan(section.indexOf(`- name: ${firstRustStep}`)); + if (!jobsWithoutNpmInstall.includes(job)) { + const npmInstall = section.indexOf( + 'bash scripts/ci-npm-ci-with-retry.sh', + ); + expect(npmInstall).toBeGreaterThanOrEqual(0); + expect(npmInstall).toBeLessThan(prepare); + } expect( stepSection(job, 'Report isolated Rust compilation cache'), ).toContain('if: always()'); @@ -246,6 +248,15 @@ describe('project CI workflow', () => { expect(warmCommands).toContain( 'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml --no-run', ); + const npmInstall = warmCommands.indexOf( + 'bash scripts/ci-npm-ci-with-retry.sh', + ); + expect(npmInstall).toBeGreaterThanOrEqual(0); + expect(npmInstall).toBeLessThan( + warmCommands.indexOf( + 'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml', + ), + ); }); it('runs for master pushes, pull requests, and manual dispatch only', () => { From 66ff5bf4b702f19d6f2b203960b728c52905de31 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:59:26 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E9=9A=90=E8=97=8F=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=AD=E7=9A=84=E6=8F=92=E4=BB=B6=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E8=AF=8A=E6=96=AD=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除内置插件卡片中的 lastError 展示 移除导入插件卡片中的 lastError 展示 补充扩展设置文档约定 --- .../features/runtime-config/RuntimeConfigDialog.tsx | 10 ---------- ...�技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 ++++ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index c18a6da4f..1b4a52659 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -1113,11 +1113,6 @@ export function RuntimeConfigDialog({
{plugin.name} 内置 Plugin · 不可卸载 - {plugin.lastError ? ( - - {plugin.lastError} - - ) : null}
@@ -1258,11 +1253,6 @@ export function RuntimeConfigDialog({ {typeLabel} · 来自 {item.sourceName} - {item.lastError ? ( - - {item.lastError} - - ) : null}
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4f86ce479..f65a82ef2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -91,6 +91,10 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema | 保持不变的边界 | 工作区根本身是链接、候选子目录是链接、二层及更深目录不递归,这三条既有边界不动 | `import_tests::ignores_symbolic_link_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_windows_reparse_child_candidate_without_writing_agent_metadata`、`import_tests::ignores_godot_projects_below_the_first_child_level` | | 内置插件行 | 设置→扩展 的内置插件行不再渲染手动「启动 / 停止」按钮;启动由项目切换时的前端自动启动承担,停止走该行启用开关(禁用即停止并断开编辑器连接);导入扩展行的启动按钮保留 | `apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`tests/pluginHost.test.ts` | +## 2026-09-30 扩展设置隐藏插件进程诊断文案 + +设置→扩展的内置插件与导入插件卡片不再渲染宿主返回的 `lastError`(包括“插件进程已退出”)。插件启用状态、运行状态、重载与启用/禁用操作保持现有行为;错误仍由宿主保留用于状态判定和操作反馈。 + ## 2026-09-20 DirectProject 七项效率闭环(补齐合同) 本节补齐并覆盖下节中仅靠 Skill 要求预检、收尾、批读和原生命令预算的部分。完整目标仍为:自动预检、宿主验收与收尾、分层验证、统一执行/返修预算、稳定测试基线、请求耗时与批量读取、所有工具并行。已有代码及测试不等于全部目标已完成;按下表逐项验收。 From a344686b59fb21a250b63ece5408de22dfe6db8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Thu, 1 Oct 2026 11:51:10 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=B8=BA=E5=90=8E=E5=8F=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=B7=A5=E7=A8=8B=E6=96=B0=E5=A2=9E=E6=8C=89=E9=9C=80?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=90=8C=E6=AD=A5=E6=97=B6=E9=97=B4=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增按钮读取所选渠道全部项目并按同步时间降序、用户与项目 ID 升序展示 复用本地分页并支持全量刷新、恢复默认顺序、进度提示与取消读取 为全量读取增加超时、数量和游标边界,失败时保留原结果 补充三项关键交互测试并同步技术方案、运维说明与共享记忆 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/546 --- .../pages/AdminProjectSnapshotsPage.test.tsx | 240 +++++++++++++++++- .../src/pages/AdminProjectSnapshotsPage.tsx | 170 ++++++++++++- .../shared-memory/decision-log.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + 5 files changed, 412 insertions(+), 5 deletions(-) diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx index 7916e60e3..1e185e9a5 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx @@ -16,7 +16,10 @@ import { getAdminProjectSnapshotChannels, listAdminProjectSnapshots, } from '../api/adminApiClient'; -import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes'; +import type { + AdminProjectSnapshotEntry, + AdminProjectSnapshotListResponse, +} from '../api/adminApiTypes'; import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage'; vi.mock('../api/adminApiClient', async () => ({ @@ -58,6 +61,241 @@ afterEach(() => { vi.useRealTimers(); }); +test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => { + const olderProjects = Array.from({ length: 22 }, (_, index) => ({ + ...entry, + projectId: `older-${index}`, + projectName: `旧项目 ${index}`, + syncedAtMs: entry.syncedAtMs + index, + })); + const tiedProjects = [ + { ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' }, + { ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' }, + { ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' }, + ].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' }) + // 后端有界扫描可能返回带下一游标的空页,不能提前结束。 + .mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' }) + .mockResolvedValueOnce({ + items: [ + ...tiedProjects, + { + ...olderProjects[0]!, + projectName: '更新项目', + syncedAtMs: entry.syncedAtMs + 200, + }, + ], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByRole('button', { name: '恢复默认顺序' }); + const rowNames = () => + screen + .getAllByRole('row') + .map((row) => row.querySelector('td strong')?.textContent) + .filter(Boolean); + expect(rowNames().slice(0, 4)).toEqual([ + '更新项目', + '同秒甲一', + '同秒甲二', + '同秒乙', + ]); + expect(rowNames()).toHaveLength(20); + expect(screen.getByRole('navigation').textContent).toContain('共 25 个'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: 'scan-3', limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(rowNames()).toHaveLength(5); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(rowNames()[0]).toBe('更新项目'); + fireEvent.change(screen.getByLabelText('每页条数'), { + target: { value: '50' }, + }); + expect(rowNames()).toHaveLength(25); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '刷新后的项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新后的项目'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: null, limit: 100, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('三消工程'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 6, + 'token', + { cursor: null, limit: 50, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + +test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => { + const projects = Array.from({ length: 45 }, (_, index) => ({ + ...entry, + projectId: `sorted-${index}`, + projectName: `排序项目 ${index}`, + syncedAtMs: entry.syncedAtMs - index, + })); + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockResolvedValueOnce({ items: projects, nextCursor: null }) + .mockRejectedValueOnce(new Error('恢复失败')) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第一页' }], + nextCursor: 'default-next', + }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '默认第二页' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('排序项目 0'); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('恢复失败'); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(screen.getByRole('navigation').textContent).toContain('共 45 个'); + expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + expect(screen.getByText('排序项目 40')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '上一页' })); + expect(screen.getByText('排序项目 20')).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3); + + fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' })); + await screen.findByText('默认第一页'); + expect(screen.getByRole('navigation').textContent).toContain('第 1 页'); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 4, + 'token', + { cursor: null, limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); + fireEvent.click(screen.getByRole('button', { name: '下一页' })); + await screen.findByText('默认第二页'); + expect(screen.getByRole('navigation').textContent).toContain('第 2 页'); + expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith( + 5, + 'token', + { cursor: 'default-next', limit: 20, channel: 'dev' }, + expect.any(AbortSignal), + ); +}); + +test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => { + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' }) + .mockResolvedValueOnce({ + items: [{ ...entry, projectName: '部分读取项目' }], + nextCursor: 'scan-next', + }) + .mockRejectedValueOnce(new Error('全量读取失败')); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('全量读取失败'); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('部分读取项目')).toBeNull(); + expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull(); + expect( + screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'), + ).toBe(false); + + vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({ + items: [{ ...entry, projectName: '完整排序项目' }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + await screen.findByText('完整排序项目'); + vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce( + new Error('刷新读取失败'), + ); + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + await screen.findByText('刷新读取失败'); + expect(screen.getByText('完整排序项目')).toBeTruthy(); + expect(screen.getByRole('navigation').textContent).toContain('共 1 个'); +}); + +test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => { + vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({ + defaultChannel: 'dev', + channels: ['dev', 'release'], + }); + let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void; + let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void; + vi.mocked(listAdminProjectSnapshots) + .mockResolvedValueOnce({ items: [entry], nextCursor: null }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishCancelled = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishOldChannel = resolve; + }), + ) + .mockResolvedValueOnce({ + items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }], + nextCursor: null, + }); + render(); + await screen.findByText('三消工程'); + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[1]![2]!; + fireEvent.click(screen.getByRole('button', { name: '取消排序' })); + expect(cancelledSignal.aborted).toBe(true); + await act(async () => { + finishCancelled({ + items: [{ ...entry, projectName: '已取消项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('三消工程')).toBeTruthy(); + expect(screen.queryByText('已取消项目')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' })); + const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock + .calls[2]![2]!; + fireEvent.change(screen.getByLabelText('项目工程渠道'), { + target: { value: 'release' }, + }); + await screen.findByText('正式渠道项目'); + expect(oldChannelSignal.aborted).toBe(true); + await act(async () => { + finishOldChannel({ + items: [{ ...entry, projectName: '旧渠道项目' }], + nextCursor: null, + }); + }); + expect(screen.getByText('正式渠道项目')).toBeTruthy(); + expect(screen.queryByText('旧渠道项目')).toBeNull(); + expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy(); +}); + test('按项目展示完整性并限制未完成工程下载', async () => { vi.mocked(listAdminProjectSnapshots).mockResolvedValue({ items: [ diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx index 026adec6b..b8c6d7b3a 100644 --- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx +++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx @@ -10,7 +10,7 @@ import { AdminPagination, AdminStatusPill, } from '@genarrative/shared/components'; -import { Download, RefreshCcw, X } from 'lucide-react'; +import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { @@ -50,6 +50,9 @@ const snapshotStatuses: Record< const DEFAULT_PAGE_SIZE = 20; const PAGE_SIZE_OPTIONS = [20, 50, 100]; +const MAX_SORT_PAGES = 200; +const MAX_SORT_PROJECTS = 10_000; +const SORT_TIMEOUT_MS = 60_000; export function AdminProjectSnapshotsPage({ token, @@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({ const [channelOptions, setChannelOptions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); + const [isSorted, setIsSorted] = useState(false); + const [isSorting, setIsSorting] = useState(false); + const [sortProgress, setSortProgress] = useState(0); const [errorMessage, setErrorMessage] = useState(''); const [downloadingKey, setDownloadingKey] = useState(null); const listController = useRef(null); @@ -71,9 +77,23 @@ export function AdminProjectSnapshotsPage({ // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 const pageCursors = useRef<(string | null)[]>([null]); + // 全量读取完成后才发布;分页只切片本次读到的完整集合。 + const sortedItems = useRef(null); const loadPage = useCallback( - async (cursor: string | null, limit: number, page: number) => { + async ( + cursor: string | null, + limit: number, + page: number, + restoreDefault = false, + ) => { + if (!restoreDefault && sortedItems.current !== null) { + setItems(sortedItems.current.slice((page - 1) * limit, page * limit)); + setPageIndex(page); + setHasLoaded(true); + setErrorMessage(''); + return; + } listController.current?.abort(); const controller = new AbortController(); listController.current = controller; @@ -86,6 +106,12 @@ export function AdminProjectSnapshotsPage({ controller.signal, ); if (controller.signal.aborted) return; + // 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。 + if (restoreDefault) { + sortedItems.current = null; + setIsSorted(false); + pageCursors.current = [null]; + } setItems(response.items); setNextCursor(response.nextCursor); setPageIndex(page); @@ -126,8 +152,16 @@ export function AdminProjectSnapshotsPage({ return () => controller.abort(); }, [token, onUnauthorized]); + useEffect(() => { + sortedItems.current = null; + setIsSorted(false); + }, [token, channel]); + useEffect(() => { if (channel === null) return undefined; + setIsSorting(false); + setIsLoading(false); + setSortProgress(0); pageCursors.current = [null]; setItems([]); setNextCursor(null); @@ -144,6 +178,10 @@ export function AdminProjectSnapshotsPage({ }, [loadPage, pageSize, channel]); function goToNextPage() { + if (sortedItems.current !== null) { + void loadPage(null, pageSize, pageIndex + 1); + return; + } if (!nextCursor) return; pageCursors.current[pageIndex] = nextCursor; void loadPage(nextCursor, pageSize, pageIndex + 1); @@ -159,6 +197,10 @@ export function AdminProjectSnapshotsPage({ } function refreshCurrentPage() { + if (sortedItems.current !== null) { + void sortAllProjects(); + return; + } void loadPage( pageCursors.current[pageIndex - 1] ?? null, pageSize, @@ -166,6 +208,90 @@ export function AdminProjectSnapshotsPage({ ); } + async function sortAllProjects() { + if (channel === null) return; + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setIsSorting(true); + setSortProgress(0); + setErrorMessage(''); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, SORT_TIMEOUT_MS); + try { + const projects = new Map(); + const seenCursors = new Set(); + let cursor: string | null = null; + for (let page = 0; page < MAX_SORT_PAGES; page += 1) { + const response = await listAdminProjectSnapshots( + token, + { cursor, limit: 100, channel }, + controller.signal, + ); + if (controller.signal.aborted) return; + for (const project of response.items) { + const key = snapshotKey(project); + const previous = projects.get(key); + if (!previous || project.syncedAtMs >= previous.syncedAtMs) { + projects.set(key, project); + } + } + if (projects.size > MAX_SORT_PROJECTS) { + throw new Error('项目超过 10,000 个,无法完成全量排序'); + } + setSortProgress(projects.size); + if (!response.nextCursor) { + const result = [...projects.values()].sort( + (left, right) => + right.syncedAtMs - left.syncedAtMs || + compareIds(left.userId, right.userId) || + compareIds(left.projectId, right.projectId), + ); + sortedItems.current = result; + setIsSorted(true); + setItems(result.slice(0, pageSize)); + setPageIndex(1); + setNextCursor(null); + setHasLoaded(true); + return; + } + if (seenCursors.has(response.nextCursor)) { + throw new Error('项目列表游标重复,无法完成全量排序,请重试'); + } + seenCursors.add(response.nextCursor); + cursor = response.nextCursor; + } + throw new Error('项目读取超过 200 页,无法完成全量排序'); + } catch (error: unknown) { + if (!controller.signal.aborted) { + handlePageError(error, onUnauthorized, setErrorMessage); + } + } finally { + clearTimeout(timeout); + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + setIsSorting(false); + if (timedOut) setErrorMessage('全量项目读取超时,请重试'); + } + } + } + + function cancelSorting() { + listController.current?.abort(); + listController.current = null; + setIsLoading(false); + setIsSorting(false); + } + + function restoreDefaultOrder() { + void loadPage(null, pageSize, 1, true); + } + async function downloadProject(entry: AdminProjectSnapshotEntry) { if (downloadController.current || entry.status === 'partial') return; const controller = new AbortController(); @@ -236,6 +362,32 @@ export function AdminProjectSnapshotsPage({ ))} + void sortAllProjects() + } + > + + {isSorting ? ( + <> + + 正在读取全部项目,已读取 {sortProgress} 个 + + + + + ) : null} 第 {pageIndex} 页 {items.length ? `,本页 ${items.length} 个项目` : ''} + {isSorted + ? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧` + : ''} } pageSize={{ @@ -279,7 +434,12 @@ export function AdminProjectSnapshotsPage({ }} busy={isLoading} previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }} - next={{ disabled: !nextCursor, onClick: goToNextPage }} + next={{ + disabled: isSorted + ? pageIndex * pageSize >= (sortedItems.current?.length ?? 0) + : !nextCursor, + onClick: goToNextPage, + }} /> ) : null } @@ -375,6 +535,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) { return `${entry.userId}/${entry.projectId}`; } +function compareIds(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} + function formatBytes(bytes: number) { const units = ['B', 'KiB', 'MiB', 'GiB']; const unit = Math.min( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c01d2a426..6a40e2722 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9258,7 +9258,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 列表排序(新增能力,同日):`AdminListPanel` 增加 `sortable` / `sortValue` / `sortDescription`——列头渲染与表查询一致的排序按钮(`admin-table-sort-button` + 升/降/双向图标),点击按「正序 → 倒序 → 不排序」循环,同步 `th[aria-sort]`,排序是稳定排序(值相同保持服务端原顺序)。已接入**一次取全**的 5 个列表:邀请码列表、操作记录、灰度 Gate 列表、可配置开关、任务配置列表(这些接口没有分页,前端排序语义正确)。 - 列表实现全量收口(同日续做):把剩余 **15 处** `children` 形态列表全部迁到 `columns` + `renderRow`(表体 JSX 原样搬进 `renderRow`,外壳/状态/分页槽位不变):`AdminRedeemCodePage`(2)、`AdminRechargeProductPage`、`AdminProjectSnapshotsPage`、`AdminErrorReportsPage`、`AdminAgcTrackingPage`、`AdminGameDistributionReviewPage`、`AdminGameManagementPage`(主列表 + 版本历史)、`AdminUserDetailDialog`(充值订单)、`AdminRechargeOrderPage`、`AdminAgcModelsPage`、`AdminEditorAssetQueryPage`、`AdminEditorShowcaseReviewPage`、`AdminAgcTemplatesPage`。迁移后全仓统计:`AdminListPanel` **26 处 / 21 个文件**,其中 **24 处 columns 形态**(含 2 处弹窗内 `surface="plain"`),仅剩 2 处非表格列表仍是 children 形态(账号管理卡片列表、账号配置的键值列表);`` 作为 children 的写法已归零。可排序列累计 **45 个**(新增写入 `AdminAgcTemplatesPage` 列头 4 个:模板/引擎版本/包大小/状态)。 - 迁移中顺带处理的形态差异:① `AdminErrorReportsPage` 的详情弹窗原本嵌在列表面板里,随表格一起搬到面板外(遮罩是 fixed,视觉不变);② `AdminAgcModelsPage` 的工具栏与状态行改为走 `toolbar` 槽位(列表面板按 toolbar → 加载行 → 表格渲染,顺序与原来一致);③ `AdminAgcTemplatesPage` 的列表从共享 `ui/Table`(`genarrative-ui-table*`)换成 `AdminTable`,与其它页签视觉统一——该页唯一的 ui Table 只剩「上传模板」弹窗里的待上传队列表(有逐行校验/进度状态的编辑态表格,未纳入列表组件,属有意保留)。 -- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。因此埋点数据、客户端埋点、错误报告、项目工程、充值订单这类**分页明细列表暂时不能排序**——只在前端排「当前页」会给出错误结论,必须给对应接口加排序参数(DTO + handler + `adminApiTypes` + 契约/测试)后前端复用同一列头。账号管理是卡片列表(`children` 形态),本轮未加排序。 +- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。埋点数据、客户端埋点、错误报告、充值订单等分页明细不能只在前端排「当前页」,必须先让对应接口支持全局排序,再复用列头。项目工程采用独立的主动全量读取按钮:当前渠道完整读取成功后按同步时间降序、用户/项目 ID 升序,本地分页;原目录接口及上传/下载契约保持不变,不新增 OSS 索引或数据库表。读取限制为 200 页、10,000 个唯一项目、60 秒,可取消;失败保留原列表,切换渠道/令牌取消请求并销毁临时集合。账号管理是卡片列表(`children` 形态),本轮未加排序。 - 本地假数据补齐:`scripts/admin-web-fake-api.mjs` 新增 `agc-models`、`game-distribution/games`、`profile/recharge-products`、`profile/redeem-codes`、`profile/tasks`、`agc/tracking-events` 夹具,并对 `profile/recharge-orders` 按 `AdminRechargeOrderEntryPayload` 的真实字段补齐(缺字段会让页面抛 `Cannot read properties of undefined`——后台没有 error boundary,整页会白屏)。**已知缺口**:充值管理页仍缺一处夹具字段(页面读 `undefined.find`),本轮没能出图;该页自身 21 条单测通过、类型检查通过,仅缺截图。 - 排序验证:共享组件新增用例覆盖「正序 / 倒序 / 取消 + aria-sort + 行序」;现场实测邀请码列表按「创建」排序:正序 `EXPIRED-CODE, BETA-CREATOR, TAONIER-VIP-2026`、倒序翻回 `TAONIER…, BETA…, EXPIRED…`,`th[aria-sort]` 依次为 `ascending` / `descending`。 - 边界(未完成):未跑生产后台构建与真实后台接口联调(截图用假数据);`apps/admin-web` 目前没有 error boundary,任何接口形状不符仍会把整页渲染清空(本次只加固了 `AdminAgcTrackingPage` 一处,其余页面同类写法未逐个排查);`AdminAgcTemplatesPage` 的列表面板是本次新增的外壳(原页面没有面板),视觉上多了白底卡片。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f65a82ef2..21b0ba771 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1787,6 +1787,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 清单补充可选 `projectName` 与 `pendingFiles`:名称来自本地 manifest;`pendingFiles` 是本轮失败、延后、并发变动与非策略排除的跳过文件数量。`0` 表示扫描范围已同步;大文件等被跳过不能标成完整。旧清单字段缺失表示完整性未知,维持可读取兼容,不反写旧清单。 - 项目名称或完整性发生变化时,即使文件内容没有差异也要提交新清单;本机索引记录上次已提交的这两个字段。实际客户端下一次正常同步可补齐历史清单元数据;后台只读访问不迁移旧清单。缺失 `pendingFiles` 不能默认成 0,临时跳过原因消失后允许无文件上传的 `partial → ready` 转换。 - 后台增加“项目工程”入口,仅 owner 及拥有 `project-snapshots` 页签权限的管理员可访问。`GET /admin/api/project-snapshots?cursor=&limit=20` 读取私有 OSS 清单并返回 `{items,nextCursor}`;单页最多 100 个,游标由服务端校验,目录与清单读取有界。条目为 `{userId,projectId,projectName,syncRevision,syncedAtMs,fileCount,totalBytes,status}`,状态为 `ready / partial / unverified`,名称缺失时显示 projectId。 +- 后台“按同步时间排序”是主动读取当前渠道全量项目的临时展示操作:从首游标开始,以每次 100 条沿 `nextCursor` 顺序读取,只有全部读取成功后才切换为 `syncedAtMs` 降序、`userId` 字符串升序、`projectId` 字符串升序;不能只排序当前远端页。成功后回到第一页,20/50/100 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”在远端目录第一页读取成功后统一切换列表、排序模式和分页游标;读取失败保留原排序集合、当前页码和本地分页,支持重试。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。 +- 全量读取展示已取得项目数并允许取消;单次最多 200 个远端页、10,000 个唯一项目、60 秒,重复游标或超限明确报错。读取失败、鉴权失败、超时或取消保留原来的列表和分页,不能发布部分数据作为排序完成的结果。并发同步期间重复项目按用户/项目身份合并,保留同步时间较新的一份,相同时间取后读到的清单;该列表是遍历期间读到的数据集合,不承诺 OSS 全局事务快照,后续同步由管理员刷新读取。仍由原接口实施渠道和页签鉴权,不新增 OSS 索引、数据库表、API 参数或客户端迁移。 +- 排序验收覆盖远端多页与空中间页、时间相同的身份排序、本地翻页/页容量、刷新与恢复默认、失败/取消/超时/限额/重复游标、并发重复身份,以及渠道/令牌切换和卸载后的旧响应隔离;证据入口为 `apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx`、后台类型检查和编码/文档索引检查。 - `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 只读取该用户/项目的固定清单与其引用对象,返回 `application/zip` 附件。ZIP 中路径直接使用原始相对路径,不包含 userId、摘要目录或 OSS 前缀;名称使用经过安全处理的项目名和 revision。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。 - `partial` 快照下载返回 409;`unverified` 历史快照可导出已同步文件,列表明确显示“完整性未知”,动作称“下载已存文件”。`ready` 才显示“下载完整工程”。ZIP 构建核验每一文件的长度与 fnv1a64 摘要,拒绝穿越、绝对路径、重复/大小写冲突路径、非法项目身份;缺失或损坏整体失败,不返回成功的残缺 ZIP。 - ZIP 使用服务端临时文件并限制并发,不将 2 GiB 工程整体驻留内存;成功、失败、客户端取消均清理临时文件。单文件、总量、文件数沿用上传上限,超限明确拒绝。零字节工程文件可以上传和导出。OSS 凭据与签名不下发浏览器,列表失败保留错误而非伪造空列表。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 7946645c1..ee2e9c2e2 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -620,6 +620,8 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null 后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。 +“按同步时间排序”按钮按需读取所选渠道全部项目,完成后按同步时间从新到旧、用户 ID 和项目 ID 字符串升序显示,并在浏览器本地按 20/50/100 条分页;列表会显示本轮项目总数。读取中可取消,失败保留原结果。排序模式下“刷新”重新读取全部项目,“恢复默认顺序”返回远端目录分页;切换渠道或重新登录清除临时排序结果。每次全量读取上限为 200 页、10,000 个唯一项目和 60 秒,超限报错,不将部分集合视作排序完成。该操作增加当次 OSS 清单读取量,不写 OSS 索引、不改数据库或 AGC 上传契约;并发同步结果以本轮读取集合为准,最新变化需刷新。 + 自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。 后台枚举另外需要 AGC 私有前缀的 `ListObjects`(RAM `oss:ListObjects`,限制 prefix)与 `GetObject` 权限;下载不需要写入权限。客户端修复、后台页面与 api-server 必须分别发布才能在安装版和线上后台使用。本地定向测试及页面模拟不能代替发布后的真实上传与 ZIP 下载验收。