文档:认证失败载体收敛为只持 error 字段的薄包装
- ADR 认证失败载体:ClientActionError 只保留 error 字段,不读 message、不塞 context、message 留空 - 技术方案与决策记录同步载体形状与 error.error 分流 - 排障记忆:判据改为全局 unhandledrejection 显式上下文
This commit is contained in:
@@ -28,9 +28,10 @@ ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generate
|
||||
`payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。
|
||||
|
||||
原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进
|
||||
**已有**的 `ClientActionError`:`context = { source: 'auth', action: 命令名 }`,
|
||||
`cause = 原始拒绝值`(也就是那个判别联合)。`captureClientError` 用 `instanceof` 解包上下文、
|
||||
用 `cause` 取栈,指纹与展示字段都不变;catch 里 `error.cause as ClientAuthError` 直接分流。
|
||||
**已有**的 `ClientActionError`,载体只持有一个 `error` 字段,值就是原始拒绝值(也就是那个
|
||||
判别联合)。它**不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段),
|
||||
不塞 `context`,`Error.message` 留空。上报的 `source` / `action` 由调用 `captureClientError`
|
||||
时的显式入参决定;catch 里 `error.error as ClientAuthError` 直接分流。
|
||||
|
||||
- 19 个具名载荷类型是有意保留的:它们是 §3 每个 `case` 里 `as X` 的目标,也正是"不要假设
|
||||
所有变体字段相同"的落点。改成内联 struct 变体确实能让 ts-rs 把字段内联进联合成员、少掉
|
||||
@@ -47,13 +48,8 @@ async function invokeClientAuth<T>(command, args): Promise<T> {
|
||||
try {
|
||||
return await invoke(command, args);
|
||||
} catch (error) {
|
||||
// 信任 tauri + ts-rs 的映射:认证命令的拒绝就是 ClientAuthError。
|
||||
const failure = error as ClientAuthError;
|
||||
throw new ClientActionError(
|
||||
failure.message,
|
||||
{ source: 'auth', action: command },
|
||||
failure,
|
||||
);
|
||||
// 原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。
|
||||
throw new ClientActionError(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -63,9 +59,9 @@ async function invokeClientAuth<T>(command, args): Promise<T> {
|
||||
静默吞掉——只是不再在包装层替 Tauri 兜底。
|
||||
- `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持
|
||||
原样抛出(`需要在 Tauri App 内登录`)。
|
||||
- `failure.message` 是判别联合上的展示文案:它要求**每个变体都带 `message`**,这是编译期契约
|
||||
(Rust 侧 `ClientAuthError::message()` 有同样的约定),不是"假设所有变体形状相同";分流永远
|
||||
只按 `failure.type`,每个分支各自 `as` 自己的载荷类型。
|
||||
- **不读、不产任何派生值**:不读变体上的 `message`(变体不保证都有这个字段),不注入
|
||||
`source` / `action`,`Error.message` 留空;载体只把原始拒绝值原样放进 `error`。展示文案与
|
||||
上报上下文都由 catch 子句里拿到具名载荷的调用方决定。
|
||||
- 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。
|
||||
|
||||
### 3. 判定只写在 catch 子句里,用具体变体
|
||||
@@ -73,7 +69,7 @@ async function invokeClientAuth<T>(command, args): Promise<T> {
|
||||
```ts
|
||||
catch (error) {
|
||||
if (!(error instanceof ClientActionError)) throw error; // 超时 / 桥未装等我们自己的错误
|
||||
const failure = error.cause as ClientAuthError;
|
||||
const failure = error.error as ClientAuthError;
|
||||
switch (failure.type) {
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload = failure as PhoneNumberInvalid;
|
||||
@@ -110,7 +106,7 @@ catch (error) {
|
||||
`failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。
|
||||
|
||||
- `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由
|
||||
`invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三种
|
||||
`invokeClientAuth` 装进 `ClientActionError`(`error` 是判别联合),不再有第三种
|
||||
"unavailable 投影"。
|
||||
- `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。
|
||||
|
||||
@@ -120,7 +116,9 @@ catch (error) {
|
||||
- `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理
|
||||
(`authoritative: false`),与旧 `failed` 分支语义一致。
|
||||
- 用户可见行为:业务拒绝仍是登录页一行提示;系统失败仍是错误池事件 + 「发现问题」通知。
|
||||
- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`。
|
||||
- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`;
|
||||
系统变体上报的 `source` 就是该 handler 的显式入参(`unhandledrejection`),载体不再携带
|
||||
`action`;结构化拒绝没有 JS `Error.message`,上报文案落回调用方给的默认值。
|
||||
|
||||
## 验收
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthError.ts,clientActionError.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 形状用**每个变体一个具名载荷类型**——Rust 枚举是 newtype 变体持有同名 `#[ts(export)]` 结构体,ts-rs 生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`,前端 `switch (error.type)` 的每个分支直接拿到具名类型。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`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)` 调用;拒绝值装进已有的 `ClientActionError`(`context = { source: 'auth', action: command }`,`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。
|
||||
- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.cause as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包上下文),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。
|
||||
- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientActionError`(载体只有一个 `error` 字段,值就是 ts-rs 生成的 `ClientAuthError` 判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。
|
||||
- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error as ClientAuthError`,每个 `case` 用 `as` 取具名载荷类型;业务 / 会话变体把载荷自带 `message` 原样给用户(无兜底文案),系统变体原样 `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 用例。
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
- **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。
|
||||
- **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。
|
||||
- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝装进已有的 `ClientActionError`(`cause` 是判别联合),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=auth`(`ClientActionError.context.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' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.cause as ClientAuthError; switch (failure.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。
|
||||
- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientActionError`(只有一个 `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' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体)。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error as ClientAuthError; switch (failure.type)`,每个 `case` 用 `as` 取具名载荷类型,`default: expectNever(failure)` 保证漏接变体编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier。
|
||||
- **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`。
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
|
||||
- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientActionError`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【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)。
|
||||
- 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。
|
||||
- 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。
|
||||
- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 把结构化拒绝装进**已有**的 `ClientActionError`(`context.action` 是命令名,`cause` 就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。
|
||||
- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;每个变体持有一个同名载荷结构体,生成 `{ type } & 变体名`,前端每个分支拿到具名类型),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientActionError`(载体只有一个 `error` 字段,值就是 ts-rs 生成的 `ClientAuthError` 判别联合;不读变体 `message`、不塞 `context`、`Error.message` 留空),不新增手写错误类;判定只写在 catch 子句里,每个 `case` 用 `as` 取具名载荷类型,`default` 用 `expectNever` 在编译期挡住漏接变体。
|
||||
- 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 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,全部是程序生成或调用方常量)与 `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` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。
|
||||
|
||||
Reference in New Issue
Block a user