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..8f83bfc4d 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,20 @@ 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; + +/// 详情行里 public summary 的字符预算。 +/// +/// `summary` 由调用方给,`direct_tool_bridge` 传的是工具错误原文;这里与 sidecar 侧的摘要 +/// 预算同口径(320 字符)截断,并再脱敏一次,避免摘要把整条详情行占满。 +pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS: usize = 320; + static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] @@ -71,6 +86,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 +132,78 @@ pub(crate) fn persist_agent_runtime_error( }) } +/// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。 +/// +/// 传进来的 `detail` 是 sidecar 用的脱敏文本,`metadata` 则从未脱敏过。两行落到 `app_log!` +/// 时都会先按应用日志预算(1200 / 200 字符)再脱敏、再截断:`app_log!` 还会把同一行写到 +/// stderr,那里没有 `sanitize_diagnostic_message` 兜底,所以每个调用方给的外来文本 +/// (`summary` 按 320 字符预算)都在这里过一遍脱敏。 +/// +/// 已经脱敏过的 `detail` 也照走同一遍流水线,不按「调用方已脱敏」走短路:截断会把 +/// `[redacted-secret]` 这类标记切开,而且这里是 `pub(crate)` 边界,不假设未来调用方一定先脱敏。 +/// +/// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被 +/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位。因此身份行**只放程序生成或 +/// 调用方常量字段**(eventId / source / stage / code / retryable / clientTurnId / elapsedMs / +/// detailRef),`summary`、`hint` 这些自由文本全部放详情行:自由文本里一个裸词 +/// (例如 `credential`)就能让整行被替换,放错了就会把事件定位信息一起吃掉。 +/// +/// 单行口径在这里落地:`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(&redact_agent_runtime_error( + root, + public_text, + AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS, + )); + 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}" + ); + 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={} summary={} detail={} metadata={}", + single_line_log_field(recovery_hint), + single_line_log_field(&public_text), + single_line_log_field(&detail), + single_line_log_field(&metadata), + ); + [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 +261,91 @@ 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"); + // 生产路径传进来的是已经脱敏的 safe_detail(8 KiB 口径),这里按同一口径造输入; + // metadata 在生产里从未脱敏,仍按原文传。 + let safe_detail = redact_agent_runtime_error( + &root, + "C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断", + AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS, + ); + 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", + &safe_detail, + Some(1200), + &serde_json::json!({"authorization": "Bearer secret"}), + ); + for line in &lines { + assert!(!line.contains('\n'), "{line}"); + } + // 落盘边界按真实口径核验:应用日志逐行脱敏、逐行截断,两行不会先拼在一起。 + let persisted = lines + .iter() + .map(|line| crate::sanitize_diagnostic_message(line, None)) + .collect::>(); + for line in &persisted { + assert!(!line.contains("token=secret"), "{line}"); + assert!(line.chars().count() <= 2_048, "{line}"); + } + let identity = &persisted[0]; + assert!(identity.contains("eventId=error-1-1"), "{identity}"); + assert!(identity.contains("code=turn-idle-timeout"), "{identity}"); + assert!( + identity.contains("detailRef=.agent/runtime/errors/error-1-1.json"), + "{identity}" + ); + // 身份行只放程序生成或调用方常量字段:自由文本放错行会让「整行命中标记」把它吃掉。 + assert!(!identity.contains("summary="), "{identity}"); + let detail = &persisted[1]; + assert!( + detail.contains("summary=本轮没有收到完成事件 附带换行"), + "{detail}" + ); + assert!( + detail.contains("detail= 第二行诊断"), + "{detail}" + ); + assert!(!detail.contains("Bearer secret"), "{detail}"); + + // 自由文本里出现裸标记词时,被整行替换的只能是详情行:身份行仍可定位事件。 + let marked = agent_runtime_error_app_log_lines( + &root, + "error-3-1", + None, + "agc-tools", + "tool-execution", + "tool-error", + true, + "credential rotation failed", + "查看项目错误诊断后处理", + ".agent/runtime/errors/error-3-1.json", + &safe_detail, + None, + &serde_json::json!({"tool": "agc_tools"}), + ); + let identity = crate::sanitize_diagnostic_message(&marked[0], None); + assert!(identity.contains("eventId=error-3-1"), "{identity}"); + assert!(identity.contains("code=tool-error"), "{identity}"); + assert_eq!( + crate::sanitize_diagnostic_message(&marked[1], None), + "" + ); + } + #[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 4fa328f1c..5913f4cfb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9294,6 +9294,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:定向 `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)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;落盘前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(`direct_tool_bridge` 会把它当自由文本传工具错误原文,而 `app_log!` 同时写 stderr,那里没有 `sanitize_diagnostic_message` 兜底),自由文本先压平换行。 +- 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。后续复核发现:裸词标记(例如 `credential rotation failed`)脱敏消不掉,summary 留在身份行时仍会连 eventId 一起被替换,因此 summary / hint 等自由文本一律只放详情行,身份行只留程序生成与调用方常量字段。 +- 边界:进程内错误报告事件池(`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` 通过。 + ## 2026-09-21 画布生成面板:重开草稿、重试身份与提交载荷全部收敛到一份 canonical content - 背景:DirectProject composer 收口到 `content[]` 之后,栏目画布的图片类生成浮层仍留着第二套形状——面板里 `prompt` + `references` 两份 state,重开草稿 `ResourceCanvasAssetGenerationPanelDraft` 与重试身份 `boundRequestRef` 也是 `prompt + 参考身份`,`directCodexContentToLegacyContentDto` 因此在面板渲染、关闭草稿、重试判据、宿主入队四个边界各算一遍;同时草稿用「正文 + 引用列表」两条线重建编辑器输入,`@显示名` 与引用 chip 会重复成 `@素材-a@素材-a`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b60e45240..9de45201a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5938,3 +5938,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **原因**:校验器对每个 `input_text` 单独执行 `trim().is_empty()` 并立即拒绝,混淆了结构化片段合法性和整条消息是否有实际内容。 - **处理(现行口径)**:`input_text` 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有任意非文本 part(素材引用 / 运行画面引用 / Skill 引用 / 附件引用)时返回“聊天内容不能为空”。各类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。 + +## 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 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index c077ab88e..74f326266 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,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。 - 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5610729d3..8613a787b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1839,3 +1839,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`)与 `agent.runtime.error.detail`(详情行:`hint / summary / detail / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 + +口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;`summary` 按 320 字符、`detail` 与 `metadata` 按(1200 / 200 字符)预算先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位:所以**自由文本(summary / hint / detail)只放详情行**,身份行只留程序生成与调用方常量字段,详情行被整体脱敏时事件仍能按 eventId / detailRef 定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。