清掉错误类型相关的过时文档

- 删两份 ADR:`AGC命令错误结构化与错误报告口径`、`AGC认证失败的JS侧载体与抛出时机`;错误通道的现行口径只留在代码与该专题技术方案里;
- 删导出面板的【实施计划】与【里程碑】两份计划,落地情况回到技术方案里一句话;
- `docs/README.md` 去掉对上述四份的链接,技术方案里的失败通道段落跟着删;
- `decision-log` 去掉 2026-10-01 那一段,2026-10-05 那段的标题与四条决策改成「同步细节与文案归属」;`pitfalls` 去掉两份 ADR 的引用。
This commit is contained in:
2026-10-06 14:32:16 +08:00
parent 8837fc5cd5
commit af19af0a2f
9 changed files with 9 additions and 449 deletions
+1 -3
View File
@@ -32,7 +32,7 @@
## AI 游戏创作与 Agent Runtime
- [导出产物面板与小红书小工具导出](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-10-05-导出产物面板与小红书小工具导出)、[里程碑](./project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md)与[实施计划](./project-memory/plans/【实施计划】导出产物面板与小红书小工具导出-2026-10-05.md):宿主只校验并运行项目内 `build:xhs-minitool`,适配由 code agent 首次实验固化;`.export/` flat 工作目录、内容 hash 冲突逐字段选择;失败按 typed 变体分流(策略拒绝留面板、宿主故障与未分类拒绝原样抛出进错误池)。已实现并通过本地定向验证,真实 vite 项目上的首轮适配与二次导出待运行时验收。
- [导出产物面板与小红书小工具导出](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-10-05-导出产物面板与小红书小工具导出):宿主只校验并运行项目内 `build:xhs-minitool`,适配由 code agent 首次实验固化;`.export/` flat 工作目录、内容 hash 冲突逐字段选择。已实现并通过本地定向验证,真实 vite 项目上的首轮适配与二次导出待运行时验收。
- [客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md):本地 12 类事件采集、每 15 分钟上传、私有事件表、确认后清理与后台明细查询已完成隔离环境验收;不扩充采集范围、不做加密,未部署生产。配置要求及验证边界见第 13 节。
@@ -59,8 +59,6 @@
- [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。
- [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。
- [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。
- [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。
- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型(可枚举细分再 `switch (payload.reason)` 在类型化枚举上分流),系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。
- [DirectProject 对话滚动与历史自动加载](./adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md):删掉「显示更早的对话」按钮改为自动加载 + 内联加载/错误行,回合 key 冻结锚点前插不跳,底部居中「回到底部 / 有新回复」胶囊,折叠展开按跟随状态贴底或保锚点。
- [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。
- [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。
@@ -1,125 +0,0 @@
# 【ADR】AGC 命令错误结构化与错误报告口径
状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与
[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md))
## 背景
用户在登录页把密码输错一次,报告面板就出现两条"错误事件"并弹出「发现问题」:
```text
登录失败:密码长度需要在 6 到 128 位之间 — auth · 1 次
手机号或密码错误 — auth · 1 次
```
根因不是文案,而是两件事叠加:
1. 登录已经下沉到 Rust(`login_client_with_password` 等命令),命令失败以 `Err(String)` 返回;
Tauri 把 `String` 原样交给 JS,`invoke` 以**裸字符串**拒绝,前端拿到的东西没有任何类型信息。
2. WebView 侧的 `shouldCaptureClientError` 对"非 object"值走默认 `return true`,于是
`handleLoginSubmit` 的 catch 把"用户输错密码"当成缺陷事件报进了错误池。
这与 [`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)
已写明的"预期的 4xx 登录/鉴权失败不进入错误报告池"直接冲突——口径早就定了,是错误通道的实现方式违背了它。
DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) §4):
命令返回结构化 typed error,前端按变体分流,"认不得的变体或非结构化错误"原样抛出走上报链路。
本 ADR 把这套口径推广成 AGC 命令错误的通用约定,并同时收窄错误报告池的入口。
## 决策
### 1. 命令错误按具体变体建模,不按文案匹配
- Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` +
`#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到
`apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。
- **顶层只放调用方要分流的类别,可枚举的细分原因收进类型化 `reason` 字段**——既不拆成几十个顶层变体,
也不用字符串。`reason` 自己也是 `#[derive(Serialize, TS)]` 的枚举。internally tagged 下无字段变体是
`{ type: 'x' }`;newtype 变体是 `{ type: 'x' } & X`,ts-rs 为它生成 `generated/X.ts`。例如服务地址
校验是 `serverAddressRejected` + `ServerAddressReason`(`emptyOrTooLong` / `notAUrl` / `hasCredentials` /
`hasPathOrQueryOrFragment` / `notHttps` / `unsupportedScheme` / `outsideChannel`),网络失败是
`authNetworkFailure` + `AuthNetworkReason`(`timeout` / `unreachable`),响应契约破损是
`authResponseInvalid` + `AuthResponseInvalidReason`(`notJson` / `invalidBody` / `missingRefreshCookie` /
`missingUserIdentity` / `serverRejected`),而不是拆成 7 + 2 + 5 个顶层变体。
- 前端 `switch (error.type)` 必须列全顶层变体:无字段变体直接取本 catch 的固定文案;带载荷变体先 `as`
取自己的具名载荷类型,可枚举的细分再 `switch (payload.reason)` 在**类型化**的 `reason` 上分流(等价于
Java 的嵌套 `switch`,仍不碰文案)。顶层与 `reason` 的 `default` 都用 `expectNever`,漏接变体或漏接
`reason` 都是**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。
- Rust **不预拼用户可见文案**:载荷只装原始事实(类型化的 `reason`、服务端 400 的原文 `serverMessage`、
HTTP 状态码、本机 IO / 网络客户端构建失败的原始 `detail`),服务端没给原文就是 `None`;前缀与句式由
前端调用方在自己的 catch 分支按当前操作拼接。**原始错误必须留在载荷里**(调用方据此分流,报告包据此
诊断),同时另记一行本地日志;但 `detail` **不直接贴在界面上**:`clientSessionPersistFailed` /
`runtimeSessionInstallFailed` / `authClientInitFailed` 三个 catch 用本操作的固定文案,路径等敏感片段
由报告侧的 sanitize 换成占位符。
- `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。
这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), EnqueueError>`),不是新约定。
DirectProject 的三条通道固定为三张类型表:入队拒绝 `EnqueueError`、宿主内部回合错误 `TurnError`
(不导出、不跨进程)、`turn.completed.failure` 载荷 `TurnFailure`;类型随所属深模块命名
(`agent/codex_app_server/turn_error.rs`、`agent/thread_manager/wire/failure.rs`),不再带 `Direct` 前缀;
回合终态判定 `TurnCompletion` 是宿主内部判别联合(`agent/thread_manager/turn_completion.rs`),不导出、不进 `wire/`。
`TurnFailure` 里仍带字符串的两臂专门标成"待清的债":`SuperErrorFromStringPlusStage`(`stage` 是 typed 枚举、
`detail` 仍是产生层字符串)与 `Unclassified`(连阶段都没有),名字故意起丑,`detail` typed 化后即改名;
`TurnError::classify`(返回 `TurnErrorClassified{ShouldStop, ShouldContinue}`)是**唯一**投影点,任何地方都不许再把 typed 失败重包成"阶段失败 + 预拼文案"。
- 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用
`BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`),
不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。
- `429` 同样按路由判定:发码路由是频控(`smsCodeThrottled`),登录路由是「验证码错误次数过多」
(复用 `phoneCodeLoginRejected`,仍是用户可修正的输入问题、不进池),其余路由的 `429` 才落到
`unexpectedRejection`。
- `/api/auth/phone/login` 的 `401` 只来自「用户不存在」(验证码错误/失效/过期都是 `400`),同样归
`phoneCodeLoginRejected`;为此退役的 `smsCodeRejected` 曾把 401 冒充成「验证码错误或过期」,属于错配。
- 本地前置校验只做布尔判定、拿不到更细事实时不编字段:手机号校验 `phone_is_valid` 只回 true/false,
所以 `phoneNumberInvalid` 保持无字段,提示由调用方给固定文案;编不出事实的"细分原因"不硬造。
### 2. 报告池只收"没有任何调用方处理"的错误
谁抛出、谁判定。分层规则:
- **预期业务拒绝**(用户输入、前置条件、预期 4xx):由调用方消化并给用户反馈,**永不进池**。
- **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文交给错误池
(`ClientAuthErrorWrapper` + `captureClientError`);`window.onerror` / `unhandledrejection` 只兜底
没人接手的错误。Rust 侧 agent 终态失败仍由失败投影入池。
- **WebView 全局 handler 是兜底**:任何没人 catch 的错误都进池。
- **408/5xx/网络的判定由调用方在 catch 里做**:AGC shell 的 WebView 侧没有 fetch 边界的自动判定
(`shouldCaptureClientError` 只认测试构造过、生产代码从不产生的 `{status}` / `{networkError}`
形状,随本 ADR 删除);4xx 一律不报,交给上层调用方。
### 3. 前端按变体分流(2026-10-01 修订)
- 本节原先的"调用方用 `isClientAuthError` 读形状、`switch (error.type)` 分流、`default` 交池"已被
[`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](./【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)
取代:`invokeClientAuth` 把结构化拒绝装进 `ClientAuthErrorWrapper`(`error` 字段是判别联合),判定只写在
catch 子句里,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as` 取具名载荷类型。
- "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(failure)`,漏接变体是
**编译错误**,不再是运行时报进池。
- 系统变体与非结构化拒绝仍由调用方原样 `throw`,经全局 `unhandledrejection` 入池;
`captureClientError` 的 `instanceof ClientAuthErrorWrapper` 解包保持不变。
- 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。
### 4. 报告面板与通知行为不变
默认选中快照中的全部事件、只由通知中的「查看并报告」打开、poisoned 快照用 fallback 等承诺保持不变;
本次只保证"不该进池的东西不再进池"。
## 后果与边界
- auth 三命令(`login_client_with_password`、`login_client_with_phone_code`、`send_client_phone_login_code`)
及其共用链路(`request_auth` / `map_auth_failure` / `response_data` / `network_error_message`)全量改为
`Result<_, ClientAuthError>`;`read_client_auth_state`、`refresh_client_auth_session`、
`logout_client_session` 的失败面同步结构化(会话 401/403 仍是"未登录"路径,不是错误)。
- 未识别变体不再靠运行时"上调"兜底:前端 switch 必须列全变体,靠 `expectNever` 在编译期挡住漏接。
- 仍保留的显式采集点(`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic`)
在后续变更里按同一口径重抛/删除,本 ADR 不改它们的行为。
## 验收
```text
npm run ai-game-creator-shell:typecheck
npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings
npm run ai-game-creator-shell:check:rust:shell
npm run check:generated-bindings
npm run check:doc-index
npm run check:encoding
git diff --check
```
@@ -1,158 +0,0 @@
# 【ADR】AGC 认证失败的 JS 侧载体与抛出时机
状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与
[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md))
前置:[`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](./【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md)
已经把 Rust 侧的认证命令失败建模成 `ClientAuthError`,ts-rs 生成
`{ type: 'x' } & X` 的判别联合。本 ADR 只解决这份结构化拒绝到了 WebView 之后
"怎么传、谁来判、什么时候抛"。
## 背景
Rust 已经返回结构化错误,但 Tauri 的 `invoke` 拒绝值是**普通对象**,不是 `Error`:
- 调用方 `catch (error)` 拿到的是 `{ type, reason, ... }`,没有栈。原样 `throw` 它,
上报链路的 `error instanceof Error` 判断会把它降级成 `new Error(String(error))`
(`[object Object]`),文案与类型一起丢掉。
- 前一版在渲染层加了 `isClientAuthError` / `getClientAuthErrorMessage` /
`presentAuthFailure` 三层:形状读取、文案回落、分类提示(均已删除)。它们既不是类型事实源,又在
"取文案"里悄悄承担了"要不要上报"的判断,与"由调用方判定"的口径冲突。
## 决策
### 1. 不新增 JS 错误类型:直接用生成的 `ClientAuthError`
ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generated/ClientAuthError.ts`),
前端只有这一个错误事实源,**不再另建 `ClientAuthFailure` 之类的手写类**——那只会退化成
`payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。
原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进
**已有**的 `ClientAuthErrorWrapper`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个
判别联合),并在构造时把整份载荷 `JSON.stringify` 写进 `Error.message`——上报事件因此拿到的是
机器事实(变体名与载荷),而不是 `[object Object]`。它**不读任何变体字段、不拼用户文案**:
不塞 `context`,展示文案与上报的 `source` / `action` 都由调用 `captureClientError` 时的显式
入参决定;catch 里 `error.error as ClientAuthError` 直接分流。
- 只有带载荷的变体才有具名载荷类型:无字段变体在 ts-rs 里就是 `{ type: 'x' }`,不生成文件;
有字段的变体才生成 `X.ts`(可枚举的细分 `reason` 字段自己也是生成的枚举,如
`ServerAddressReason.ts` / `AuthNetworkReason.ts` / `AuthResponseInvalidReason.ts`)。这些具名类型是 §3
每个带载荷 `case` 里 `as X` 的目标,也正是"不要假设所有变体字段相同"的落点——没有字段可读的变体
不需要、也不允许硬造一个空载荷类型。
### 2. 一个包装函数:`invokeClientAuth`
`clientAuth.ts` 里所有认证命令都经它调用:
```ts
async function invokeClientAuth<T>(command, args): Promise<T> {
const invoke = requireInvoke(); // 认证桥未装:我们自己的失败关闭错误,原样抛出
try {
return await invoke(command, args);
} catch (error) {
// 原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。
throw new ClientAuthErrorWrapper(error);
}
}
```
- **不做运行时形状嗅探**:不再检查 `type` 存不存在。Rust 与 TS 同包发布,形状由 ts-rs 保证;
出现别的形状属于 Tauri / Rust 侧的缺陷,`switch` 的 `default` 分支仍会把它抛出去上报,不会
静默吞掉——只是不再在包装层替 Tauri 兜底。
- `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持
原样抛出(`需要在 Tauri App 内登录`)。
- **不读任何变体字段、不拼用户文案**:`Error.message` 是构造时对整份载荷的序列化,不注入
`source` / `action`;载体把原始拒绝值原样放进 `error`。展示文案与上报上下文都由 catch
子句里拿到具名载荷的调用方决定。Tauri 缺陷抛出的真 `Error` 序列化后只有 `{}`,但上报链路
对真 `Error` 优先用其自身 message/stack。
- 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。
### 3. 判定只写在 catch 子句里,用具体变体
```ts
catch (error) {
if (!(error instanceof ClientAuthErrorWrapper)) throw error; // 超时 / 桥未装等我们自己的错误
const failure = error.error as ClientAuthError;
switch (failure.type) {
case 'phoneNumberInvalid': {
// 无字段变体:文案由本 catch 给,不读任何字段。
setLoginStatus('手机号无效: 需为纯数字且不超过 32 位');
break;
}
case 'passwordLoginRejected': {
const payload = failure as PasswordLoginRejected;
// 前缀由本 catch 按当前操作提供;Rust 只给服务端原文(可能为 null)。
setLoginStatus(`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`);
break;
}
// ... 每个业务 / 会话变体一个分支
case 'authNetworkFailure': {
const payload = failure as AuthNetworkFailure;
// 可枚举的细分在类型化 reason 上再分流,仍然不碰文案。
switch (payload.reason) {
case 'timeout':
case 'unreachable':
break;
default:
expectNever(payload.reason);
}
throw error;
}
// ... 其余系统变体逐个列出后原样抛出
default:
expectNever(failure);
}
}
```
- **每个带载荷的业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,再读它自己的字段;字段是可枚举的
细分 `reason` 时,再 `switch (payload.reason)` 在类型化枚举上分流。无字段的 `case` 直接用本 catch 的
固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自当前 catch 的操作语义(登录、发码、
启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体先按载荷里的原始
事实(`reason` / `status` / `serverMessage`)给一行可见反馈,再原样抛出;带 `detail` 的本机失败是例外,
只用固定文案,`detail` 只用于分流与诊断、不贴到界面上——变体名必须逐个列出,`default` 的
`expectNever` 才成立。
- 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给类型化 `reason` /
`serverMessage` / `status`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如
「服务器地址非法: 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。
- 系统变体:调用方处理不了,先给一行可见反馈(载荷原始事实,不建兜底文案层),再**原样 `throw`**。
`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局
handler 交给错误池。
- `default: expectNever(failure)`(`expectNever(value: never)`)让"Rust 加了变体而这里
没接"变成**编译错误**。这是上一版"未识别变体上调是故意的"的替代方案:判据从运行时前移到
编译期。
- 不把这段 switch 抽成 presenter / helper 函数:判定必须发生在 catch 里,包装函数只负责
"把结构化拒绝转成 JS 错误"。
### 4. Rust 侧失败不再降级成字符串
结构化必须一路到底:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,
`ClientAuthStateView` / `ClientAuthRefreshView` 不再有 `errorMessage` 字段,续期结果不再有
`failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。
- `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由
`invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三种
"unavailable 投影"。
- `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。
## 后果与边界
- 新增认证命令或新增 `ClientAuthError` 变体,必须同时改所有 catch 的 switch,否则 `tsc` 失败。
- `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理
(`authoritative: false`),与旧 `failed` 分支语义一致。
- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`;
系统变体上报的 `source` 就是该 handler 的显式入参(`unhandledrejection`),载体不再携带
`action`;结构化拒绝的 `Error.message` 是构造载体时生成的载荷序列化,`captureClientError`
直接把它当作事件 `message`,事件指纹因此按变体区分,报告面板呈现的是机器事实。
## 验收
```text
npm run ai-game-creator-shell:typecheck
npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx apps/ai-game-creator-shell/tests/clientAuthHost.test.ts apps/ai-game-creator-shell/tests/platformSession.test.ts
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error
npm run check:generated-bindings
npm run check:doc-index
npm run check:encoding
git diff --check
```
@@ -1,75 +0,0 @@
# 【实施计划】导出产物面板与小红书小工具导出
| 字段 | 值 |
| --- | --- |
| Milestone | [导出产物面板与小红书小工具导出](./【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md) |
| Status | implemented-awaiting-runtime-acceptance(代码与定向验证已落,真实 vite 项目首轮适配待验) |
| Owner | Codex |
## 修改边界
- Rust 模块:`src-tauri/src/export/mod.rs`(`EXPORT_WORK_RELATIVE_DIR` 与共用注册表读写、规范序列化 hash 助手)、`src-tauri/src/export/draft/mod.rs`、`src-tauri/src/export/draft/xhs_minitool/mod.rs`(表单 / 状态 / 结果 / 错误 DTO 与常量)、`src-tauri/src/export/draft/xhs_minitool/commands.rs`(小红书专属 `#[tauri::command]`)。
- 现存草稿的归位:`export/command/`(空 `mod.rs`)是上一轮「共享命令目录」方案的残留,最终落点是 per-target `commands.rs`,实现时删除该目录与 `mod.rs` 里的 `pub mod command;`。同一轮把草稿常量改名落定:`xhx-minitool.zip` → `xhs-minitool.zip`、`xhx-minitool.json` → `xhs-minitool.json`、`build-xhx-minitool` → `build:xhs-minitool`;表单字段随 `#[serde(rename_all = "camelCase")]` 在 JSON / TS 侧为 `iconPath`(Rust 侧仍是 `icon_path`)。
- Rust 命令(4 个,全部 per-target):`read_xhs_minitool_export` → `XHSMiniToolExportState { form, hasScript }`(内容,自动刷新反复调);`read_xhs_minitool_export_hash` → 注册表指纹字符串(写回时的 `baseHash`,只在内容被采纳时取一次);`save_xhs_minitool_export_form(project_path, form, base_hash)` → 写回后的新指纹;`run_xhs_minitool_export_build(project_path)` → `XHSMiniToolExportRunResult { outputTail, omittedCharacters }`(构建输出尾部**原文**与省略量两个事实,stdio 已合并;成功出口的退出码必然是 0,因此不带 `exitCode`,失败侧由 `CommandFailed.exitCode` 承担)。失败走 typed error `XHSMiniToolExportError`(ts-rs 导出):`buildScriptMissing` / `formInvalid` / `registryMalformed` / `saveConflict` / `commandDenied` / `commandFailed` / `artifactMissing` / `exportUnavailable`,前七类是预期拒绝,`exportUnavailable` 是宿主侧事实故障——两者在调用方要做的判断不同(留在面板 vs 原样抛出进错误池)。不新增 `GAME_CREATION_APP_COMMANDS` 条目,走 `command.exec` 的 typed `enforce_project_permission_policy_rejection`(不用那版拼好人话的 `enforce_project_permission_policy`,否则调用方分不出「策略拒绝」与「策略读不出来」)。内容与指纹分两条命令是刻意的:刷新这条高频路径在类型上就动不了写回基线。
- 复用而非新造:运行脚本走 `command_exec` 的 `resolve_project_command_spec_at → prepare/stage/spawn`(或 `run_project_verification_with_commit_at`),cwd 沿用 `project/export.rs` 的 `resolve_publish_build_cwd` 口径;icon 与 zip 下载复用 `save_local_project_asset_file`;不拿项目写锁、不推进 revision。
- 前端:`src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx`(表单 + 产物 + 冲突/失败卡片,只有「适配 / 导出」两颗显式按钮)、新增 `state/useXhsMinitoolExport.ts`(状态、防抖自动保存、按固定间隔自动重读内容;指纹只在 hook 内部的写回基线里,不进对外状态)、新增 `state/xhsMinitoolInstruction.ts`(适配与修复指令的纯函数 + 契约常量)、新增 `state/{xhsMinitoolApi,xhsMinitoolFailure,xhsMinitoolFields,xhsMinitoolDownload,xhsMinitoolOutputTail}.ts`、新增 `src/view/project-development/export/generated/`(ts-rs 产物)。宿主不预拼用户可见文案:输出尾部的「已省略前 N 个字符」与策略拒绝那句话都在前端拼(`xhsMinitoolOutputTail.ts` / `xhsMinitoolFailure.ts`)。失败分流按 ADR 两道口子:预期拒绝留在面板,宿主侧事实故障与认不出形状的拒绝先给现场再原样抛出(按指纹去重,面板 2 秒一轮重读不会把报告池的 `count` 刷成轮询次数)。
- 快照排除:`src-tauri/src/project/filesystem.rs` 新增 `PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS = [".export"]`;**不能**并进通用排除列表(`agent/direct_patch.rs` 直接拿它拒绝路径,`agc_apply_patch` 会拒 `.export/`,首次适配无法落地)。
- skill:`resources/agc-skills/vite-export-xhs-minitool/scripts/pack.mjs`(新增 `--zip-out <path>`,相对 cwd 解析、父目录自动创建;原 `--zip <name>` 行为不变;`--out-dir` 更名为 `--vite-built-dir`,并把原来的「就地删除白名单外文件」改成打包前预检报错、绝不动磁盘)、`scripts/vite.config.xhs-minitool.mjs` 注释点明 `build.outDir` 与 `--vite-built-dir` 必须一致、`SKILL.md` 补「被 AGC 导出面板调用时」三条硬契约;同步 `manifest.json` 指纹与 `skill_pack.rs` 的 `include_bytes!` 内容(文件数不变,仍 36 条)。
- 文档:主规范一节、本里程碑与实施计划、`docs/README.md` 入口、`docs/project-memory/shared-memory/decision-log.md` 一条。
- 契约常量的单一来源:Rust `export/mod.rs` 的 `EXPORT_WORK_RELATIVE_DIR` 与 `draft/xhs_minitool/layout.rs` 是权威,前端 `state/xhsMinitoolInstruction.ts` 只为插值留一份副本;这份副本由 `tests/xhsMinitoolContract.test.ts`(读上面两个 `.rs`)逐条钉住,两边任一侧改名都会红。前端指令用例只钉「正文有没有把契约写进去」,不再抄一份字面量。
- 明确不动:`GAME_CREATION_APP_COMMANDS`、`shared-contracts`、`server-rs`、SpacetimeDB、OpenAPI、`api-server`,以及现有 `project/export.rs` 的发布链路。
## 实现顺序
1. Rust 基础:`export/mod.rs` 落 `EXPORT_WORK_RELATIVE_DIR`、注册表读写与规范序列化 hash;`xhs_minitool/mod.rs` 落 DTO(`#[serde(rename_all = "camelCase")]` + `#[ts(export, export_to = ...)]`)、常量与校验(首个错误即返回)。
2. Rust 命令:`commands.rs` 三个命令。`read` 自动建空注册表;`save` 带 `base_hash` 做冲突判定;`run` 复用 `command_exec` 边界并解析 cwd,每次重新产出 zip。
3. 前端:`state/xhsMinitoolInstruction.ts` 落指令正文与契约常量;`state/useXhsMinitoolExport.ts` 持状态与防抖自动保存;`ArtifactsPane.tsx` 落表单、复制、下载与字段级冲突面板;三颗显式按钮接线。
4. skill:`pack.mjs` 新增 `--zip-out`、vite config 与 `SKILL.md` 写清 `.export/` 落点契约;`npm run agc:skill-pack:sync` 重算指纹并提版本。
5. 快照排除:`.export/` 进 `PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS` 并补两侧口径的定向用例。
6. 定向测试与真实项目首轮适配验证;回写主规范证据与未验证项。
## 验证命令与操作
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell export::`
- 真实起进程跑宿主链路(默认 `#[ignore]`,要本机 node/npm 与可用命令沙箱):`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- --ignored executes_the_project_script`
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`,随后 `npm run check:generated-bindings`
- `npx vitest run apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx`
- `npm run ai-game-creator-shell:typecheck`
- `npm run agc:skill-pack:sync` → `npm run agc:skill-pack:check`
- `node --test apps/ai-game-creator-shell/src-tauri/resources/agc-skills/vite-export-xhs-minitool/scripts/.pack.test.mjs`、同目录 `.validate.test.mjs`
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`
- 真实运行时:打开一个 vite 项目 → 点一键适配 → agent 跑通后不用点刷新(2 秒内导出按钮自己亮起来)→ 点导出 → 确认 `.export/xhs-minitool.zip` 可下载、内容可交给平台;再次导出确认零 agent 调用;手改坏注册表、删掉脚本各验证一次 rich error 文案。
## 风险与回滚点
- `pack.mjs` 在打包前预检 `--vite-built-dir`:白名单外的扩展名直接报错退出,不再就地删除;prompt 与 skill 都要求把它指向 vite 产物目录并与 `build.outDir` 一致,配错只会失败,不会误删项目源码。
- `.export/` 排除出快照同步后适配成果不跟随恢复;prompt 与面板文案要说清「换机或恢复后需重新适配」,避免用户误以为已持久化。同机 checkpoint 仍含 `.export/`,是恢复适配脚本的兜底。
- `.export/` 一旦被并进通用排除口径,`agc_apply_patch` 会以「不得修改受保护或排除路径」拒绝它,首次适配直接卡死;两侧口径差别由 `project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials` 钉住。
- agent 写注册表是软约束(只靠 prompt 要求);宿主严格解析必须 fail closed,解析失败一律给 rich error 并引导重新适配,不做猜测性合并。
- skill 指纹:改 `pack.mjs` / vite config 会让 `skill_pack.rs` 的 `include_bytes!` 内容与 manifest 不一致,必须同一次变更里跑 sync 并提版本,否则 release build 直接失败关闭。
- 回滚点:三个命令与前端 tab 以新增文件为主(`main.rs` 仅多 `pub mod export;`),可整体摘除;skill 改动可回滚到上一版指纹。
## 当前状态
代码与文档已实现,提交按「Rust 基础 → Rust 命令 → 前端行为层 → 前端视图 → 前端测试 → skill → 快照排除」拆分:
- `91741f9de` 共享注册表与 export 模块骨架;`2a70926a3` 小红书 DTO / 错误 / 布局 / 校验与 ts-rs 绑定;`b6bef5b10` 生成目录加入 prettier 忽略;`3a1999e11` 三个命令 + 构建 + desktop 接线。
- `d694c183d` 前端行为层(命令入口、指令正文、失败说法、字段元数据、下载、状态 hook);`9a8d77dd9` 面板左半(表单 / 冲突 / 失败卡片与三颗按钮);`f8517abaa` 前端契约测试(10 例)。
- `95323ce54` skill 的 `--zip-out` 与 AGC 接入契约(manifest `2026-08-26.41`);`3369f76b1` `.export` 快照同步排除;`faa93929b` 文档回写。
- `5e2426c8a` 导出收尾判据抽成 `conclude(...)` 并补 5 例确定性用例(非零退出 / 超时 / 产物缺失 / 空产物 / 成功),同时去掉 `XHSMiniToolExportRunResult.exitCode` 这个恒为 0 的死字段;`463341c4b` 导出成功后把脚本输出尾部报给用户;`b3be3ee71` 证据口径修正;`5dd158ee1` 补默认 ignore 的真实起进程用例。
- `1b7741ac6` 适配与修复指令补上「包根在 game/ 时落点写成 `../.export/xhs-minitool.zip`」,skill 契约同步(manifest `2026-08-26.42`);`f8ed4e898` 保存对话框的默认文件名收进下载助手并复用既有取文件名工具;`90f651ddf` 刷新时先撤掉待触发的自动保存(附回归用例;该行为已被下面的自动刷新取代——轮询不再撤掉待保存的输入,因为「磁盘为准」的刷新动作没有了)。
- `ac666daad` 之后:未适配提示改成带入队按钮的卡片、失败卡片按变体分派适配/修/重试、提示只在注册表读回来之后出现(同一时刻只有一张卡片、一个入口)。
- 导出面板的用例去掉恒真的存在性断言(`getBy*` 本来就抛,不再包 `expect(...).not.toBeNull()`;等待出现改用 `findBy*`,能写成行为断言的就点一下按钮看回调),并把这条口径写进 team-conventions。
- layout 的用例重写:原来那条 `assert_eq!(artifact_relative_path(), format!(...))` 只是把实现抄了一遍,现改成 4 条只钉关系的用例(相对路径与宿主 `PathBuf` 指向同一文件、不许跑出 `.export/` 且不出现反斜杠、注册表与产物共用一个项目根锚定的 flat 目录、脚本名保住 `build:<后缀>` 形式);逐条反证过会红,`export::` 50 → 53 例。
- `dffe4de55` 导出链路按 ADR 收口(策略拒绝与宿主故障拆成两个变体、输出尾部只回原文与省略量);`df3a6826b` 前端失败通道按 ADR 分流(宿主故障与未分类拒绝原样抛出并去重)与复制失败可见反馈。
- 复制按钮改用 Tauri 剪贴板插件(原 `navigator.clipboard` 在 WebView 里可能根本没有,失败还会静默吞掉),失败态在按钮上可见。
- 指令正文里的契约字符串全部从常量组合(`XHS_MINITOOL_EXPORT_DIR_RELATIVE_PATH` + 三个脚本落点),用例的期望值也从常量推;`9d3018a49` / `6dd0dc00c` 之后:去掉刷新按钮,改成按固定间隔自动重读内容;内容与注册表指纹拆成两条命令(自动刷新拿不到写回基线);同步细节(指纹、保存中、待保存)退出前端对外状态与界面。
已验证:Rust `export::` 53 例(`export::draft::xhs_minitool` 37 例)、`export_bindings` 116 例、`project_snapshot` 22 例、`check:generated-bindings`(118 个文件)、前端 vitest 21 例(新增 19 + 契约 2)、`ai-game-creator-shell:typecheck`、`ai-game-creator-shell:check:rust:shell`(本机 4 个 shard 都跑到了底,唯一失败是 4 个 `process_session` 真 PTY 用例——与下面的已知环境噪声同一批:沙箱里的 npm 由另一个 node 版本执行、`Cannot find module '../lib/cli.js'`;本次改动涉及的 `export::` 与 `project_snapshot` 全绿)、`agc:skill-pack:check`、`check:doc-index`、`check:encoding`、`git diff --check`。
未验证(唯一开口项):真实 vite 项目上的首轮适配闭环——agent 跑通 `build:xhs-minitool`、产出可上传 zip、二次导出零 agent 调用;以及 `.export/` 换机后重新适配的体感。宿主侧这一段已尽力自动化:`build.rs` 的 `executes_the_project_script_and_requires_a_non_empty_artifact_each_time`(`#[ignore]`)会真起进程执行项目脚本,覆盖「第一次产出 → 删掉产物再跑一次重新产出 → 脚本成功但没产出报 artifactMissing」。本机尝试运行被容器环境挡下,读日志时注意两点、别误判成 bug:
- 子进程输出里的 `running 1 test` 是**预期现象**:命令沙箱的垫片就是当前可执行文件本身(`desktop.rs` 按 `--command-sandbox-trampoline` 分流),编译到测试态时 `sandbox_trampoline_arguments()` 会改成 `--exact ...trampoline_child_fixture --ignored`,所以垫片进程是测试二进制在跑那一个 fixture 用例。
- 真正失败的是沙箱里的 `npm`:解析到的是 fnm 包装脚本、却由另一个 node 版本执行(`Cannot find module '../lib/cli.js'`,`Node.js v26.10.0`)。同一脚本在普通 shell 里 `npm run build:xhs-minitool` 正常产出 zip,因此不是代码问题。
该用例留给有健康沙箱与 node 环境的机器一键复跑。
已知环境噪声:本机全量 `cargo test --bin` 有 12 例与本变更无关的失败(`process_session` / `command_sandbox_trampoline` / `runner` 的 pty 与 GUI 锁、`resource_editor` 的外部 HTTP 连接被拒),失败用例均不经过本次改动的路径。
@@ -1,60 +0,0 @@
# 【里程碑】导出产物面板与小红书小工具导出
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented-awaiting-runtime-acceptance(代码与定向验证已落,真实 vite 项目首轮适配待验) |
| Date | 2026-10-05 |
| Parent Spec | [导出产物面板与小红书小工具导出](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-10-05-导出产物面板与小红书小工具导出) Version 1.0 |
## 目标
把「导出到第三方平台」收敛成一条可复用链路:首次由 code agent 在用户项目里实验出能产出平台制品的脚本,之后每次导出由宿主直接运行该脚本产出 zip,用户从面板复制平台上传表单字段并下载 icon 与 zip 自行上传。本里程碑只交付小红书小工具(vite 项目)一个目标。
## 范围
- Rust 新增 `export` 模块与小红书专属命令:注册表自动创建与严格解析、表单校验、脚本存在判定、运行 `build:xhs-minitool`、返回产物路径与失败输出。
- 注册表 `.export/xhs-minitool.json`(首次自动创建且字段全空)与 flat 工作目录 `.export/`;共享 icon 放 `.export/` 根,多目标可指同一文件。
- 前端导出面板小红书 tab:表单展示/编辑/复制、icon 与 zip 下载、显式「适配 / 导出」两颗按钮(内容与脚本判据自动重读,没有刷新按钮)、冲突时逐字段二选一。
- 前端组装首次适配指令,经会话写权限门入队到项目主 Direct 会话。
- `vite-export-xhs-minitool` skill 的脚本与示例配置按 `.export/` 布局适配(`pack.mjs` 新增 `--zip-out <path>` 指定确定落点、`--out-dir` 更名为 `--vite-built-dir` 并在打包前预检该目录,vite config 示例注释点明 `build.outDir` 与它必须一致)。
- 主规范一节、本里程碑、实施计划与决策记录同步。
## 不在范围内
- 平台自动上传(小红书不提供 API)、非 vite 项目、第二个导出目标、宿主侧 zip 结构校验与平台规范复刻、PNG/JPG 之外的 icon 格式转换(留 TODO)。
- `GAME_CREATION_APP_COMMANDS`、`shared-contracts`、SpacetimeDB schema、OpenAPI、`api-server` 的任何改动;不新增权限命令、不推进全局 revision、不拿项目写锁。
- `.export/` 进入项目快照或发布包的兼容;`.export/` 排除在快照同步之外(不进通用排除口径,否则 Agent 的 `agc_apply_patch` 写不了它),恢复或换机后需重新适配。
- 导出进度流式推送、自动轮询或自动重试。
## 依赖与前置条件
- 用户已确认主规范与本里程碑。
- `vite-export-xhs-minitool` skill 随包(`agc-skill-pack.v1` 十项之一),agent 可经 `agc_read_skill_resource` 读取 `scripts/*.mjs`。
- 目标项目是 npm + vite 项目(`game/package.json`,`scripts.build` 为 `vite build`)。
- `command.exec` 的 npm 参数白名单放行 `build:*`;脚本 cwd 解析可沿用 `resolve_publish_build_cwd` 的口径。
## 验收标准
- [x] 首次打开小红书 tab,`.export/xhs-minitool.json` 自动生成且 `name` / `introduction` / `iconPath` 全为空字符串;注册表损坏或字段不合法时给字段级错误,不静默吞掉。
- [x] `name` / `introduction` 为空或超 14 个 Unicode 字符、icon 不存在/越界/符号链接/扩展名不在 `png|jpg|jpeg`/超 5 MiB 时被拒,首个错误即返回。
- [x] `hasScript` 只由 npm 包 `package.json` 的 `scripts["build:xhs-minitool"]` 现算;注册表内没有 status、没有「已适配」、没有经验文本字段。
- [x] 点「导出」每次都运行脚本并重新产出 `.export/xhs-minitool.zip`;脚本缺失、运行非零退出(带 exit code 与输出尾部,stdout 与 stderr 已合并)、产物缺失各有独立 rich error。输出尾部只装原文与省略量两个事实,漏掉多少字那句话由前端拼。
- [x] 表单字段可复制;icon 与 zip 经原生保存对话框落盘;复制与下载不触发任何 agent 调用。
- [x] agent 改过注册表后,用户保存旧值触发字段级冲突面板,可逐字段选「我的 / 文件里的」,纯格式化改动静默吸收,任何情况下不静默覆盖用户输入。
- [x] 一键适配只入队一条指令且必须过会话写权限门;被拒时不落消息、不改注册表。
- [x] 全链路不自动跑构建、不自动叫 agent(内容与脚本判据按固定间隔自动重读);`.export/` 不进发布包、不进快照。
- [x] 失败按 ADR 的 typed 变体分流:权限策略拒绝(`commandDenied`)留在面板并给固定话术;宿主侧事实故障与认不出形状的拒绝先给现场、再原样抛出进错误池;面板每 2 秒重读,同一个故障只抛一次。
- [x] ts-rs 绑定生成到前端目录且 `npm run check:generated-bindings` 通过。
## 证据要求
- 自动化(已落):
- Rust `cargo test ... --bin genarrative-ai-game-creator-shell export::` 53 例,其中 `export::draft::xhs_minitool` 37 例:注册表自动建空表单、格式漂移静默吸收、严格解析拒绝、表单首错、icon 越界/符号链接/扩展名、`hasScript` 现算、`contentHash` 冲突、产物路径守卫,以及构建收尾判据的逐条映射(脚本缺失、非零退出带退出码与输出尾部、超时无退出码、产物缺失、空产物不算、成功回输出尾部、截断只回省略量不回句子;layout 的 4 例只钉关系不抄实现——前端字符串相对路径与宿主 `PathBuf` 必须指向同一文件、相对路径不许跑出 `.export/`(含 Windows 反斜杠)、注册表与产物共用一个项目根锚定的 flat 目录、脚本名保住 `build:<后缀>` 形式且不许退化成裸 `build`)。
- `export_bindings` 116 例 + `npm run check:generated-bindings`(118 个生成文件)通过。
- 前端 `npx vitest run tests/xhsMinitoolExport.test.tsx`(19 例)与 `tests/xhsMinitoolContract.test.ts`(2 例,把前端契约常量的值逐条对到 Rust `layout.rs` / `export/mod.rs`)通过;面板外壳原先那份 `tests/artifactsPanel.test.tsx`(4 例)整份删除——三个目标的列表与切 tab 后的文案都是存在性断言,关闭按钮只是把 `onClose` 转发一次,而它唯一有内容的「挂载的是主聊天同一个 `.project-chat-surface`」已被 `chatDialogFrameLayout.test.ts` 覆盖;`npm run ai-game-creator-shell:typecheck` 通过。其中 19 例覆盖(期望值一律从契约常量推,不另抄字面量):指令正文三条硬契约与 game/ 落点、失败现场拼装、失败变体人话映射与三向 action 分派(适配 / 修 / 重试)、未适配卡片自带入队按钮且按钮行不重复、注册表读回来之前不下「还没适配」结论、表单复制、icon 与 zip 下载、冲突逐字段二选一、自动保存带回读到的 `baseHash`、自动重读只更新脚本判据而不动用户未保存的表单、agent 事后改注册表/加脚本不用点刷新就能带回来、导出把脚本输出尾部报给用户、失败变体的产出量与前端拼出的省略说明、认不出形状的拒绝原样抛出且不落成业务提示、宿主侧事实故障先给卡片再抛一次且同一故障只抛一次,复制失败在按钮上可见(不静默吞掉),以及「只有适配/修复才叫 agent」。
- `.export/` 快照排除有定向用例:`project_snapshot::tests::project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials` 同时钉住「同步排除」与「通用口径不排除(Agent 仍可写)」。
- `npm run agc:skill-pack:sync` → `agc:skill-pack:check` 通过(`manifest.json` 版本 `2026-08-26.43`,文件数不变,仍 36 条)。
- `pack.mjs` 的 no-delete 预检有定向用例(`scripts/.pack.test.mjs` 5 例):`findUnsupportedFiles` 只报白名单外扩展名、存在不支持文件时整体失败且不删任何文件/不写 zip、干净目录仍能打包、CLI 拒绝旧名 `--out-dir` 不静默退回默认目录。
- 运行时(**未验证**,本里程碑唯一开口项):在真实 vite 项目上由 agent 完成首轮适配、产出可人工上传的 zip、二次导出零 agent 调用;`.export/` 换机后需重新适配的体感也需一并确认。宿主侧这一段已有默认 `#[ignore]` 的真起进程用例(`executes_the_project_script_and_requires_a_non_empty_artifact_each_time`:第一次产出 → 删掉产物重跑必须重新产出 → 脚本成功但没产出报 artifactMissing),一键复跑命令见实施计划;本次尝试被容器环境的 npm/沙箱解析挡下,不是代码问题。
- 边界(已覆盖):注册表损坏、脚本缺失、脚本非零退出、产物缺失、icon 越界与符号链接、目录遍历拒绝。
@@ -9,21 +9,19 @@
- 决策(工作目录):`EXPORT_WORK_RELATIVE_DIR = ".export"` 锚在项目根并保持 flat;共享 icon 放 `.export/` 根,注册表存项目内相对路径,多目标复用同一文件;agent 拷贝的脚本按 `<target>` 前缀命名避免第二目标撞名。`.export/` **只**排除在项目快照同步之外(`PROJECT_SNAPSHOT_SYNC_ONLY_EXCLUDED_COMPONENTS`),恢复或换机后需重新适配(已知代价);**不得**并进通用排除口径——`agent/direct_patch.rs` 直接拿它拒绝路径,`agc_apply_patch` 会以「不得修改受保护或排除路径」拒绝 `.export/`,首次适配就落不了地;同机 checkpoint 仍含 `.export/`,可作恢复适配脚本的兜底。
- 决策(skill 落点):`pack.mjs` 新增 `--zip-out <path>`(相对 cwd 解析、父目录自动创建),让「产物必须落在项目根 `.export/xhs-minitool.zip`」有确定写法;`--out-dir` 只允许指向 vite 构建输出目录(它会就地删掉该目录内非白名单扩展名的文件)。宿主只认结果:脚本名 + 非空产物路径,产物结构与平台规范仍归 skill 与 agent。
- 决策(权限与副作用):跑脚本复用 `command.exec` 的权限口径(只查 deny,UI 按钮即用户确认),不新增 `GAME_CREATION_APP_COMMANDS` 条目;导出链路不拿项目写锁、不推进全局 revision,`.export/` 不进 manifest、素材、UI State 或客户端投影;发布包白名单收集,`.export/` 不会进入。
- 决策(prompt 归属):首次适配与失败修复的指令正文、契约常量与组装都放前端(`view/project-development/export/state/xhsMinitoolInstruction.ts` 纯函数;失败说法在 `xhsMinitoolFailure.ts`),宿主不提供 `enqueue_*` 命令;前端经 `useDirectProjectChatController` 的 `chat.submit` 入队,复用它既有的会话写权限门与 clientTurnId。Rust 侧因此只有 per-target 的四个命令(读内容 / 读指纹 / 存表单 / 跑构建),typed error 首个错误即返回,不带问题数组;内容与指纹为什么分成两条见下面 2026-10-05 的同步细节决策。
- 决策(prompt 归属):首次适配与失败修复的指令正文、契约常量与组装都放前端(`view/project-development/export/state/xhsMinitoolInstruction.ts` 纯函数;失败说法在 `xhsMinitoolFailure.ts`),宿主不提供 `enqueue_*` 命令;前端经 `useDirectProjectChatController` 的 `chat.submit` 入队,复用它既有的会话写权限门与 clientTurnId。Rust 侧因此只有 per-target 的四个命令(读内容 / 读指纹 / 存表单 / 跑构建);内容与指纹为什么分成两条见下面 2026-10-05 的同步细节决策。
- 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/export/{mod,draft/mod,draft/xhs_minitool/{mod,commands}}`(`main.rs` 仅加 `pub mod export;`)、前端 `view/project-development/export/{tabs/xiaohongshu,state,generated}`、`resources/agc-skills/vite-export-xhs-minitool/scripts/*` 与其 `manifest.json` 指纹、`src-tauri/src/agent/skill_pack.rs` 的 `include_bytes!` 内容(文件数不变,仍 36 条)。不触碰 `GAME_CREATION_APP_COMMANDS`、`shared-contracts`、`server-rs`、SpacetimeDB、OpenAPI、`api-server` 与现有 `project/export.rs` 发布链路。
- 验证方式:`cargo test ... export::` 与 `export_bindings` + `npm run check:generated-bindings`、前端 vitest(复制 / 下载 / 冲突逐字段选择 / 适配指令组装与入队 mock)、`npm run ai-game-creator-shell:typecheck`、`npm run agc:skill-pack:sync` + `agc:skill-pack:check`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check`;真实 vite 项目上验证首轮适配产出可上传 zip 与二次导出零 agent 调用。
- 边界:本决策只覆盖小红书小工具这一个目标;第二个目标出现时再抽 target 描述符,不预留动态注册或通用表单引擎。
## 2026-10-05 导出面板的同步细节与失败通道:指纹不进对外状态,真故障原样抛出
## 2026-10-05 导出面板的同步细节与文案归属:指纹不进对外状态
- 背景:导出面板(同上一条)落地后回头核 ADR([`【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)、[`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](../../adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md)),发现四件事与既有口径不一致:读状态把内容与指纹混在一条命令里、失败只有「显示一句话」一条出口、策略拒绝与宿主故障共用一个变体、宿主自己拼了「省略前 N 个字符」这句用户可见文案。四条都在这里收口。
- 背景:导出面板(同上一条)落地后回头核 [`【ADR】DirectProject对话滚动与历史自动加载-2026-10-02`](../../adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md),发现两件事与既有口径不一致:读状态把内容与指纹混在一条命令里、宿主自己拼了「省略前 N 个字符」这句用户可见文案。两条都在这里收口。
- 决策(同步细节不进对外状态):命令拆成 `read_xhs_minitool_export`(`form` + `hasScript`,自动刷新反复调的高频路径)与 `read_xhs_minitool_export_hash`(写回基线),后者只在进编辑会话、或新内容真的被采纳时取一次。`contentHash` 不进前端对外状态(hook 里只在 `baselineRef`),界面不显示任何同步状态。理由:刷新这条高频路径在**类型上**就动不了写回基线,否则「顺手刷新」会把两次刷新之间的外部改动认成自己的基线、把冲突吞掉。
- 决策(自动重读,取代「刷新按钮」,作废提交 `90f651ddf` 的「刷新取消未保存输入」):取消刷新按钮,按 2 秒固定间隔自动重读(窗口在后台时跳过)。`hasScript` 是宿主现算事实,任何时刻都采纳;表单只在用户手上没有未保存输入(也不在冲突/字段错误里)时才覆盖,否则绝不覆盖正在打的字。代价是「刷新会取消未保存输入」这条旧行为被有意作废,对应的回归用例同步替换。
- 决策(失败通道):预期拒绝留在面板(一句话按变体写死 + 宿主现场),**宿主侧事实故障与认不出形状的拒绝先给现场再原样抛出**,由全局 `unhandledrejection` 进错误池——面板不再把裸 message 当成业务失败的解释糊给用户,也不再静默吞掉真故障。抛出按指纹去重:面板每 2 秒重读,同一个故障只抛一次、变了再抛,否则报告池的 `count` 统计的是「面板开了多久」。策略拒绝(`commandDenied`)与宿主故障(`exportUnavailable`)必须是两个变体:`enforce_project_permission_policy` 那版把「策略拒绝」与「策略读不出来」拼成同一句人话,调用方无从判该不该上报,导出链路因此改用 typed 的 `enforce_project_permission_policy_rejection`。
- 决策(文案归属的延伸):宿主不预拼用户可见文案这条也管载荷里的说明——构建输出尾部只回 `outputTail` + `omittedCharacters` 两个事实,「已省略前 N 个字符」由前端拼(`export/state/xhsMinitoolOutputTail.ts`,失败卡片与成功提示共用)。与上面那条 prompt 归属同源:机器事实在 Rust,句子在前端。
- 刻意保留的差异:`xhsMinitoolFailure.ts` 这个「变体 → 一句话 + 下一步按钮」的纯映射没有按 ADR「判定只写在 catch 里、不抽 presenter」内联进 catch。那条规则的落点是只有一个消费方的认证 catch;这里有两个消费方(面板卡片与交给 agent 的修复指令),必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉。它不判「要不要上报」——上报判据在 hook 的 catch 里。
- 契约常量的单一来源:Rust `export/mod.rs` 与 `draft/xhs_minitool/layout.rs` 是契约值(`.export`、脚本名、注册表名、产物名)的唯一权威;前端在 `export/state/xhsMinitoolInstruction.ts` 保留一份用于拼指令,由 `tests/xhsMinitoolContract.test.ts` 直接读 Rust 源码把两份钉住(ts-rs 不能导出 `const`,所以钉法是测试而不是生成物)。改 Rust 常量而前端没跟,测试红。
- 影响范围:`export/draft/xhs_minitool/{commands,error,build,dto}.rs`、`export/generated/{CommandDenied,CommandFailed,XHSMiniToolExportRunResult,XHSMiniToolExportError}.ts`、`export/state/{useXhsMinitoolExport,xhsMinitoolFailure,xhsMinitoolOutputTail,xhsMinitoolApi}.ts`、`export/tabs/xiaohongshu/*`。不新增命令、不碰权限位、不动发布链路。
- 影响范围:`export/draft/xhs_minitool/{commands,error,build,dto}.rs`、`export/generated/*.ts`、`export/state/{useXhsMinitoolExport,xhsMinitoolFailure,xhsMinitoolOutputTail,xhsMinitoolApi}.ts`、`export/tabs/xiaohongshu/*`。不新增命令、不碰权限位、不动发布链路。
- 验证方式:`cargo test ... export::`(含 `export_bindings`)、`npm run check:generated-bindings`、`npx vitest run apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx apps/ai-game-creator-shell/tests/xhsMinitoolContract.test.ts`、`npm run ai-game-creator-shell:typecheck`、`npm run ai-game-creator-shell:check:rust:shell`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check`;真实 vite 项目上的首轮适配与二次导出仍是未验证项。
- 边界:只覆盖小红书小工具这一个目标;第二个目标出现时再抽 target 描述符,不预留动态注册或通用表单引擎。
@@ -164,23 +162,6 @@
- 影响范围:根开发脚本、AGC 开发启动编排、本地开发运维文档;不改变 API、schema、生产部署和独立 `npm run agc` 行为。
- 验证方式:参数/状态单测、开发栈健康端点 smoke、`.app/dev-stack.json` 身份复用检查、进程树收束检查。
## 2026-10-01 AGC 命令错误结构化与错误报告口径
- 决策: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`;可枚举的细分原因是类型化枚举字段(`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`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。
- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` 拼上本次操作的上下文前缀(`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-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。
- 追加(2026-10-02,401 归 phoneCodeLoginRejected):`/api/auth/phone/login` 的 401 只来自「用户不存在」(验证码错误/失效/过期在服务端都是 400,已由 `phoneCodeLoginRejected { serverMessage }` 带原文);顶层变体 `smsCodeRejected` 退役删除,前端三个 catch 去掉了它那个「验证码错误或已过期」的固定分支,`phoneCodeLoginRejected` 的文案统一为「验证码登录失败:<服务端原文>」。
- 追加(2026-10-02,读 body 失败按已确认状态码归类):AGC 认证请求拿到 `status` 后 `response.text()` 失败,不再一律压成 `authNetworkFailure { unreachable }`;非 2xx 走既有分类(`serverMessage` 为 `None`,如 503 → `authServiceUnavailable { 503 }`),只有 2xx 响应没收完才算传输层故障。分类收敛在 `classify_unreadable_body`。
- 追加(2026-10-02,系统类失败保留原始错误载荷):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` / `authClientInitFailed` 都带 `detail: string`(原始 error),既让调用方有机会分流处理,也让报告包带够诊断信息;原始 error 同时经 `app_log!`(落盘前过 `sanitize_diagnostic_message`)记一行本地日志。`detail` 不贴到界面上:三个 catch 用本操作的固定文案(登录检查 / 发码 / 登录各自不同)。取代上一版"本机 IO 失败不进载荷、原始 error 只进日志"。
- 影响范围(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 用例。
## 2026-10-02 launcher 页面高度契约:外壳分高度,页面不再自己算窗口高度
- 背景:PR #228(`6d2c275d3`)只给项目页补了「外壳纵向 flex + 页面 `flex: 1 1 auto`」的高度修复;其余页面仍各自算高度——帮助页没写高度也没有内层滚动容器,内容一长就被外壳 `overflow: hidden` 裁掉且无法滚动;首页用 `h-screen` / `h-[calc(100vh-32px)]`,模板库用 JS 量父级高度写内联 `height`。
@@ -31,7 +31,7 @@
- **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(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` 调用:结构化拒绝原样装进已有的 `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)。
- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。
- **判据/取证**:`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` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `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` 重新导出,生成物保持 ts-rs 原始输出(不再经 prettier / eslint 二次改写,见本节「生成绑定」口径);ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。
- **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。
@@ -8,7 +8,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 `<url>`。历史已经脱敏的归档不推测或补造原路由。
- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。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)。
- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。
- 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。
- 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。
- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型(`reason` 是枚举时再 `switch (payload.reason)`)、再用它自己的字段拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。
@@ -10,12 +10,11 @@
- 构建契约:项目 npm 包里存在脚本 `build:xhs-minitool`(冒号形式是既有 `command.exec` 白名单 `build:*` 的要求),由宿主负责运行;跑完必须存在 `.export/xhs-minitool.zip`。脚本内部怎么 `vite build`、怎么调 `pack.mjs`、用什么中间目录由 agent 决定并写进脚本;**每次导出都重新构建 zip**,不做「产物已存在」的短路。
- 首次适配:用户点显式按钮 → 前端组装适配指令(正文在 `view/project-development/export/state/xhsMinitoolInstruction.ts`,纯函数 + 契约常量)→ 经 `useDirectProjectChatController` 的 `chat.submit` 入队(该链路自带会话写权限门与 clientTurnId),进的就是同一个项目主 Direct 会话,不另开「直接调 agent」的旁路。不自动跑构建、不自动叫 agent;内容与脚本判据按固定间隔(2 秒,窗口在后台时跳过)自动重读,所以没有刷新按钮——agent 回合结束后用户只需要点「导出」。重读时两条事实分开处理:`hasScript` 是宿主的现算事实,任何时刻都采纳;表单是用户的编辑对象,只有用户手上没有未保存输入(也不在冲突里)时才覆盖,否则只更新 `hasScript`,绝不覆盖正在打的字。没有构建脚本时,「还没适配」的结论只由注册表读回来之后下(`hasScript` 初值是 `false`,读取中不下结论),提示卡片自带那颗入队按钮,按钮行里就不再重复一颗;失败卡片按变体的 `action` 分派三颗按钮——`adapt` 发首次适配指令(还没适配,修复指令会引用一份还不存在的适配说明)、`repair` 发同一份契约加宿主失败现场、`retry` 只重试,同一时刻只有一张卡片、一个入口。
- 命令切分:`read_xhs_minitool_export` 只返回**内容**(`form` + `hasScript`),自动刷新反复调的就是这一条;**注册表指纹**(写回时的 `baseHash`)由 `read_xhs_minitool_export_hash` 单独给,只在进编辑会话、或重读到的新内容真的被采纳时取一次。两者分开是刻意的:刷新这条高频路径在类型上就动不了写回基线,否则「顺手刷新」会把两次刷新之间的外部改动认成自己的基线、把冲突吞掉。指纹是同步细节,不进前端对外状态(hook 里只在 `baselineRef`),界面也不显示任何同步状态(没有「保存中」「待保存」文案)。`save_xhs_minitool_export_form` 成功后回新的指纹。
- 失败通道:命令失败是 typed 变体(`XHSMiniToolExportError`,ts-rs 导出到 `export/generated/`),前端只按 `type` 分流、`expectNever` 钉死穷尽性,**不对任何文案做判断**,也不把裸 message 当成业务失败的解释。分流沿用 [`【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) 的两道口子:预期拒绝(表单不合法、脚本缺失、冲突、权限策略拒绝 `commandDenied`、脚本非零退出、产物不在、注册表坏)留在面板里就地消化,一句话按变体写死、宿主现场照原样展示;**宿主侧事实故障(`exportUnavailable`)与认不出形状的拒绝(Tauri 传输失败 / 命令 panic / 参数序列化失败)先给现场再原样抛出**,由全局 `unhandledrejection` 送进错误池。抛出按指纹去重:面板每 2 秒重读,同一个故障只抛一次、变了再抛,否则报告池里的 `count` 统计的是「面板开了多久」。策略拒绝与宿主故障必须是两个变体:`enforce_project_permission_policy` 那版把「策略拒绝」与「策略读不出来」拼成同一句人话,调用方就分不出该不该上报,所以导出链路走 typed 的 `enforce_project_permission_policy_rejection`。宿主也不预拼用户可见文案:构建输出尾部只回原文 + `omittedCharacters` 两个事实,「已省略前 N 个字符」那句话由前端拼(`export/state/xhsMinitoolOutputTail.ts`,失败卡片与成功提示共用)。同一口径也管界面自己的失败:复制字段走 Tauri 剪贴板插件(`clipboard-manager:allow-write-text`,与 `UiEditorCopyPathButton` 等三处同一条路;`navigator.clipboard` 在 WebView 里可能根本没有、失败还静默),写失败时按钮显示「复制失败」,不假装成功。
- 冲突:注册表可被 agent(改文件)与用户(面板编辑)两方写入。宿主保存时带 `baseHash`;宿主重读比对,hash 不同再逐字段 diff,只要有字段真的不同就判冲突,**绝不覆盖**,返回两侧值让前端逐字段选择;纯格式化改动静默吸收。注册表内不存 revision / updatedAt 之类 token。
- 权限与副作用:跑脚本复用 `command.exec` 的权限口径(只查 deny,UI 按钮即用户确认),**不新增** `GAME_CREATION_APP_COMMANDS` 条目;导出链路**不拿项目写锁、不推进全局 revision**(`.export/` 不进 manifest、素材、UI State 或客户端投影)。发布包是白名单收集,`.export/` 不会进入。
- 非目标:平台自动上传(无 API)、PNG/JPG 之外的 icon 格式转换、宿主侧 zip 结构校验、非 vite 项目、第二个导出目标,以及 `GAME_CREATION_APP_COMMANDS` / `shared-contracts` / SpacetimeDB / OpenAPI 的任何改动。
- 验收:注册表自动建空表单与严格解析拒绝、表单首错、`hasScript` 判定、内容与指纹两条命令的切分(自动刷新拿不到写回基线)、`contentHash` 冲突与逐字段选择、icon 越界与符号链接拒绝、产物路径;失败按 typed 变体分流(策略拒绝与宿主故障是两个变体、认不出形状的拒绝与宿主故障原样抛出、同一故障只抛一次、输出省略量由前端写进提示);自动重读不覆盖用户未保存的输入、agent 事后加上脚本不用点刷新就能亮起导出按钮;首次适配在真实 vite 项目上由 agent 跑通并产出可人工上传的 zip,二次导出零 agent 调用;变更 skill 脚本后指纹同步。证据与未验证项见[里程碑](../project-memory/plans/【里程碑】导出产物面板与小红书小工具导出-2026-10-05.md)。
- 落地情况:Rust(`export/{mod,registry,draft/xhs_minitool/*}` 四个命令)、前端(`export/{state,tabs/xiaohongshu,generated}`)、skill(`pack.mjs --zip-out`)与 `.export/` 快照同步排除均已实现;失败通道已按上面两条 ADR 收口(`commandDenied` 与 `exportUnavailable` 分开、宿主故障与未分类拒绝原样抛出并去重、输出尾部只回事实)。定向单测与前端 vitest 通过,**真实 vite 项目上的首轮适配仍是唯一未验证项**。
- 验收:注册表自动建空表单与严格解析拒绝、表单首错、`hasScript` 判定、内容与指纹两条命令的切分(自动刷新拿不到写回基线)、`contentHash` 冲突与逐字段选择、icon 越界与符号链接拒绝、产物路径;自动重读不覆盖用户未保存的输入、agent 事后加上脚本不用点刷新就能亮起导出按钮;首次适配在真实 vite 项目上由 agent 跑通并产出可人工上传的 zip,二次导出零 agent 调用;变更 skill 脚本后指纹同步。
- 落地情况:Rust(`export/{mod,registry,draft/xhs_minitool/*}` 四个命令)、前端(`export/{state,tabs/xiaohongshu,generated}`)、skill(`pack.mjs --zip-out`)与 `.export/` 快照同步排除均已实现。定向单测与前端 vitest 通过,**真实 vite 项目上的首轮适配仍是唯一未验证项**。
## 2026-10-02 退役自建 Agent Runtime 与 CLI 执行面