文档:AGC认证错误改为顶层类别加类型化reason

- 更新 AGC 命令错误结构化 ADR:顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 reason 字段,不再拆成几十个顶层变体、也不用字符串
- 更新 AGC 认证失败 JS 侧载体 ADR:带载荷分支先 as 取具名类型,reason 是枚举时再 switch(payload.reason),两处 default 都用 expectNever
- 同步错误报告技术方案、README 索引、决策记录与踩坑的拍平变体口径
This commit is contained in:
2026-10-02 02:42:15 +08:00
parent 3d6eade206
commit 9fe692ba87
6 changed files with 45 additions and 28 deletions
@@ -2,14 +2,14 @@
## 2026-10-01 AGC 命令错误结构化与错误报告口径
- 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端只按 `type` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。
- 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端先按 `type` 选类别、可枚举细分再按类型化 `reason` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。
- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientAuthErrorWrapper` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。
- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 有字段变体的载荷文件)。
- 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。
- 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;可枚举的细分原因是类型化枚举字段(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`),不是字符串、也不各拆一个顶层变体。前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段,`reason` 是枚举时再 `switch (payload.reason)`(`default` 同样用 `expectNever`)。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。
- 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。
- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。
- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀;Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。
- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` / `detail` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。
- 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。
- 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。
@@ -8,7 +8,7 @@
- **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。
- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。
- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。
- **形状约定**:`ClientAuthError` 的变体拍平不嵌套——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的 `serverMessage` / `status` / `detail`,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`。
- **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。
- **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。