Compare commits

...

46 Commits

Author SHA1 Message Date
k88936 07bac0377e 修复:调用级拒绝里属于宿主与环境事实的错补回运行错误诊断
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m23s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m45s
Project CI / Native shell tests (pull_request) Successful in 6m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m29s
- `DirectTurnError::is_reportable`:按变体判定哪几条调用级拒绝值得进 `.agent/runtime/errors` 与应用日志(环境未就绪、宿主状态取不到、项目目录锚不定),回合级失败恒 false(上游已写过诊断)
- `record_direct_codex_turn_failure` 改名 `record_direct_codex_failure` 并开放到 crate 内:它同时服务回合失败与可留痕的调用级拒绝,摘要 / 可重试 / 建议仍全部由 typed 分类判定
- 新增 `direct_turn_error_boundary_text`:命令边界唯一的文本投影——可留痕的拒绝补一份诊断并在返回串里带 `详情:` 引用,其余只输出 `Display`
- GUI 命令与 CLI 边界共用这一份投影:字符串只在边界生成一次,前端横幅的 `详情:` 展开与诊断留痕恢复分层改 typed 之前的行为
- 新增 4 项测试:可留痕拒绝写出诊断、用户侧拒绝不留痕、回合失败不在边界二次留痕、`is_reportable` 的变体集合
2026-09-23 15:59:32 +08:00
k88936 46b86527f9 注释:记录 Direct 命令接单化的 TODO 与实现要点
- `direct_runtime/user_input.rs` 命令入口加 TODO:目标形状(接单 + spawn、`turn.started` 与终态守卫下沉、接单后早退必须补终态、环境类失败升为回合级、宿主侧承担留痕与上报)与两条已决策的作废项
- `useDirectProjectChatController.ts` 的 catch 加同一份 TODO,说明它现在兼职"接单被拒"与"回合失败"、将来只剩前者
- 两处都指向草案 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`,本次不实施、不提交该草案
2026-09-23 15:36:03 +08:00
k88936 34b95b5826 文档:Direct 回合错误改 typed 的决策与影响口径
- ADR【DirectProject对话历史单一事实源】新增一条决策:回合失败在宿主内部是 typed 的、调用级拒绝与回合级失败不共用判据,线上载荷与命令边界仍由同一出口投影
- 同 ADR 修正原措辞:原生 error 现在按 `codexErrorInfo` 解析成 typed 分类,不再描述成"投影成 LlmError";影响一节补一条调用级拒绝只回命令边界、不写诊断不发失败事件
- decision-log 记本次决策、根因、明确不做项、影响范围与验证结果
2026-09-23 14:36:42 +08:00
k88936 a553967ab9 Direct 回合失败全链路改 typed 错误:不再靠字符串匹配分类
- 新增 `agent/direct_turn_error.rs`:`DirectTurnError` 每个变体自带字段(调用级拒绝与回合级失败不共用结构和判据),分流只认 `is_turn_failure()`,不再有 `kind` 字段 + 共用字段的伪结构化
- 分类判据从"对原因文本做子串匹配"改成 `match` typed 值:`DirectCodexNativeKind` 只解析 `codex-app-server-error:<kind>` 结构化前缀,原 `direct_turn_failure_kind` / 各 `contains` 词表判据删除
- `direct_runtime`:`run_direct_game_creator_turn_*` 返回 typed 错误;本地 `DirectCodexFailureStage` / `DirectCodexTurnFailure` 与并发前缀常量改由 typed 模型提供;调用级拒绝不进失败诊断、不发 `failed` 事件
- `codex_app_server`:执行适配器把宿主亲见的收场事实(通道断开 / 超时 / 中断)存成 typed 值;模型自报失败经 `DirectTurnError::from_model_call` 投影
- `direct_turn_failure`:终态判定收 typed 错误并投影出载荷 `kind` / `message`;删除 `DIRECT_TURN_FAILURE_{TRANSPORT,INTERRUPTED,TIMEOUT}_KIND` 与 `direct_turn_failure_kind`
- `direct_delivery` 返修控制流改用 `ReviewRequired`(不是失败);命令边界与 CLI 仍是 `Result<String, String>`,字符串只在 `Display` 一处生成,`wire_kind` 取值与可见文案与改造前逐一相同
2026-09-23 14:34:43 +08:00
k88936 69f6c2dc69 Direct 回合链路引入 typed 错误数据模型
- 新增 direct_turn_error 深模块:DirectTurnError 按变体各带字段,调用级拒绝与回合级失败分开
- 原生失败分类 DirectCodexNativeKind 由结构化前缀读入,事件载荷 kind 与旧口径逐条对齐
- 模型调用失败按平台 LlmError 分支投影成 DirectModelCallKind,反馈/重试/摘要/建议改由类型判定
- 跨进程边界仍由 Display 序列化成字符串,Rust 侧不再解析该字符串
2026-09-23 13:39:37 +08:00
k88936 217f5e8d81 Merge remote-tracking branch 'origin/master' into feat/fail-as-event 2026-09-23 11:50:42 +08:00
kdletters 43b7aa809b 清理旧创作模板退役过程记录与死代码 (#489)
Project CI / AI game creator shell Rust crates (push) Successful in 1m6s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m16s
Project CI / Backend tests (push) Successful in 4m49s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m1s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m50s
Project CI / Native shell tests (push) Successful in 6m5s
Project CI / Frontend tests (push) Successful in 2m6s
Project CI / AI game creator shell web tests (push) Successful in 1m19s
Project CI / Repository checks (push) Successful in 1m43s
- 删除一次性 schema guard 退役表白名单、isApprovedRetiredTableDeletion 导出与专属测试,compareTables 收回为内部函数
- 删除 shared-contracts 未挂载的旧玩法契约模块、api-server 旧 story 模块与旧压测资源 scripts/loadtest/ 及对应忽略/排除配置
- 清理 decision-log、pitfalls 中旧表退役、两阶段清表窗口与旧玩法过程记录,保留改写后的通用条目
- 移除权威文档、模块 README 与运维文档中的旧模板、历史表与旧压测叙述

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/489
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-23 11:46:39 +08:00
k88936 29d4b24226 宿主终态由事实判定:模型自报失败投影进既有错误通道,失败载荷不再被收尾阶段吞掉
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m33s
Project CI / Native shell tests (pull_request) Successful in 6m12s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m3s
- codex_app_server 的 failed 分支先投影原生 turn.error(复用 game_creator_codex_app_server_failed_turn_error),把它当作本回合的错误结果返回:载荷形状不变,RepairRequired 保持自己的原语义,交付报告不再顶掉原因
- direct_turn_terminal 去掉 model_status 入参:判定改为「宿主当场记下的失败 -> 本回合错误结果是 Err -> 只有账本读不出来时才用交付报告」,有载荷一定写 status="failed",没载荷才用收尾阶段推出来的 status
- 执行适配器把宿主观察到的失败记在适配器上(fail_turn / turn_failure / host_stop_requested):看门狗与终态判定共用同一条事实,不用调用点局部变量
- 单测:投影后的原生失败压过被收尾改写的会话状态、账本读不出来仍带载荷、宿主自己关的连接不算失败(断言改用真实原因)
2026-09-23 11:20:48 +08:00
k88936 258c2f6cae 文档:终态由事实判定,模型自报失败的原生错误投影进既有错误通道
- ADR【DirectProject对话历史单一事实源】补一条决策:终态按事实取原因、有载荷必 failed;模型自报失败的 turn.error 投影成 LlmError 走同一条错误通道,不为载荷新增字段
- ADR「影响」补一条:可见文案仍走既有映射,区别只是原因改由事件载荷给出、命令返回恢复运行错误横幅
- 技术方案【DirectProject Codex原始历史与异常恢复】写明 lifecycle_status 没有终态否决权,以及原生错误的投影口径
- decision-log 记本次决策、根因、不做项、影响范围与验证方式
2026-09-23 11:20:39 +08:00
suzmii b62afb43b6 Merge pull request '修复 AGC dev 无限重建:Codex 组件权限仅在真正不一致时写入' (#487) from fix/agc-dev-codex-rebuild-loop into master
Project CI / AI game creator shell Rust smoke (push) Successful in 1m19s
Project CI / AI game creator shell Rust crates (push) Successful in 1m5s
Project CI / Backend tests (push) Successful in 3m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m2s
Project CI / Frontend tests (push) Successful in 2m14s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m56s
Project CI / Native shell tests (push) Successful in 6m21s
Project CI / AI game creator shell web tests (push) Successful in 2m37s
Project CI / Repository checks (push) Successful in 3m23s
Reviewed-on: #487
2026-09-23 11:13:02 +08:00
suzmii 710d6dddf2 修复 AGC dev 无限重建:Codex 组件权限仅在真正不一致时写入
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- build.rs 的 Codex staging 不再无条件调用 fs::set_permissions,改为仅在目标文件权限与源文件不一致时写入
- 内容相同且权限一致时跳过写入,避免 Windows 上更新 change time 被 tauri dev 文件监听误判为 staging 变更,形成“构建 -> 监听 -> 再构建”的自触发循环
- 复制分支仍保留写权限;权限不一致时仍会恢复,staging 组件执行权限语义不变
2026-09-23 11:09:09 +08:00
lhk229 20b594d4f2 清理后端未使用代码及废弃测试
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m49s
Project CI / Backend tests (pull_request) Successful in 3m38s
Project CI / Frontend tests (pull_request) Successful in 1m47s
Project CI / Native shell tests (pull_request) Successful in 5m40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m17s
Project CI / Repository checks (pull_request) Successful in 1m48s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m52s
Project CI / Backend tests (push) Successful in 3m41s
Project CI / Frontend tests (push) Successful in 1m54s
Project CI / Native shell tests (push) Successful in 5m39s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m42s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m30s
Project CI / Repository checks (push) Successful in 2m7s
Project CI / AI game creator shell web tests (push) Successful in 1m35s
删除编辑器旧分段持久化、生成任务收尾和音频发布链路
删除未使用辅助函数及其专属测试,修正受影响的现有测试边界
保留现役实现,将纯退款测试辅助函数移入测试模块
同步原子提交、角色生成文档和计费验证说明
验证:API 全目标编译零警告,格式、编码和 diff 检查通过
定向测试:391 项通过,11 项退款 outbox 测试失败,含 Windows 权限错误
2026-09-23 02:52:01 +00:00
k88936 8b8e95908c Merge remote-tracking branch 'origin/feat/fail-as-event' into feat/fail-as-event 2026-09-23 10:07:21 +08:00
lhk229 85f2831f16 补齐客户端埋点环境变量示例
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m30s
Project CI / Backend tests (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m23s
Project CI / Native shell tests (pull_request) Successful in 6m43s
Project CI / Frontend tests (pull_request) Successful in 2m26s
Project CI / Repository checks (pull_request) Successful in 2m52s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m17s
Project CI / AI game creator shell Rust crates (push) Successful in 1m2s
Project CI / Backend tests (push) Successful in 4m49s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m6s
Project CI / Native shell tests (push) Successful in 6m0s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m48s
Project CI / Frontend tests (push) Successful in 2m8s
Project CI / Repository checks (push) Successful in 1m49s
Project CI / AI game creator shell web tests (push) Successful in 1m18s
补充本地、生产及容器的埋点 origin 配置示例和 dev/release 说明。
同步技术方案中的运行时配置入口与地址匹配要求。
2026-09-22 16:14:05 +00:00
lhk229 1e186369c9 客户端埋点设置 (#446)
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-23 00:09:12 +08:00
lhk229 5c2f85c089 修复 CI 缓存镜像组装的基础镜像引用
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / Backend tests (push) Successful in 3m40s
Project CI / Frontend tests (push) Successful in 2m0s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m13s
Project CI / Native shell tests (push) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m34s
Project CI / AI game creator shell web tests (push) Successful in 1m32s
Project CI / Repository checks (push) Successful in 1m55s
使用临时本地标签和宿主默认 builder 组装缓存镜像
组装成功或失败后清理临时标签,保持基础镜像清理归属
补充基础镜像复用、重建及失败清理测试
同步部署文档和项目共享记忆
2026-09-22 15:03:05 +00:00
lhk229 4380d453d7 修复 CI 缓存对象误判与产物下载完整性检查
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
按 ZIP 成员内容和元数据识别等价缓存对象,保留真实冲突拒绝规则。
核对产物大小与 ZIP 完整性,为截断和临时传输失败增加有界重试。
补充回归测试并同步部署文档与共享记忆。
通过 80 项缓存测试、17 项工作流测试及真实产物和 HTTP 截断验证。
2026-09-22 14:40:55 +00:00
kdletters f502829fde 发布灰度默认关闭:修掉客户端“看得到点不动”与后台看不到 key (#482)
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 5m19s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m59s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m19s
Project CI / Native shell tests (push) Successful in 6m48s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Repository checks (push) Successful in 2m58s
Project CI / AI game creator shell web tests (push) Successful in 2m34s
## 解决什么

你反馈的两个现象都是真问题,这个 PR 一次修掉:

1. **没开灰度也能看到发布按钮**:发布开关此前是「无 gate 行 = 默认开放」,所以没配灰度时后端把 `gameDistributionPublishEnabled` 判成 true,客户端就渲染了发布入口。
2. **按钮点了没反应**:发布动作的提示原先只写 `workspaceStatus`,而普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,于是「先打开一个项目再发布」「构建失败…」这类信息全都看不见。
3. **后台看不到 key**:预设项此前在未合并的另一个 PR 里,而且后台只列「已创建」的 gate 行。

## 改成什么

- `is_game_distribution_publish_enabled_for_user` 要求 gate 行存在且 `enabled=true`:**灰度默认关闭**,未登录/无行/`enabled=false` 一律不开放;只有白名单 / 灰度比例 / 用户标签命中才开放。作者写入与新版本激活都按这条判据。
- 新增与作者无关的总开关 `is_game_distribution_publish_open`:管理员审核激活新版本只看「灰度是否开启」,不会因为审核员不在作者白名单里而被挡住(这是合入后立刻发现的回归,已修 + 有用例)。
- AGC 新增 `announcePublishMessage`:发布相关提示同时写工作台状态与 DirectProject 对话,点「发布到游戏广场」不会再像没反应。
- 后台「灰度发布配置」新增「可配置开关」列表:`game-distribution:publish` 等预设即使还没创建行也会显示,点「配置」按默认关闭填表,运营开启后填白名单/比例即可放量。

## 验证

- `npm run check:game-distribution-media-e2e`(真实 Phaser 构建产物)现在覆盖:灰度关闭 → 作者拿不到入口 + 写入口 503 → 开启灰度 → 真实封面/截图直传 → 创建游戏/版本/上传/送审 → 管理员审核通过 → 网关 index.html 与 bundle 200 → nosniff;全流程通过。
- 后端:`cargo test -p api-server game_distribution` 19 passed(含新增 `game_distribution_publish_open_ignores_author_allowlist_for_activation`)、`frontend_runtime_config` 6 passed。
- 前端:admin 灰度页 10 用例(含「未创建的预设开关可见并可一键配置」)、AGC 聊天头 3 用例、网页发布页 15 用例;三端 typecheck、`check:encoding`、`check:doc-index`、`check:rustfmt`、全量 `npm test`(400 文件 / 4441 用例)与 `check:repository-ci -- origin/master HEAD` 全绿。

## 上线注意

灰度默认关闭是**刻意**的:合入后到运营在后台把 `game-distribution:publish` 配置并开启之前,作者看不到发布入口、写入口 503;已公开游戏、目录、详情、发行网关、安全下架不受影响。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/482
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-22 21:38:03 +08:00
kdletters a56790eaa6 合并 Phaser 一键发布自动构建与打包
Project CI / AI game creator shell Rust crates (push) Successful in 1m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 5m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m53s
Project CI / Native shell tests (push) Successful in 7m28s
Project CI / Frontend tests (push) Successful in 3m38s
Project CI / Repository checks (push) Successful in 3m36s
Project CI / AI game creator shell web tests (push) Successful in 2m55s
2026-09-22 21:03:57 +08:00
kdletters 6e1fbac5d2 合并旧创作模板历史表删除
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m54s
Project CI / Backend tests (push) Successful in 5m29s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m28s
Project CI / Native shell tests (push) Successful in 6m54s
Project CI / Frontend tests (push) Successful in 3m11s
Project CI / Repository checks (push) Successful in 3m20s
Project CI / AI game creator shell web tests (push) Successful in 3m6s
2026-09-22 20:46:37 +08:00
kdletters b93d15e46e 合并 master:DirectProject 旧消息重解析优化
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m35s
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- 合并 origin/master(a46302846:优化 DirectProject 旧消息重解析导致的会话区卡顿)。
- 自动合并无冲突;Phaser 发布前构建与发行网关根路径改动保持接线。
- 同步目的:满足仓库「PR head 必须包含最新 base」的门禁,随后由 Project CI 复验。
2026-09-22 20:42:32 +08:00
kdletters 57a72f652c Phaser 项目一键发布:发布前自动构建与打包
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- AGC:export_local_project_package 改为发布前构建——已有可玩入口直接打包;否则解析 game/(Phaser 4 + Vite 脚手架)或项目根的 npm build 脚本,缺 game/node_modules 时先跑 project.bootstrap(npm install),再走 project.verify 的受控 npm 运行器执行 npm run build,最后校验入口并打包;安装/构建失败返回带日志尾部的可操作错误。
- 新增 resolve_publish_build_plan 纯函数与用例:game 子工程优先并要求先装依赖、根 npm 工程回退、没有可构建工程时失败关闭。
- 后端:发行网关根路径(含尾斜杠)等价于 index.html,路由级用例覆盖 Cookie 拒绝门与根路径;生产仍由每游戏 origin 把根路径映射到该游戏入口。
- 脚本:check:game-distribution-media-e2e 支持 E2E_PACKAGE_ZIP 直接发布真实构建产物,断言从包内派生入口/资源,本地审核入口改为发行网关路径。
- 文档:玩法链路与实施计划记录「作者不构建、不打 ZIP」的 Phaser 发布口径与验证证据。
- 验证:真实 Phaser 4.2.1 + Vite 7 构建产物(相对引用)经发布链路后网关 index.html 200 / assets 1,388,719 B 200,播放页在 allow-scripts 沙箱 iframe 内渲染 PHASER-PUBLISH-OK 且点击交互生效。
2026-09-22 20:38:00 +08:00
kdletters 55801deb38 Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
2026-09-22 20:22:09 +08:00
k88936 a46302846a Merge pull request '优化 DirectProject 旧消息重解析导致的会话区卡顿' (#479) from fix/typing-stuck into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m13s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m27s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m2s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m45s
Project CI / Backend tests (push) Successful in 7m7s
Project CI / Native shell tests (push) Successful in 7m36s
Project CI / Frontend tests (push) Successful in 3m30s
Project CI / Repository checks (push) Successful in 3m24s
Project CI / AI game creator shell web tests (push) Successful in 2m45s
Reviewed-on: #479
2026-09-22 20:20:07 +08:00
k88936 3467042000 Merge branch 'master' into feat/fail-as-event
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
2026-09-22 20:19:23 +08:00
kdletters 1eaa51b72b 记录阶段二生产发布结果
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
记录 release 直接切换 2.8.3 锁定二进制
记录阶段二 wasm 发布后的 84 张现役表与旧 schema 清零
记录运行时服务恢复和公网 200
2026-09-22 20:09:35 +08:00
k88936 185649323e 优化 DirectProject 旧消息重解析导致的会话区卡顿
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m32s
Project CI / Backend tests (pull_request) Successful in 7m19s
Project CI / Frontend tests (pull_request) Successful in 3m47s
Project CI / Native shell tests (pull_request) Successful in 8m14s
Project CI / Repository checks (pull_request) Successful in 3m40s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m14s
为 ChatMarkdownMessage 加 memo,旧消息文本不变时不再重新解析 Markdown 与语法高亮
流式期间跳过语法高亮,定稿后再补,避免每个 chunk 重跑一遍 highlight.js
为 AgentReasoning 加 memo,内容未变的思考块整块跳过重渲染
把 AgentReasoning 折叠态预览的 Markdown AST 解析收进 useMemo
2026-09-22 20:04:52 +08:00
kdletters 05a5ea4d85 补充历史表清空与备份现场证据
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
记录 release 阶段一 apply、全零复核和观察窗口结果
记录 files-minimal 备份与 restore dry-run 验证
记录大库非 minimal files 备份的 catalog 序列化边界
2026-09-22 19:58:06 +08:00
k88936 944ae9a6eb Merge pull request '调试支持:开发模式下增加 Codex CLI 执行器锚定逻辑,确保裸命令名按 PATH 解析为绝对路径' (#475) from fix/linux-dev into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m14s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m38s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m30s
Project CI / Backend tests (push) Successful in 6m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m16s
Project CI / Native shell tests (push) Successful in 7m14s
Project CI / Frontend tests (push) Successful in 3m21s
Project CI / AI game creator shell web tests (push) Successful in 3m0s
Project CI / Repository checks (push) Successful in 3m42s
Reviewed-on: #475
2026-09-22 19:53:06 +08:00
lhk229 7bc411119c 修复 CI 镜像脚本的 Buildx 驱动检查兼容性
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
读取 buildx inspect 的 Driver 字段,移除不受支持的 --format 参数。
收紧 Docker 测试替身参数检查,并覆盖错误驱动拒绝行为。
更新部署文档与共享记忆,记录真实 Buildx 兼容性验证要求。
2026-09-22 11:45:22 +00:00
kdletters 3103a6e632 Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m20s
Project CI / Backend tests (pull_request) Successful in 7m15s
Project CI / Native shell tests (pull_request) Successful in 8m18s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m18s
Project CI / Repository checks (pull_request) Successful in 3m38s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
2026-09-22 19:43:10 +08:00
kdletters b417144baf Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2 2026-09-22 19:42:44 +08:00
kdletters fd0c1007ad 清除旧创作模板历史表定义与绑定
删除 63 张旧玩法表的 module 定义与 legacy schema
移除清空 procedure、迁移白名单和旧行兼容逻辑
重新生成 spacetime-client bindings
新增一次性 schema guard 删除白名单与回归测试
清理后台表名映射、旧回填工具和权威文档
2026-09-22 19:42:29 +08:00
lhk229 079466b29b 合并最新 master 到 CI 下载缓存优化分支
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
合入 origin/master 的浏览器进程清理与引用输入框重构。
保留 CI 下载缓存、最小构建上下文和维护日志优化。
通过 17 项工作流测试、2 项 npm 缓存导出测试及编码检查。
2026-09-22 11:33:40 +00:00
lhk229 1e6b0e5684 优化 Gitea CI 镜像下载缓存与维护日志
使用专用 BuildKit builder 持久复用 Cargo 与 npm 下载缓存,并支持从可信镜像导入
按当前依赖物化镜像下载快照,配置独立缓存回收策略
补齐 AGC vendor 本地依赖清单并缩小构建上下文
增加缓存维护阶段、耗时和失败日志路径
补充下载缓存与构建上下文测试,同步部署说明和共享记忆
2026-09-22 11:23:46 +00:00
k88936 a0d72bbef2 调试支持:开发模式下增加 Codex CLI 执行器锚定逻辑,确保裸命令名按 PATH 解析为绝对路径
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
2026-09-22 19:19:01 +08:00
k88936 8f2e5b4381 文档:执行通道断开定为失败终态,诊断记在执行适配器上
- ADR【DirectProject对话历史单一事实源】补一条决策:连接级故障与回合事件通道关闭同样带 failure{kind:"transport-failed"},原因用宿主当场写下的诊断,判据是"适配器是否已由宿主主动关闭"
- ADR「影响」补一条:断开时用户看到的仍是既有映射结果,真实诊断在事件载荷、宿主交付报告与运行日志里,改可见文案属于映射规则变更
- 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状,并写明失败事实为什么必须记在执行适配器上(看门狗会抢时序)
- decision-log 记本次决策、判据、不做项、影响范围与验证方式
2026-09-22 18:25:48 +08:00
k88936 7dca17d517 执行通道断开也算失败终态:诊断记在执行适配器上,事件带 transport-failed 载荷
- ExecutionAdapter 新增 transport_failed / transport_failure:调用方只给宿主诊断,文案、报告与"这算不算失败"都归适配器管;先同步记事实,再把同一份原因补进宿主交付报告
- 判据收在适配器里(is_closed):宿主自己收束(正常终态 / 用户主动停止 / 预算与交付收尾)会关掉同一条连接、发同一个 TransportClosed,那些不算失败,调用点两条分支的控制流保持不变;连接自己断掉才算,且只认第一份原因(第一份最接近现场,含 exitStatus 与 stderr 摘要)
- lifecycle_status 见到这条事实一律返回 failed:连接不是被本轮主动收束,也没有"用户主动停止"这层授权,报成 interrupted 只会让界面停在"本轮已结束"却不给原因
- 连接级故障(app-server 进程退出 / 流断 / JSON 行越界 / stderr 读取失败)在收束连接之前先把事实记到本回合的执行适配器上,避免与盯着同一个 closed 标志的看门狗抢时序
- direct_turn_failure 增加第三来源且优先级最高:通道断开时原因取宿主诊断,不取只会说"收束到哪一步"的交付报告
- 单测三条:适配器把诊断记成失败终态且只认第一份原因;宿主自己关的连接不算失败;失败载荷优先取宿主诊断(含与 LlmError 并存时的优先级)
2026-09-22 18:25:37 +08:00
k88936 80b15b24ae appSurface 用例:失败只经终态事件收口,界面不再停在"还在处理"
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 新增「closes the turn from the host failure payload instead of leaving it running」:宿主发过 turn.started 之后以 failure 载荷收场并让命令失败,断言失败文案来自事件载荷(经同一份可见文案映射)、"陶泥儿正在处理"消失、终止钮消失、输入盒回到「发送」
- 同一条用例反向断言命令返回的错误原文不进聊天:那条通道只负责运行错误横幅
- 变异校验:让 reducer 不落失败说明条目时该用例变红(1 failed),恢复后绿
2026-09-22 17:40:17 +08:00
k88936 b432556ee3 reducer 用例:失败终态落说明条目、文案与横幅同源、重放不重复
- 新增「失败终态(turn.completed 带 failure 载荷)」六条用例:失败照样收口并冻结终点、说明条目按本轮开口身份派生、本轮开口条目拿到边界
- 可见文案与运行错误横幅共用同一份映射(点名 codex-app-server-error:context-window-exceeded)
- 重复 / 迟到的失败终态不追加第二条说明、不抬高冻结终点、不复活运行态
- 身份不匹配的失败终态不动正在跑的这一轮;空原因不落说明条目但终态照样收口
- 没有身份时用事件时间派生说明身份,两轮失败不会合并成一条
2026-09-22 17:40:17 +08:00
k88936 258645f182 前端失败说明改由事件驱动:turn.completed.failure 落成本轮说明条目,命令返回只留横幅
- 新增 conversation/directTurnFailure.ts:失败说明条目的展示身份(本轮开口身份 + :failure)与可见文案(复用 projectRuntimeVisibleError)两条口径集中一处
- directThreadChat 的 turn.completed 分支读 failure 载荷:非空原因先落成本轮最后一条说明条目,再走同一个收口函数;失败不再是第二套生命周期
- useDirectProjectChatController 的失败分支不再写聊天气泡:聊天文案唯一来源是事件,命令返回只保留运行错误横幅(含详情 long detail)与诊断留痕
- 数据流、时序与投影注释同步:标注失败说明来自事件、本地通道只剩终止说明与壳层 announce
2026-09-22 17:40:17 +08:00
k88936 5cf4a018b3 宿主终态接线:失败写进 turn.completed 的 failure 载荷,并在 turn.started 之后武装兜底守卫
- codex_app_server 的 DirectProject 终态改用 direct_turn_failure 判定:失败走 turn_completed_failed(原因脱敏 + 截断后写进同一个事件),其余仍走 turn_completed(status)
- 失败判定两个来源:collect_result 是 Err 时用错误本身当原因;collect_result 是交付报告但 status 已判成 failed 时用那份报告当原因
- turn.started 进入队列后立即武装 DirectTurnFailureGuard,写完终态 disarm:panic、回合 future 被丢弃、终态之前的早退都会补一条 host-dropped 失败终态,前端不会停在"还在跑"
- 定向 `cargo test direct_`(438 passed,含 wire / manager / 失败策略模块)
2026-09-22 17:40:17 +08:00
k88936 5d9223c32e 失败终态策略独立成模块:分类、原因脱敏与 Drop 兜底守卫
- 新增 agent/direct_turn_failure.rs:LlmError → 稳定分类(timeout / model-failed / transport-failed / request-rejected)、判定"终态是不是失败"并给出脱敏截断后的原因、DirectTurnFailureGuard(turn.started 之后武装、写完终态 disarm,Drop 时补 host-dropped 失败终态)
- 守卫兜底覆盖 panic / future 被丢弃 / 终态之前的早退;kill -9 与 turn.started 之前的早退写进模块注释,明确不为它们补路径
- agent.rs 注册模块并再导出
- 5 条用例:错误分类映射、只有 failed 终态带载荷、原因脱敏 + 按字符截断、armed 后 Drop 补终态、disarm 后不再产出事件
2026-09-22 17:40:17 +08:00
k88936 d32c99c927 事件协议:turn.completed 增加可选 failure 载荷,失败终态与正常终态同权入锚点
- direct_thread_wire 新增 DirectTurnFailure{kind,message} 类型,给 TurnCompleted 增可选 failure 字段,并补 turn_completed_failed 构造器与 failure 读取器
- with_user_item_id 显式带上 failure:原先把 TurnCompleted 写成 `..` 会静默吞掉失败载荷,身份与原因必须一起流转
- 新增 wire 用例:失败终态带载荷、正常终态不带且回写不补 null、缺载荷的 failed 事件仍可反序列化
- direct_thread_manager 增回归用例:turn.completed(status=failed) 必须顶替更早的 turn.started 成为 lifecycle_anchor,重放不会把已收口的回合看成"还在跑"
- 重新生成 ts-rs 绑定(新增 DirectTurnFailure.ts、DirectThreadEvent.ts 增 failure 字段)并按 prettier 格式化
2026-09-22 17:40:17 +08:00
k88936 d36f5842b6 文档:失败回合终态定为 turn.completed 带 failure 载荷,宿主 Drop 守卫兜底
- ADR【DirectProject对话历史单一事实源】补三条决策:终态事件只有 turn.completed,失败时 status="failed" 必须带 failure{kind,message};宿主 Drop 守卫在 turn.started 之后武装、写完终态即解除;失败原因只走事件这条通道,聊天说明的展示位保留、数据来源换成事件
- 同 ADR「影响」补两条已知边界(进程被强杀时没有 Drop、turn.started 之前的早退不产回合也不补终态)与「可见文案映射规则不变」的口径
- 技术方案【DirectProject Codex原始历史与异常恢复】同步线上形状:turn.completed 增加可选 failure,并写明失败终态与正常终态同权顶替 lifecycle_anchor
- decision-log 记本次决策、明确不做项、影响范围与验证方式
2026-09-22 17:40:17 +08:00
k88936 981a6b0021 Revert:撤掉前端「命令返回就收口」的兜底,改由宿主 turn.failed 事件收口
- 撤销 a35956f3e 的前端实现:directThreadChat 的 commandClosedTurnUserItemId / stopDirectThreadTurn、subscription 的 stopCommandTurn、controller 失败分支的调用,以及随附的两处用例
- 原因:失败语义改由宿主事件(turn.failed)表达,前端不再自造第二条「结束」判定路径,也不再在失败路径上补本地消息
2026-09-22 17:40:16 +08:00
594 changed files with 19752 additions and 43391 deletions
-5
View File
@@ -30,8 +30,3 @@ server-rs/.data
server-rs/.spacetimedb
public/generated-*
scripts/loadtest/data/*.local.json
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt
+6
View File
@@ -239,6 +239,12 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。
# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。
# dev 使用 https://dev.genarrative.worldrelease 使用 https://www.genarrative.world。
GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082"
# Optional: official VikingDB credentials for regenerating build-tag similarities
# with the Python embedding script. The script auto-loads `.env.local` and uses
# the fixed `bge-large-zh` embedding model.
-1
View File
@@ -365,7 +365,6 @@ module.exports = {
'src/types.ts',
'src/types/**',
'src/uiAssets.ts',
'scripts/loadtest/**',
'packages/shared/src/contracts/jumpHop.ts',
'packages/shared/src/contracts/match3dAgent.ts',
'packages/shared/src/contracts/match3dRuntime.ts',
+3 -1
View File
@@ -561,7 +561,9 @@ jobs:
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Validate CI cache maintenance behavior
run: python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
run: |
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
node --test scripts/export-ci-npm-download-cache.test.mjs
- name: Run repository checks
run: npm run check:repository-ci
-8
View File
@@ -88,11 +88,3 @@ nohup.out
spacetime.local.json
deploy/container/api-server.env
deploy/container/worker-smoke/
# Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json
# Local load-test run artifacts
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt
@@ -7,6 +7,7 @@ import {
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
@@ -24,6 +25,30 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
const payload = { entries: [], nextCursor: null };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
expect(
await listAdminAgcTrackingEvents('token', {
userId: 'user+1',
projectId: 'project-1',
cursor: 'page/2',
limit: 50,
}),
).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
}),
);
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
+27
View File
@@ -1,6 +1,8 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -409,6 +411,31 @@ export function listAdminTrackingEventKeys(token: string) {
);
}
export function listAdminAgcTrackingEvents(
token: string,
query: AdminAgcTrackingEventQuery = {},
) {
return request<AdminAgcTrackingEventListResponse>(
`/admin/api/agc/tracking-events${buildQueryString((params) => {
for (const key of [
'userId',
'projectId',
'creativeTaskId',
'agentRunId',
'eventName',
'clientVersion',
'startTime',
'endTime',
'cursor',
] as const) {
appendQueryParam(params, key, query[key]);
}
appendNumericQueryParam(params, 'limit', query.limit);
})}`,
{ token },
);
}
export function listAdminErrorReports(
token: string,
query: {
+38
View File
@@ -820,6 +820,44 @@ export interface AdminTrackingEventListResponse {
entries: AdminTrackingEventEntryPayload[];
}
export interface AdminAgcTrackingEventQuery {
userId?: string;
projectId?: string;
creativeTaskId?: string;
agentRunId?: string;
eventName?: string;
clientVersion?: string;
startTime?: string;
endTime?: string;
cursor?: string;
limit?: number;
}
export interface AdminAgcTrackingEventEntry {
eventId: string;
schemaVersion: number;
eventName: string;
eventTime: string;
userId: string;
editorSessionId: string;
projectId: string | null;
creativeTaskId: string | null;
agentRunId: string | null;
agentTurnId: string | null;
status: string | null;
errorCode: string | null;
source: string;
clientVersion: string;
properties: Record<string, unknown>;
batchId: string;
receivedAt: string;
}
export interface AdminAgcTrackingEventListResponse {
entries: AdminAgcTrackingEventEntry[];
nextCursor: string | null;
}
export interface AdminTrackingEventKeyPayload {
eventKey: string;
eventTitle: string;
+7
View File
@@ -20,6 +20,7 @@ import {
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -233,6 +234,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'agc-tracking' ? (
<AdminAgcTrackingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'error-reports' ? (
<AdminErrorReportsPage
token={token}
+1
View File
@@ -40,6 +40,7 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'agc-tracking': Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
+16 -6
View File
@@ -8,6 +8,22 @@ import {
routeHash,
} from './adminRoutes';
test('客户端埋点路由遵守成员页签权限', () => {
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-tracking'],
}).map((route) => route.id),
).toEqual(['agc-tracking']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking'],
}).some((route) => route.id === 'agc-tracking'),
).toBe(false);
});
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
id: 'dashboard',
@@ -43,12 +59,6 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
expect(routeHash('gray-release')).toBe('#gray-release');
});
test('后台不再暴露旧创作模板管理路由', () => {
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
+2
View File
@@ -5,6 +5,7 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'agc-tracking'
| 'error-reports'
| 'gray-release'
| 'redeem'
@@ -41,6 +42,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tables', label: '表查询', hash: '#tables' },
{ id: 'debug', label: 'API 调试', hash: '#debug' },
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
listAdminAgcTrackingEvents,
} from '../api/adminApiClient';
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
vi.mock('../api/adminApiClient', async (original) => ({
...(await original<typeof import('../api/adminApiClient')>()),
listAdminAgcTrackingEvents: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: () => null,
}));
const entry: AdminAgcTrackingEventEntry = {
eventId: 'event-1',
schemaVersion: 1,
eventName: 'project_save',
eventTime: '2026-09-21T12:00:00.123Z',
userId: 'user-1',
editorSessionId: 'session-1',
projectId: 'project-1',
creativeTaskId: 'project-1',
agentRunId: null,
agentTurnId: null,
status: 'success',
errorCode: null,
source: 'gui',
clientVersion: '1.0',
properties: { reason: 'manual' },
batchId: 'batch-1',
receivedAt: '2026-09-21T12:15:00.000Z',
};
beforeEach(() => {
vi.mocked(listAdminAgcTrackingEvents).mockReset();
});
afterEach(cleanup);
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
.mockResolvedValueOnce({
entries: [{ ...entry, eventId: 'event-2' }],
nextCursor: null,
})
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('保存项目', { selector: 'td' });
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: undefined,
limit: 50,
});
fireEvent.click(screen.getByText('查看详情'));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getAllByText('—').length).toBe(3);
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
fireEvent.click(screen.getByText('下一页'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: 'cursor-page-2',
limit: 50,
}),
);
await waitFor(() =>
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('上一页'));
await screen.findByText('第 1 页');
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('项目 ID'), {
target: { value: 'project-2' },
});
fireEvent.click(screen.getByText('查询'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
'admin-token',
expect.objectContaining({
projectId: 'project-2',
cursor: undefined,
limit: 50,
}),
),
);
expect(screen.getByText('第 1 页')).toBeTruthy();
await waitFor(() =>
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
);
});
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
const unauthorized = vi.fn();
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [], nextCursor: null })
.mockRejectedValueOnce(
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
)
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '登录失效' }),
);
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
await screen.findByText('暂无客户端埋点数据');
fireEvent.click(screen.getByText('刷新'));
expect((await screen.findByRole('alert')).textContent).toContain(
'无权访问客户端埋点',
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
@@ -0,0 +1,379 @@
import { Modal } from '@genarrative/shared/components';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
import type {
AdminAgcTrackingEventEntry,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
const eventLabels: Record<string, string> = {
editor_session_start: '编辑器会话开始',
editor_session_end: '编辑器会话结束',
editor_focus_start: '编辑器获得焦点',
editor_focus_end: '编辑器失去焦点',
project_create_success: '项目创建成功',
project_open: '打开项目',
creative_task_submit: '首次提交创作目标',
agent_run_completed: 'Agent 运行完成',
agent_run_failed: 'Agent 运行失败',
project_revision_created: '项目产生修改',
preview_ready: '预览就绪',
project_save: '保存项目',
};
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
eventId: '事件 ID',
schemaVersion: '事件版本',
eventName: '事件类型',
eventTime: '发生时间',
userId: '用户 ID',
editorSessionId: '编辑器会话 ID',
projectId: '项目 ID',
creativeTaskId: '创作目标 ID',
agentRunId: 'Agent run ID',
agentTurnId: 'Agent turn ID',
status: '结果',
errorCode: '错误码',
source: '来源',
clientVersion: '客户端版本',
properties: '事件属性',
batchId: '批次 ID',
receivedAt: '入库时间',
};
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
}
export function AdminAgcTrackingPage({
token,
onUnauthorized,
}: {
token: string;
onUnauthorized: (message?: string) => void;
}) {
const [filters, setFilters] = useState({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
const [cursors, setCursors] = useState<Array<string | undefined>>([
undefined,
]);
const [page, setPage] = useState(0);
const [refresh, setRefresh] = useState(0);
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
const cursor = cursors[page];
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
const firstPage = useRef<{
token: string;
query: AdminAgcTrackingEventQuery;
response: AdminAgcTrackingEventListResponse;
} | null>(null);
useEffect(() => {
const saved = firstPage.current;
if (!cursor && saved?.token === token && saved.query === query) {
setEntries(saved.response.entries);
setNextCursor(saved.response.nextCursor);
setError('');
setLoading(false);
return;
}
let active = true;
setLoading(true);
setError('');
setEntries([]);
setNextCursor(null);
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
.then((response) => {
if (!active) return;
if (!cursor) firstPage.current = { token, query, response };
setEntries(response.entries);
setNextCursor(response.nextCursor);
})
.catch((failure: unknown) => {
if (active) handlePageError(failure, onUnauthorized, setError);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [token, query, cursor, refresh, onUnauthorized]);
function resetPages() {
firstPage.current = null;
setCursors([undefined]);
setPage(0);
setDetail(null);
}
function search(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (
filters.startTime &&
filters.endTime &&
new Date(filters.startTime) >= new Date(filters.endTime)
) {
setError('结束时间必须晚于开始时间');
return;
}
resetPages();
setQuery({
userId: filters.userId.trim(),
projectId: filters.projectId.trim(),
eventName: filters.eventName,
startTime: filters.startTime
? new Date(filters.startTime).toISOString()
: undefined,
endTime: filters.endTime
? new Date(filters.endTime).toISOString()
: undefined,
});
}
function related(
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
value: string,
) {
resetPages();
setFilters({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
setQuery({ [key]: value });
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
type="button"
className="admin-secondary-button"
disabled={loading}
onClick={() => {
resetPages();
setRefresh((value) => value + 1);
}}
>
</button>
</div>
<form className="admin-panel admin-form" onSubmit={search}>
<div className="admin-filter-grid">
{(['userId', 'projectId'] as const).map((key) => (
<label key={key} className="admin-field">
<span>{fieldLabels[key]}</span>
<input
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
<label className="admin-field">
<span></span>
<select
value={filters.eventName}
onChange={(event) =>
setFilters({ ...filters, eventName: event.target.value })
}
>
<option value=""></option>
{Object.entries(eventLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{(['startTime', 'endTime'] as const).map((key) => (
<label key={key} className="admin-field">
<span>
{key === 'startTime'
? '发生时间起点(含)'
: '发生时间终点(不含)'}
</span>
<input
type="datetime-local"
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
</div>
<div className="admin-action-row">
<button
type="submit"
className="admin-primary-button"
disabled={loading}
>
</button>
</div>
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
.filter((key) => query[key])
.map((key) => (
<p key={key}>
{fieldLabels[key]}{query[key]}
</p>
))}
</form>
{error ? (
<p role="alert" className="admin-error-message">
{error}
</p>
) : null}
<div className="admin-panel">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
{[
'入库时间',
'发生时间',
'用户',
'事件名称',
'项目',
'来源',
'结果',
'客户端版本',
'详情',
].map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.eventId}>
<td>{formatTime(entry.receivedAt)}</td>
<td>{formatTime(entry.eventTime)}</td>
<td>
{entry.userId}
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
</td>
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
<td>{entry.projectId ?? '—'}</td>
<td>{entry.source}</td>
<td>{entry.status ?? '—'}</td>
<td>{entry.clientVersion}</td>
<td>
<button
type="button"
className="admin-ghost-button"
onClick={() => setDetail(entry)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading ? (
<p role="status"></p>
) : !error && entries.length === 0 ? (
<p></p>
) : null}
<div className="admin-action-row">
<button
type="button"
className="admin-secondary-button"
disabled={loading || page === 0}
onClick={() => setPage((value) => value - 1)}
>
</button>
<span> {page + 1} </span>
<button
type="button"
className="admin-secondary-button"
disabled={loading || !nextCursor}
onClick={() => {
if (!nextCursor) return;
setCursors([...cursors.slice(0, page + 1), nextCursor]);
setPage(page + 1);
}}
>
</button>
</div>
</div>
{detail ? (
<Modal
open
title="客户端埋点详情"
closeLabel="关闭详情"
onClose={() => setDetail(null)}
className="genarrative-ui"
>
<dl>
{(
Object.keys(fieldLabels) as Array<
keyof AdminAgcTrackingEventEntry
>
)
.filter((key) => key !== 'properties')
.map((key) => (
<div key={key}>
<dt>{fieldLabels[key]}</dt>
<dd style={{ overflowWrap: 'anywhere' }}>
{detail[key] == null ? '—' : String(detail[key])}
</dd>
</div>
))}
</dl>
<div className="admin-action-row">
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
(key) =>
detail[key] ? (
<button
type="button"
key={key}
className="admin-secondary-button"
onClick={() => related(key, detail[key]!)}
>
{fieldLabels[key]}
</button>
) : null,
)}
</div>
<h3></h3>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{JSON.stringify(detail.properties, null, 2)}
</pre>
</Modal>
) : null}
</section>
);
}
@@ -2641,48 +2641,6 @@ const databaseTableLabelMap: Record<string, string> = {
profile_recharge_order: '充值订单',
profile_feedback_submission: '反馈提交',
profile_save_archive: '存档记录',
story_session: '剧情会话',
story_event: '剧情事件',
npc_state: 'NPC 状态',
inventory_slot: '背包槽位',
battle_state: '战斗状态',
treasure_record: '宝藏记录',
quest_record: '任务记录',
quest_log: '任务日志',
player_progression: '玩家进度',
chapter_progression: '章节进度',
custom_world_profile: '自定义世界档案',
custom_world_session: '自定义世界会话',
custom_world_agent_session: '自定义世界 Agent 会话',
custom_world_agent_message: '自定义世界 Agent 消息',
custom_world_agent_operation: '自定义世界 Agent 操作',
custom_world_draft_card: '自定义世界草稿卡片',
custom_world_gallery_entry: '自定义世界画廊条目',
puzzle_agent_session: '拼图 Agent 会话',
puzzle_agent_message: '拼图 Agent 消息',
puzzle_work_profile: '拼图作品档案',
puzzle_event: '拼图事件',
puzzle_runtime_run: '拼图运行记录',
puzzle_leaderboard_entry: '拼图排行榜条目',
match3d_agent_session: '抓大鹅 Agent 会话',
match3d_agent_message: '抓大鹅 Agent 消息',
match3d_work_profile: '抓大鹅作品档案',
match3d_runtime_run: '抓大鹅运行记录',
square_hole_agent_session: '方洞挑战 Agent 会话',
square_hole_agent_message: '方洞挑战 Agent 消息',
square_hole_work_profile: '方洞挑战作品档案',
square_hole_runtime_run: '方洞挑战运行记录',
visual_novel_agent_session: '视觉小说 Agent 会话',
visual_novel_agent_message: '视觉小说 Agent 消息',
visual_novel_work_profile: '视觉小说作品档案',
visual_novel_runtime_run: '视觉小说运行记录',
visual_novel_runtime_history_entry: '视觉小说历史条目',
visual_novel_runtime_event: '视觉小说运行事件',
big_fish_creation_session: '大鱼吃小鱼创建会话',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
big_fish_event: '大鱼吃小鱼事件',
big_fish_runtime_run: '大鱼吃小鱼运行记录',
asset_object: '资产对象',
asset_entity_binding: '资产实体绑定',
asset_event: '资产事件',
@@ -2724,48 +2682,6 @@ const databaseTableDescriptionMap: Record<string, string> = {
profile_recharge_order: '充值订单表',
profile_feedback_submission: '反馈提交记录表',
profile_save_archive: '用户存档记录表',
story_session: '剧情会话表',
story_event: '剧情事件表',
npc_state: 'NPC 状态表',
inventory_slot: '背包槽位表',
battle_state: '战斗状态表',
treasure_record: '宝藏记录表',
quest_record: '任务记录表',
quest_log: '任务日志表',
player_progression: '玩家进度表',
chapter_progression: '章节进度表',
custom_world_profile: '自定义世界档案表',
custom_world_session: '自定义世界会话表',
custom_world_agent_session: '自定义世界 Agent 会话表',
custom_world_agent_message: '自定义世界 Agent 消息表',
custom_world_agent_operation: '自定义世界 Agent 操作表',
custom_world_draft_card: '自定义世界草稿卡片表',
custom_world_gallery_entry: '自定义世界画廊条目表',
puzzle_agent_session: '拼图 Agent 会话表',
puzzle_agent_message: '拼图 Agent 消息表',
puzzle_work_profile: '拼图作品档案表',
puzzle_event: '拼图事件表',
puzzle_runtime_run: '拼图运行记录表',
puzzle_leaderboard_entry: '拼图排行榜条目表',
match3d_agent_session: '抓大鹅 Agent 会话表',
match3d_agent_message: '抓大鹅 Agent 消息表',
match3d_work_profile: '抓大鹅作品档案表',
match3d_runtime_run: '抓大鹅运行记录表',
square_hole_agent_session: '方洞挑战 Agent 会话表',
square_hole_agent_message: '方洞挑战 Agent 消息表',
square_hole_work_profile: '方洞挑战作品档案表',
square_hole_runtime_run: '方洞挑战运行记录表',
visual_novel_agent_session: '视觉小说 Agent 会话表',
visual_novel_agent_message: '视觉小说 Agent 消息表',
visual_novel_work_profile: '视觉小说作品档案表',
visual_novel_runtime_run: '视觉小说运行记录表',
visual_novel_runtime_history_entry: '视觉小说历史条目表',
visual_novel_runtime_event: '视觉小说运行事件表',
big_fish_creation_session: '大鱼吃小鱼创建会话表',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
big_fish_event: '大鱼吃小鱼事件表',
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
asset_object: '资产对象表',
asset_entity_binding: '资产实体绑定表',
asset_event: '资产事件表',
@@ -175,6 +175,107 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', async ()
);
});
test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'game-distribution',
]);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'game-distribution:publish',
);
expect(
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
).toBe('publish');
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
false,
);
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
'0',
);
expect(
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
).toContain('游戏发布入口灰度');
});
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
gates: [
...configResponse.gates,
{
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
updatedAt: '2026-09-22T10:00:00Z',
},
],
});
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'game-distribution',
]);
fireEvent.click(screen.getByLabelText('启用'));
fireEvent.change(screen.getByLabelText('灰度比例'), {
target: { value: '20' },
});
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
target: { value: 'user-internal' },
});
fireEvent.change(screen.getByLabelText('描述'), {
target: { value: '游戏发布入口灰度' },
});
await user.click(screen.getByRole('button', { name: '保存配置' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
}),
);
});
test('未创建的预设开关在后台可见并可一键配置', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const row = await screen.findByText('game-distribution:publish');
expect(row).not.toBeNull();
// 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。
const configureButton = row.closest('tr')?.querySelector('button');
expect(configureButton).not.toBeNull();
await user.click(configureButton!);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'game-distribution:publish',
);
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
false,
);
});
test('灰度发布页保存时转换数组和百分比', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
@@ -28,6 +28,7 @@ interface GateTargetOption {
const GATE_PREFIX_LABELS: Record<string, string> = {
'image-editor': '画布',
agc: '客户端',
'game-distribution': '游戏分发',
};
const FIXED_GATE_TARGETS: GateTargetOption[] = [
@@ -45,6 +46,14 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
label: 'Agent 侧边栏',
description: '画布 Agent 入口灰度',
},
{
prefix: 'game-distribution',
suffix: 'publish',
key: 'game-distribution:publish',
label: '游戏发布',
description:
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
},
];
export function AdminGrayReleaseConfigPage({
@@ -197,6 +206,11 @@ export function AdminGrayReleaseConfigPage({
setErrorMessage('');
}
// 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。
const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter(
(option) => !gates.some((gate) => gate.gateKey === option.key),
);
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
return {
gateKey: gateKey.trim(),
@@ -443,6 +457,53 @@ export function AdminGrayReleaseConfigPage({
</div>
)}
</section>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{unconfiguredGateTargets.length}</span>
</div>
{unconfiguredGateTargets.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Gate</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{unconfiguredGateTargets.map((option) => (
<tr key={option.key}>
<td>
{option.key}
<small>
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
{option.label}
</small>
</td>
<td>{option.description}</td>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => applyGateTarget(option)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '预设开关都已创建'}
</div>
)}
</section>
</div>
{confirmDialog}
@@ -154,7 +154,6 @@ const allowedUncalledTauriCommands = [
'steer_game_creator_agent_runtime_task',
'write_local_agent_memory',
'write_local_game_memory',
'write_local_project_file',
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
'archive_game_creator_agent_session',
'clear_game_creator_agent_goal',
@@ -517,7 +516,7 @@ function parseTauriHandlerCommandNames(source) {
throw new Error('AI game creator shell Tauri handler list is missing');
}
return Array.from(
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
match[1].matchAll(/\b(?:[a-z][a-z0-9_]*::)*([a-z][a-z0-9_]*)\b/g),
([, command]) => command,
);
}
@@ -550,6 +549,12 @@ function assertCommandNamesDisjoint(label, leftNames, rightNames) {
}
function runAppInvokeParserRegressionChecks() {
assert.deepEqual(
parseTauriHandlerCommandNames(
'tauri::generate_handler![plain_command, analytics::gui::capture_analytics_context,]',
),
['plain_command', 'capture_analytics_context'],
);
assert.deepEqual(
parseAppInvokeCommandNames(`
invoke('direct_command', {});
+15 -8
View File
@@ -125,17 +125,24 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
&& sha256_file(&target_path)
.map(|target_sha256| target_sha256 == source_sha256)
.unwrap_or(false);
let source_permissions = fs::metadata(&source_path)
.expect("读取组件权限失败")
.permissions();
if !target_matches_source {
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
fs::set_permissions(&target_path, source_permissions.clone())
.expect("保留内置 Codex CLI 组件权限失败");
} else if fs::metadata(&target_path)
.expect("读取内置 Codex CLI 资源失败")
.permissions()
!= source_permissions
{
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
// 权限已一致时不再写元数据:Windows 上这次写入会更新 change time
// 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。
fs::set_permissions(&target_path, source_permissions)
.expect("保留内置 Codex CLI 组件权限失败");
}
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
fs::set_permissions(
&target_path,
fs::metadata(&source_path)
.expect("读取组件权限失败")
.permissions(),
)
.expect("保留内置 Codex CLI 组件权限失败");
file_hashes.insert(
relative.to_string(),
serde_json::Value::String(source_sha256),
@@ -34,6 +34,8 @@ mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_error;
mod direct_turn_failure;
mod direct_turn_metrics;
mod direct_turn_stream;
mod direct_validation;
@@ -72,6 +74,8 @@ pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_error::*;
pub(crate) use direct_turn_failure::*;
pub(crate) use direct_turn_metrics::*;
pub(crate) use direct_turn_stream::*;
pub(crate) use direct_validation::DirectValidationConfig;
@@ -1,7 +1,7 @@
//! Native / third-party approval adapter. The host execution session owns policy
//! and persistence; this module only binds the app-server protocol to its leases.
use super::super::{direct_delivery, direct_execution, direct_validation};
use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError};
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
use serde_json::{json, Value};
@@ -147,6 +147,11 @@ pub(super) struct ExecutionAdapter {
changed: Notify,
shutdown_gate: tokio::sync::Mutex<()>,
outcome: watch::Sender<Option<HostOutcome>>,
/// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败,
/// 原因与交付报告同一份文本。
turn_failure: Mutex<Option<DirectTurnError>>,
/// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。
host_stop_requested: AtomicBool,
}
fn identity(value: Option<&Value>) -> Option<&str> {
@@ -263,6 +268,8 @@ impl ExecutionAdapter {
changed: Notify::new(),
shutdown_gate: tokio::sync::Mutex::new(()),
outcome,
turn_failure: Mutex::new(None),
host_stop_requested: AtomicBool::new(false),
})
}
@@ -654,6 +661,7 @@ impl ExecutionAdapter {
}
pub(super) fn cancel_from_host(self: &Arc<Self>) {
self.request_host_stop();
if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) {
return;
}
@@ -672,6 +680,49 @@ impl ExecutionAdapter {
let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await;
}
/// 宿主判定"这一轮以失败收口":记下 `(分类, 原因)`,再把同一条原因写进宿主交付报告。
///
/// 谁调用:宿主亲眼看到或亲手判定的异常收场——执行通道断开(app-server 进程退出 / 流断 / 回合
/// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实,
/// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。
///
/// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合
/// 事件通道上看到的是同一个 `TransportClosed`;判据是 [`Self::is_closed`]——适配器先于连接置位
/// 就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。
///
/// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个
/// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态
/// 判定发生在收束之后;记不下原因,界面就只能看到"本轮已结束"、看不到为什么。
///
/// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由
/// 不得覆盖它。
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
let reason = failure.to_string();
if !self.is_closed() {
if let Ok(mut slot) = self.turn_failure.lock() {
if slot.is_none() {
*slot = Some(failure);
}
}
}
self.interrupt(&reason).await;
}
/// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。
pub(super) fn turn_failure(&self) -> Option<DirectTurnError> {
self.turn_failure.lock().ok().and_then(|slot| slot.clone())
}
/// 记下"用户主动要求终止这一轮"。用来把用户主动终止与 app-server 自己中断分开:
/// 前者不是失败,后者是(判据不能被事件到达的先后顺序左右,所以用标志而不是看阶段)。
pub(super) fn request_host_stop(&self) {
self.host_stop_requested.store(true, Ordering::Release);
}
pub(super) fn host_stop_requested(&self) -> bool {
self.host_stop_requested.load(Ordering::Acquire)
}
pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) {
let adapter = Arc::clone(self);
tokio::spawn(async move {
@@ -744,7 +795,19 @@ impl ExecutionAdapter {
.unwrap_or(true)
}
/// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。
///
/// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到
/// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。
/// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。
fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
// 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由
// `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的
// `Interrupted` 抹成一次没有原因的"已结束"。
match self.session.snapshot().map(|state| state.phase) {
Ok(ExecutionPhase::Completed) => "completed",
Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted",
@@ -1037,6 +1100,10 @@ pub(super) async fn wait_outcome(
#[cfg(test)]
mod tests {
use super::super::DirectTurnDeadline;
/// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。
const EXPECTED_TRANSPORT_KIND: &str = "transport-failed";
use super::*;
fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) {
@@ -1084,6 +1151,57 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn host_observed_failure_is_recorded_with_its_kind_and_reason() {
let (_temp, adapter) = fixture();
assert!(adapter.turn_failure().is_none());
assert!(!adapter.host_stop_requested());
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
})
.await;
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
let failure = adapter.turn_failure().expect("host fact must be recorded");
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
assert!(failure.to_string().contains("SIGKILL"));
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
assert!(adapter.report().contains("SIGKILL"));
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
adapter
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
})
.await;
let failure = adapter.turn_failure().expect("first reason is kept");
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
assert!(failure.to_string().contains("SIGKILL"));
assert!(!failure.to_string().contains("超时"));
}
/// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道
/// 上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。
#[tokio::test]
async fn host_ended_turn_is_not_a_failure() {
let (_temp, adapter) = fixture();
adapter.request_host_stop();
adapter.closed.store(true, Ordering::Release);
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "模型本次执行结束,回收原生后台子树".into(),
})
.await;
assert!(adapter.turn_failure().is_none());
assert!(adapter.host_stop_requested());
// 原因照样进报告:不算失败不等于不用记。
assert!(adapter.report().contains("模型本次执行结束"));
}
#[tokio::test]
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
let (_temp, adapter) = fixture();
@@ -3548,12 +3548,20 @@ impl CodexAppServerConnection {
let direct_turn_user_item_id = direct_persisted_user_item
.as_ref()
.and_then(direct_thread_item_identity);
// 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何
// 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能
// 永远停在"还在跑"。
let mut direct_turn_failure_guard: Option<DirectTurnFailureGuard> = None;
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_started(direct_turn_started_at_ms)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
);
direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm(
direct_thread_id.clone(),
direct_turn_user_item_id.clone(),
));
if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
@@ -3594,8 +3602,11 @@ impl CodexAppServerConnection {
hard_deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
if let Some(adapter) = approval_adapter.as_ref() {
// 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。
adapter
.interrupt("等待模型回合结束达到硬上限,已停止本轮并核对后台操作。")
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::TurnHardLimit,
})
.await;
return execution::outcome_text(adapter.wait_outcome().await);
}
@@ -3623,7 +3634,9 @@ impl CodexAppServerConnection {
Err(_) => {
if let Some(adapter) = approval_adapter.as_ref() {
adapter
.interrupt("等待模型执行回执超时,不能自动重放未确认操作。")
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
})
.await;
return execution::outcome_text(adapter.wait_outcome().await);
}
@@ -3953,9 +3966,16 @@ impl CodexAppServerConnection {
}
"interrupted" => {
if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() {
// app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点
// 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常
// 收场,必须让界面看到原因,不能只是把回合静默收口。
if !adapter.is_host_ending() && !adapter.host_stop_requested() {
adapter
.interrupt("本轮模型执行已中断,正在核对自有后台进程。")
.fail_turn(DirectTurnError::TurnInterrupted {
detail:
"本轮模型执行被中断,正在核对自有后台进程。"
.into(),
})
.await;
}
return execution::outcome_text(adapter.wait_outcome().await);
@@ -3968,14 +3988,23 @@ impl CodexAppServerConnection {
));
}
"failed" => {
// 原生 `turn.error` 是这一轮最准的原因:先把它投影成 `LlmError`
// 再作为 `collect` 的错误结果走既有的 collect_result 通道。投影之后
// 载荷形状(`{kind, message}`)和终态判定都不用为此多一个入参,
// 原因文本里带着 `codex-app-server-error:<kind>` 前缀交给界面归类。
// 交付报告只说明"收束到哪一步",不能顶掉原因;返修请求
// `RepairRequired`)是宿主复核要求,保持它自己的原语义。
let native = game_creator_codex_app_server_failed_turn_error(turn);
if let Some(adapter) = approval_adapter.as_ref() {
if let Some(outcome) =
adapter.finish_model_attempt(&self.inner, false).await
{
return execution::outcome_text(outcome);
if let Err(repair) = execution::outcome_text(outcome) {
return Err(repair);
}
}
}
return Err(game_creator_codex_app_server_failed_turn_error(turn));
return Err(native);
}
status => {
return Err(platform_llm::LlmError::Deserialize(format!(
@@ -3987,8 +4016,13 @@ impl CodexAppServerConnection {
Some(CodexTurnEvent::TransportClosed(error)) => {
if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() {
// 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由
// 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态
// 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。
adapter
.interrupt("执行通道已断开,不能自动重放未确认操作。")
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: error.clone(),
})
.await;
}
return execution::outcome_text(adapter.wait_outcome().await);
@@ -4002,8 +4036,12 @@ impl CodexAppServerConnection {
None => {
if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() {
// 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能
// 以失败收口,不能报成"被中断"。
adapter
.interrupt("执行事件通道已结束,正在核对后台操作。")
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server turn 事件通道已关闭".into(),
})
.await;
}
return execution::outcome_text(adapter.wait_outcome().await);
@@ -4061,11 +4099,33 @@ impl CodexAppServerConnection {
.map(|(_, at)| *at)
.unwrap_or_else(direct_tool_call_now_ms)
};
// 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主
// 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。
// 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能
// 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。
// 判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),不是收尾阶段推出来的
// `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把已经失败的回合讲成"已结束"。
let turn_failure = approval_adapter
.as_ref()
.and_then(|adapter| adapter.turn_failure());
// 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来。
let collect_outcome = match collect_result.as_ref() {
Ok(report) => Ok(report.as_str()),
Err(error) => Err(DirectTurnError::from_model_call(error)),
};
let terminal = direct_turn_terminal(
&status,
collect_outcome,
turn_failure.as_ref(),
history_root,
);
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(status, completed_at)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
terminal.event(completed_at, direct_turn_user_item_id.as_deref()),
);
if let Some(guard) = direct_turn_failure_guard.as_mut() {
guard.disarm();
}
}
let text = collect_result?;
guard.armed = false;
@@ -4964,6 +5024,16 @@ async fn fail_game_creator_codex_app_server_connection(
let stderr = inner.stderr_summary.lock().await.diagnostic();
let diagnostic = format!("{error}exitStatus={exit_status}{stderr}");
app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
// 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束
// 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成
// "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。
record_execution_turn_failure(
&inner,
DirectTurnError::TransportClosed {
diagnostic: diagnostic.clone(),
},
)
.await;
match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await {
Ok(proof) if proof.confirmed() => {}
Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"),
@@ -4971,6 +5041,24 @@ async fn fail_game_creator_codex_app_server_connection(
}
}
/// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server
/// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放)
/// 就是空操作。
async fn record_execution_turn_failure(inner: &Arc<CodexAppServerInner>, failure: DirectTurnError) {
let adapter = {
let slot = match inner.execution.lock() {
Ok(slot) => slot,
Err(_) => return,
};
// 只借一下指针:后面要 await(写交付报告),不能带着执行槽位的锁等。
slot.as_ref().map(Arc::clone)
};
let Some(adapter) = adapter else {
return;
};
adapter.fail_turn(failure).await;
}
async fn shutdown_game_creator_codex_app_server_inner(
inner: &Arc<CodexAppServerInner>,
reason: &str,
@@ -5037,7 +5125,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
) -> Result<String, String> {
) -> Result<String, DirectTurnError> {
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
@@ -5055,7 +5143,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
system_prompt: String,
user_prompt: String,
observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send),
) -> Result<String, String> {
) -> Result<String, DirectTurnError> {
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
@@ -5076,12 +5164,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
) -> Result<String, String> {
) -> Result<String, DirectTurnError> {
// Resolve project authority before deriving the pool/thread identity. A
// caller may hold a stable symlink path whose target changes between
// projects, or replace the project manifest in-place; raw path text alone
// must never select a connection created for the previous project.
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)?;
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)
.map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?;
let codex_root = if let Some(path) = canonical_root
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
@@ -5090,9 +5179,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
} else {
canonical_root.clone()
};
let config = load_game_creator_app_config()?;
game_creator_codex_app_server_validate_llm_config(&config.llm)
.map_err(|error| error.to_string())?;
let config = load_game_creator_app_config()
.map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?;
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
})?;
// 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。
let generated_client_turn_id;
let effective_client_turn_id = match client_turn_id {
@@ -5105,7 +5198,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
.map(|state| state.client_turn_id)
})
.await
.map_err(|_| "宿主 CLI 回合身份读取中断".to_string())??;
.map_err(|_| DirectTurnError::HostStateUnavailable {
detail: "宿主 CLI 回合身份读取中断".to_string(),
})?
.map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?;
Some(generated_client_turn_id.as_str())
}
};
@@ -5125,8 +5221,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
web_search_enabled: config.llm.web_search_enabled,
allow_idle_context_compaction: false,
};
let api_kind =
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| {
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
})?;
let metrics_attempt = audit.as_ref().map(|audit| {
audit.metrics().attempt(
&config.llm.model,
@@ -5156,7 +5255,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish("failed");
}
error.to_string()
// 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
})?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
@@ -5178,7 +5280,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
)
.await
.map(|value| value.text)
.map_err(|error| error.to_string());
.map_err(|error| DirectTurnError::from_model_call(&error));
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish(if result.is_ok() {
"completed"
@@ -203,12 +203,51 @@ pub(in crate::agent) fn game_creator_codex_cli_version_at(
Ok(version.to_string())
}
/// 开发态允许从宿主 PATH 里找到 Codex,但宿主必须持有可锚定的绝对文件:
/// 裸命令名按 PATH 解析成真实路径,否则 `bind_codex_executor` 的 canonicalize 会按 CWD 解析并失败。
/// 发行构建不走这段,候选顺序、校验与返回值都与原先一致(打包环境用内置侧车/npm 绝对路径)。
#[cfg(debug_assertions)]
fn anchor_codex_cli_executable_candidate(
candidate: &Path,
path: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
let is_bare_command_name = candidate
.parent()
.is_some_and(|parent| parent.as_os_str().is_empty());
if !is_bare_command_name {
return candidate.is_file().then(|| candidate.to_path_buf());
}
let name = candidate.as_os_str();
for directory in path.into_iter().flat_map(std::env::split_paths) {
if directory.as_os_str().is_empty() {
continue;
}
let target = directory.join(name);
if target.is_file() {
// 第一个实际命中的项就是 OS 会执行的项;锚定失败时不再从 PATH 里换另一个。
return target.canonicalize().ok();
}
}
None
}
pub(crate) fn game_creator_codex_cli_executable_path() -> Result<PathBuf, String> {
let mut last_error = None;
let mut seen = std::collections::HashSet::new();
let bundled =
game_creator_bundled_codex_cli_path(game_creator_bundled_resource_dir().as_deref());
for candidate in game_creator_codex_cli_executable_candidates() {
#[cfg(debug_assertions)]
let candidate = match anchor_codex_cli_executable_candidate(
&candidate,
std::env::var_os("PATH").as_deref(),
) {
Some(candidate) => candidate,
None => {
last_error = Some("候选执行器不是可锚定的文件".to_string());
continue;
}
};
let identity = candidate.to_string_lossy().to_ascii_lowercase();
if !seen.insert(identity) {
continue;
File diff suppressed because it is too large Load Diff
@@ -697,10 +697,14 @@ pub(super) async fn finish_sealing(
}).await.map_err(|_| "delivery-finalize-worker-exited")?
}
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
///
/// 返修要求是**控制流**[`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。
pub(super) async fn review_reply(
root: &Path,
session: &Arc<ExecutionSession>,
) -> Result<Option<String>, String> {
) -> Result<Option<String>, DirectTurnError> {
if let Some(report) = terminal_report(session) {
return Ok(Some(report));
}
@@ -751,7 +755,9 @@ pub(super) async fn review_reply(
.map_err(|_| "delivery-review-worker-exited")??;
return Ok(Some(report));
}
Err(format!("delivery-review-required: {detail}"))
Err(DirectTurnError::ReviewRequired {
detail: format!("delivery-review-required: {detail}"),
})
}
#[cfg(test)]
@@ -914,10 +920,10 @@ mod tests {
assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0);
let (new_game, _new_host, required) = project_session(true);
for _ in 0..2 {
assert!(review_reply(new_game.path(), &required)
.await
.unwrap_err()
.starts_with("delivery-review-required:"));
assert!(matches!(
review_reply(new_game.path(), &required).await.unwrap_err(),
DirectTurnError::ReviewRequired { .. }
));
}
assert!(review_reply(new_game.path(), &required)
.await

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