From 8b2d0cb9df83b040fb3c29ae62130c730a6106e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 21:34:45 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=90=8C=E6=AD=A5=E7=95=99=E7=97=95=E5=88=B0=20AppDat?= =?UTF-8?q?a=20=E5=BA=94=E7=94=A8=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agent_runtime_error_app_log_lines,把统一错误事件的同一份已脱敏诊断投影成应用日志两行:agent.runtime.error 身份行与 agent.runtime.error.detail 详情行 - 身份行含 eventId/source/stage/code/retryable/clientTurnId/elapsedMs/detailRef/summary,详情行含 hint/detail/metadata;detail 与 metadata 各按 1200/200 字符预算先脱敏再截断,字段仍只来自 sidecar 那份 diagnosis - 拆两行的原因:整行命中凭据标记会被 sanitize_diagnostic_message 整体替换成脱敏占位,详情行被吃掉时身份行仍能定位 eventId 与 detailRef - app_log! 先于 sidecar 写入,sidecar 写失败也留下可提交的诊断;各字段先压平换行,保证应用日志逐行读取且 stderr 输出不拆行 - 新增用例覆盖身份字段、凭据脱敏、单行口径与实际 sanitize_diagnostic_message 落盘边界(含超长诊断) - 同步文件【技术方案】AGC错误报告与诊断上传与【技术方案】AI游戏创作智能体App实施计划,共享记忆 decision-log 与 pitfalls 明确项目内 .agent/runtime/errors sidecar 与进程内错误报告事件池是两套东西,本次只写日志行 - 验证:cargo test runtime_error(7 passed)、cargo fmt --check、npm run check:encoding、git diff --check --- .../src-tauri/src/agent/runtime_error.rs | 132 +++++++++++++++++- .../shared-memory/decision-log.md | 9 ++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...术方案】AGC错误报告与诊断上传-2026-08-31.md | 1 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 + 5 files changed, 151 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 08230226d..3f67baed8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -2,7 +2,8 @@ //! //! Every caller supplies a safe public summary and a private detail. This //! module is the only persistence boundary for the latter: it redacts project -//! paths and credentials before writing a bounded diagnostic sidecar. +//! paths and credentials before writing a bounded diagnostic sidecar, and +//! projects the same bounded diagnosis into the AppData application log. use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes}; use serde::{Deserialize, Serialize}; @@ -14,6 +15,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1"; pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024; +/// 应用日志里 detail / metadata 的字符预算。 +/// +/// `application.log` 的每一行在落盘前还会被 `sanitize_diagnostic_message` 截到 2 KiB, +/// 这里的预算留出身份字段与中文摘要的位置,保证被截掉的是诊断正文的尾部,而不是 +/// `eventId`、`code` 或 `detailRef`。 +pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS: usize = 1_200; +pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200; + static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] @@ -71,6 +80,27 @@ pub(crate) fn persist_agent_runtime_error( "detail": safe_detail, "metadata": metadata, }); + // 统一错误事件的项目内 sidecar 只在项目目录可见:用户提交错误报告时上传的是 AppData + // 应用日志,诊断包拿不到 detail。这里先把同一份已脱敏诊断留进应用日志,再落项目文件, + // 于是 sidecar 写失败也仍然留下可提交的诊断。 + let app_log_lines = agent_runtime_error_app_log_lines( + root, + &event_id, + client_turn_id, + source, + stage, + code, + retryable, + public_text, + recovery_hint, + &detail_ref, + &safe_detail, + elapsed_ms, + &metadata, + ); + for line in app_log_lines { + app_log!("{line}"); + } write_agent_runtime_json_sidecar_with_max_bytes( root, &detail_ref, @@ -96,6 +126,65 @@ pub(crate) fn persist_agent_runtime_error( }) } +/// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。 +/// +/// 传进来的 `detail` 已经是 sidecar 用的脱敏文本;这里只再按应用日志的预算截一次, +/// 让日志与项目内 sidecar 是同一份诊断,不再单独拼一套字段。 +/// +/// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被 +/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位,详情行可能因此消失; +/// 身份行保持短小且只含摘要与引用,保证事件还能被定位。 +/// +/// 单行口径在这里落地:`app_log!` 同时把这行写到 stderr,那里没有人替我们压行, +/// 所以每个字段(含 `source` / `stage` / `code` / `detailRef` 这些调用方给的标识)都先 +/// 过一遍 [`single_line_log_field`],不假设调用方一定给单行文本。 +pub(crate) fn agent_runtime_error_app_log_lines( + root: &Path, + event_id: &str, + client_turn_id: Option<&str>, + source: &str, + stage: &str, + code: &str, + retryable: bool, + public_text: &str, + recovery_hint: &str, + detail_ref: &str, + detail: &str, + elapsed_ms: Option, + metadata: &Value, +) -> [String; 2] { + let event_id = single_line_log_field(event_id); + let client_turn_id = single_line_log_field(client_turn_id.unwrap_or("none")); + let source = single_line_log_field(source); + let stage = single_line_log_field(stage); + let code = single_line_log_field(code); + let detail_ref = single_line_log_field(detail_ref); + let public_text = single_line_log_field(public_text); + let elapsed_ms = elapsed_ms + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()); + let identity = format!( + "agent.runtime.error eventId={event_id} source={source} stage={stage} code={code} retryable={retryable} clientTurnId={client_turn_id} elapsedMs={elapsed_ms} detailRef={detail_ref} summary={public_text}" + ); + let detail = redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS); + let metadata = redact_agent_runtime_error( + root, + &metadata.to_string(), + AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS, + ); + let detail_line = format!( + "agent.runtime.error.detail eventId={event_id} hint={} detail={} metadata={metadata}", + single_line_log_field(recovery_hint), + single_line_log_field(&detail), + ); + [identity, detail_line] +} + +/// 应用日志是逐行读取的:落到日志里的自由文本必须先压平换行。 +fn single_line_log_field(value: &str) -> String { + value.replace(['\r', '\n'], " ") +} + pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str { let normalized = error.to_ascii_lowercase(); if normalized.contains("等待 turn/completed 超时") { @@ -153,6 +242,47 @@ mod tests { assert!(!text.contains("token=secret")); } + #[test] + fn error_event_app_log_lines_keep_identity_and_redact_detail() { + let parent = tempfile::tempdir().expect("temp root"); + let root = parent.path().join("project"); + crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件") + .expect("init project"); + let lines = agent_runtime_error_app_log_lines( + &root, + "error-1-1", + Some("turn-123"), + "direct-codex", + "code-generation", + "turn-idle-timeout", + true, + "本轮没有收到完成事件\n附带换行", + "查看诊断后重试", + ".agent/runtime/errors/error-1-1.json", + "C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断", + Some(1200), + &serde_json::json!({"authorization": "Bearer secret"}), + ); + for line in &lines { + assert!(!line.contains('\n'), "{line}"); + } + // 落盘边界按真实口径核验:应用日志的整行脱敏既不能吃掉身份字段,也不能靠换行拆行。 + let persisted = crate::sanitize_diagnostic_message(&lines.join(" "), None); + assert!(persisted.contains("eventId=error-1-1"), "{persisted}"); + assert!(persisted.contains("code=turn-idle-timeout"), "{persisted}"); + assert!( + persisted.contains("detailRef=.agent/runtime/errors/error-1-1.json"), + "{persisted}" + ); + assert!(persisted.contains("summary=本轮没有收到完成事件 附带换行")); + assert!(persisted.contains("detail= 第二行诊断")); + assert!( + !persisted.contains("token=secret") && !persisted.contains("Bearer secret"), + "{persisted}" + ); + assert!(persisted.chars().count() <= 2_048, "{persisted}"); + } + #[test] fn timeout_and_tool_errors_have_distinct_codes() { assert_eq!( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e708d7af2..fc72b0b57 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9173,3 +9173,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`.../features/resource-canvas/resourceCanvasFocusModel.ts`、`tests/{projectResourceLiveIntegration,resourceCanvasQuickEditDraft,resourceCanvasFloatingDismiss}.test.tsx`、PRD §3.10。 - 验证:定向 `projectResourceLiveIntegration`(32 条,三条断言面板留在失败态的用例按新口径改写为「重开面板再重试,身份不变」)、`resourceCanvasQuickEditDraft`(10 条)、`resourceCanvasFloatingDismiss`(18 条)全绿;`npm --prefix apps/ai-game-creator-shell run typecheck` 通过。 - 合并前复核(2026-09-21):合并 master 后按**仓库根**跑全量 `npx vitest run`,**373 个测试文件全过、4512 通过 / 34 跳过 / 0 失败**;PR #419 显示 `No Conflicts`。真实客户端观感与远程 CI 未复验(后者按用户要求不追,runner/镜像问题见 Issue #431)。 + +## 2026-09-21 统一错误事件额外投影到 AppData 应用日志 + +- 背景:`direct-codex-failure:v2 ... 详情:.agent/runtime/errors/error--9.json`(例如 `stage=code-generation code=turn-idle-timeout`)里的诊断正文只落在项目目录,而“报告问题”只上传 AppData `diagnostics/application.log`;用户提交上来的失败消息因此只有一个指向项目文件的引用,团队复现不到 stderr 摘要、退出状态这些真因。 +- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;detail / metadata 先按应用日志预算(1200 / 200 字符)脱敏截断,自由文本先压平换行(`app_log!` 同时写 stderr,那里没人压行)。 +- 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。 +- 边界:进程内错误报告事件池(`error_report`)与项目内 `.agent/runtime/errors/.json` sidecar 是两套东西——本次只把 sidecar 的同一份诊断作为**应用日志行**落盘,不进事件池、不改报告上传协议、不改 `read_agent_runtime_error_detail` 详情入口。 +- 影响面:只改 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs` 这一处写入边界(覆盖 direct-codex / agc-tools / agent-runtime 三类来源);sidecar schema、对话投影、前端详情入口与错误报告协议均未改动。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error`(7 passed,含新增 `error_event_app_log_lines_keep_identity_and_redact_detail`)与 `-- direct_runtime direct_tool_bridge`(131 passed)全绿;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 14b72578c..5ea414011 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5851,3 +5851,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:① 依赖里只放数据,回调走 ref(`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。 - **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**,renderer 工作集 **254 MB**(修复前 4.2–4.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。 - **关联**:`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`。 + +## 2026-09-21 应用日志整行凭据脱敏会吃掉整条结构化诊断 + +`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时先按两行落:身份行短小、详情行独立,且自由文本先自行压平换行)。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index c077ab88e..d13a14f72 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -13,6 +13,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 +- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,detail 与 metadata 各按应用日志预算截断,不新增字段来源;详情行可能因整行凭据标记被整体脱敏,身份行保证事件仍可定位。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。 - 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5417661f7..8f55a9280 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1831,3 +1831,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 用户级配额:跨节点的用户总量配额与计费口径未定;当前用单项目 2 GiB 上限 + 清单引用回收保证常驻占用有界,用户级总量只能靠项目数间接约束。 - 目标 bucket 的生命周期规则(例如转低频/过期删除)需要在部署环境确认后单独收口;功能本身已不再依赖它来控制增长。 - 大项目(素材数量多、单文件大)的首轮全量上传耗时与带宽占用未实测;单次预算 512 MiB 会把超出部分留到下一次同步,但并发上限与断点续传仍未引入。 + +## 2026-09-21 统一错误事件同时落到 AppData 应用日志 + +`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary`)与 `agent.runtime.error.detail`(详情行:`hint / detail / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 + +口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;`detail` 与 `metadata` 按应用日志预算(1200 / 200 字符)先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位,详情行可能消失,身份行至少要保住事件定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。