Compare commits

...

1 Commits

Author SHA1 Message Date
k88936 8b2d0cb9df 统一错误事件同步留痕到 AppData 应用日志
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m44s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m28s
Project CI / Frontend tests (pull_request) Successful in 4m9s
Project CI / Native shell tests (pull_request) Successful in 8m24s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m37s
- 新增 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
2026-09-21 21:34:45 +08:00
5 changed files with 151 additions and 1 deletions
@@ -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<u64>,
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=<absolute-path> <redacted-url> 第二行诊断"));
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!(
@@ -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-<id>-9.json`(例如 `stage=code-generation code=turn-idle-timeout`)里的诊断正文只落在项目目录,而“报告问题”只上传 AppData `diagnostics/application.log`;用户提交上来的失败消息因此只有一个指向项目文件的引用,团队复现不到 stderr 摘要、退出状态这些真因。
- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/<eventId>.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)会被整体替换成 `<sensitive diagnostic details redacted>`;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。
- 边界:进程内错误报告事件池(`error_report`)与项目内 `.agent/runtime/errors/<eventId>.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` 通过。
@@ -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.24.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` 这类标记,**整行**就被换成 `<sensitive diagnostic details redacted>`,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId``code` 一起消失(2026-09-21 加统一错误事件的日志投影时先按两行落:身份行短小、详情行独立,且自由文本先自行压平换行)。
@@ -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/<eventId>.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 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。
@@ -1831,3 +1831,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
- 用户级配额:跨节点的用户总量配额与计费口径未定;当前用单项目 2 GiB 上限 + 清单引用回收保证常驻占用有界,用户级总量只能靠项目数间接约束。
- 目标 bucket 的生命周期规则(例如转低频/过期删除)需要在部署环境确认后单独收口;功能本身已不再依赖它来控制增长。
- 大项目(素材数量多、单文件大)的首轮全量上传耗时与带宽占用未实测;单次预算 512 MiB 会把超出部分留到下一次同步,但并发上限与断点续传仍未引入。
## 2026-09-21 统一错误事件同时落到 AppData 应用日志
`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/<eventId>.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 失败不能连日志一起丢。