diff --git a/FEATURES.md b/FEATURES.md index 855e5cd1..7e97159d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -34,6 +34,7 @@ - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 - [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。 +- [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。 - [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用有总时限的原生 Compact;并发外部 Server 对话的提问使用请求级公开 ID 映射,回答不会串到其他会话。外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 - [x] **Continue Runtime 定制**:提供静态配置中的原生 Rules、Prompt 模板与 MCP 清单,以及可编辑的 GoodBuddy Rules/Prompt 配置预设;聊天可按请求选择预设和填入可继续编辑的 Prompt。当前 Continue Host 没有可信的静态原生 Tool 发现接口,且使用隔离的 `CONTINUE_GLOBAL_DIR`,因此界面明确标记 Tools 不支持静态发现,也不把 Host 实际不会加载的工作区或用户 Skills 冒充原生能力;GoodBuddy 分配的 Skills 仍按请求暂存执行。Continue 临时 Host 不复用原生会话压缩,手动压缩由 GoodBuddy 摘要模型完成并验证持久化摘要覆盖范围;Agent 交互提问转换为统一问答卡片。Resources、Hooks、后台 Job 和 Continue 原生会话管理继续暂缓。 - [x] **Runtime 原生清单语义**:原生能力以 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 个页签展示;清单状态独立于 Runtime 连通性,区分完整、部分、不可用、仅连接和不支持。DeepSeek Harness 通过 Host Registry 枚举有界的内置/插件 Tools 与 Skills,显示真实 Ask/Execute 边界,并排除 GoodBuddy 按请求分配的 Skills、Web/MCP 代理。 @@ -82,7 +83,7 @@ - [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。 - [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。 - [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。 -- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;失败后保留草稿并按同一请求编号重试,不自动附加对话、日志、文件、配置或凭据。 +- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;默认不上传诊断,用户可主动附加有界的最近桌面诊断摘要。失败后保留草稿、诊断选择和同一请求编号;不会附加对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。 - [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。 ### 开源、构建与发布 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 68f0b975..b8c857a0 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -651,8 +651,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。 - 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。 - “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 -- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。 -- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图,失败时保留草稿并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。 +- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。 +- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。 - Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 - Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 - “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。 diff --git a/docs/README.md b/docs/README.md index 88723bcb..c79902c1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,7 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先 - [SSH Host 远程环境准备与控制面直连设计](./architecture/remote-host-environment-provisioning-design.md) - [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md) - [长期助手路线图](./roadmap/long-term-assistant-roadmap.md) +- [产品、性能与体验综合改进计划](./roadmap/product-performance-experience-improvement-plan.md) - [电脑控制实施状态](./status/computer-control-implementation-status.md) - [知识检索评估](./quality/knowledge-retrieval-evaluation.md) - [GoodBuddy 龙芯(LoongArch)预览版构建与功能列表](./development/loongarch-preview-build.md) diff --git a/docs/architecture/in-app-feedback-integration-design.md b/docs/architecture/in-app-feedback-integration-design.md index 4b12fe38..82a86ba5 100644 --- a/docs/architecture/in-app-feedback-integration-design.md +++ b/docs/architecture/in-app-feedback-integration-design.md @@ -45,8 +45,9 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 以此保证幂等;创建另一条反馈时才生成新 ID。 10. GoodBuddy 使用单独的随机安装 UUID 做服务端限流。该 UUID 不得复用 Agent installation ID、硬件 ID、机器名、用户名、项目 ID 或任何设备指纹。 -11. 默认不发送对话、提示词、模型回复、日志、文件、项目或工作区路径、知识库、模型配置、 - Runtime 配置、API Key、Token、Cookie、SSH 信息、剪贴板或屏幕内容。 +11. 默认不发送对话、提示词、模型回复、诊断、日志、文件、项目或工作区路径、知识库、模型 + 配置、Runtime 配置、API Key、Token、Cookie、SSH 信息、剪贴板或屏幕内容。用户可通过 + 默认关闭的 Checkbox 主动附加有界的最近桌面诊断摘要。 12. 反馈提交失败时保留全部表单内容和截图,成功后显示可复制的服务端反馈编号。 13. 首版不提供反馈历史、状态查询、编辑、撤回、自动日志收集或服务端管理入口。 14. 生产接口固定为 `https://imp.mesalogo.com/api/v1/feedback`。域名健康检查、 @@ -79,7 +80,8 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 - GitHub Issue 创建、GitHub Token 或 GitHub 网页附件上传。 - GoodBuddy 内的管理员后台、处理状态修改或内部备注。 - 用户账户、登录、反馈列表、远程状态查询或推送通知。 -- 自动附加当前对话、日志、错误堆栈、项目、文件、模型或 Runtime 信息。 +- 自动附加当前对话、桌面诊断、日志、错误堆栈、项目、文件、模型或 Runtime 信息。 +- 远端 Agent 日志或通用日志、诊断包和附件框架。 - 自动截取全屏、当前窗口、浏览器、远程桌面或其他应用窗口。 - 多截图、视频、录屏、任意文件附件或压缩包。 - 离线队列、后台自动重试、指数退避、跨重启草稿或发送历史。 @@ -113,16 +115,23 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 - 使用体验:`experience` - 其他:`other` 2. **标题**,必填,1 至 120 字符。 -3. **详细描述**,必填,去除首尾空白后 10 至 5,000 字符。 -4. **联系邮箱**,可选,最多 254 字符。 -5. **截图**,可选,最多一张,支持选择 PNG/JPEG/WebP 或在对话框中粘贴图片。 -6. **将发送的信息**,只读显示应用版本、系统、架构和界面语言。 -7. **隐私说明**,紧邻提交操作。 +3. **详细描述**,必填,去除首尾空白后 10 至 5,000 字符;选择桌面诊断时为摘要预留空间, + 用户描述上限为 3,398 字符。 +4. **附加最近桌面诊断记录**,可选的原生 Checkbox,默认关闭。 +5. **联系邮箱**,可选,最多 254 字符。 +6. **截图**,可选,最多一张,支持选择 PNG/JPEG/WebP 或在对话框中粘贴图片。 +7. **将发送的信息**,只读显示应用版本、系统、架构和界面语言。 +8. **隐私说明**,紧邻提交操作。 隐私说明使用明确文案: > 将发送反馈类型、标题、描述、可选邮箱、GoodBuddy 版本、操作系统、架构、界面语言, -> 以及你主动添加的截图。不会自动发送对话、日志、文件、项目路径、模型配置或凭据。 +> 以及你主动添加的截图。默认不上传桌面诊断;只有勾选后才会把有界诊断摘要追加到描述。 +> 不会发送对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。 + +Checkbox 下方持续说明:诊断摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息。 +Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详细描述上限为 3,398 字符,为附加 +摘要预留 1,600 字符以及分隔换行;超长草稿保留在表单中并显示字段错误,不静默截断。 截图区域必须包含: @@ -167,7 +176,7 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 | `productKey` | Main 常量 `goodbuddy` | 服务端产品路由 | | `category` | 用户选择 | 分流反馈 | | `title` | 用户输入 | 摘要 | -| `description` | 用户输入 | 问题和建议详情 | +| `description` | 用户输入;勾选时由 Main 追加桌面诊断摘要 | 问题、建议和主动提供的有界诊断详情 | | `contactEmail` | 用户可选输入 | 必要时联系用户 | | `environment.appVersion` | `app.getVersion()` | 判断受影响版本 | | `environment.platform` | `process.platform` 映射 | 判断受影响平台 | @@ -197,8 +206,9 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平 - 剪贴板、屏幕、摄像头、麦克风或其他应用内容。 - Remote Agent installation ID、Runtime ID、Conversation ID、Task ID 或 Project ID。 -未来若增加日志、诊断包或其他附件,必须单独设计字段、预览、大小限制、用户选择和服务端 -保留策略,不能复用截图字段静默上传。 +除上述用户主动选择、追加到描述且有界的桌面诊断摘要外,未来若增加日志、诊断包或其他 +附件,必须单独设计字段、预览、大小限制、用户选择和服务端保留策略,不能复用截图字段 +静默上传。 ### 4.3 本地数据生命周期 @@ -289,6 +299,7 @@ HTTP 201 表示首次创建,HTTP 200 表示相同 `clientRequestId` 的幂等 | 500–599 | 提示服务暂时异常,保留草稿 | | 超时 / DNS / TLS / 离线 | 提示无法连接反馈服务,保留草稿 | | 响应格式错误 | 提示服务返回无效结果,不能猜测成功 | +| 本地诊断读取失败 | 不发送反馈,保留草稿、勾选状态和请求 ID,提示重试或取消勾选 | 首版不自动重试。自动重试容易让用户无法判断是否已经发送,也会在服务持续异常时制造额外 流量。用户点击“重试”时复用同一个 `clientRequestId`。 @@ -330,6 +341,7 @@ type FeedbackSubmitInput = { category: 'bug' | 'feature' | 'experience' | 'other' title: string description: string + includeDiagnostics: boolean contactEmail?: string locale: 'zh-CN' | 'en-US' clientRequestId: string @@ -341,7 +353,7 @@ type FeedbackSubmitInput = { ``` Renderer 不能传入 `productKey`、`appVersion`、`platform`、`architecture` 或 -`installationId`。这些字段由 Main 从受信运行环境生成。 +`installationId`,也不能传入诊断内容或路径。这些字段由 Main 从受信运行环境生成。 ### 6.3 IPC 与 Preload @@ -402,6 +414,12 @@ Main-only `FeedbackService` 负责: - 映射应用版本、平台和架构。 - 验证并标准化可选截图。 - 组装严格的公共 payload。 +- 仅在 `includeDiagnostics: true` 时通过注入的 DesktopDiagnostics provider 读取最多 20 条 + 最近记录;默认提交完全不调用诊断 provider。 +- 将诊断格式化为带 + `[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_BEGIN]` / + `[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END]` 明确分隔、最多 1,600 字符且只保留完整 JSON + 行的摘要。无记录时写入固定 `{"status":"no-recent-diagnostics"}` 事实。 - 调用 HTTP Client。 - 维持单次提交的 AbortController,并在 dispose / 清除本地数据时取消。 @@ -416,6 +434,11 @@ Main-only `FeedbackService` 负责: 架构只保留 `x64` 和 `arm64`,其他值映射为 `unknown`。 +公共 API 继续使用 `schemaVersion: 1` 和原有固定字段。`includeDiagnostics` 只属于客户端 +IPC 输入,不进入公共 payload,也不新增 multipart 字段;Main 把摘要追加到 +`payload.description`。默认未勾选时,公共 payload 与既有实现逐字段一致。诊断读取失败时 +不得发送部分摘要或声称已附加,必须返回可本地化的 `diagnostics-unavailable` 错误。 + ### 6.6 严格 HTTPS Client 反馈客户端使用 `undici` 的独立 `Agent`: @@ -618,7 +641,8 @@ Endpoint 的健康检查和无副作用 schema 探针也必须在发布前通过 - 普通文本粘贴不被图片处理拦截。 - 截图选择、预览、替换、移除和 Object URL 清理。 - 并发截图读取按最新选择生效,关闭对话框后不会留下待处理 Object URL。 -- 失败保留字段,重试使用原 `clientRequestId`。 +- 失败保留字段、诊断勾选状态,重试使用原 `clientRequestId`。 +- 默认提交不读取诊断且公共 payload 不变;主动附加、描述预算、无记录和读取失败均有覆盖。 - 成功显示真实编号并清空后续新草稿。 - Dialog role、初始焦点、Tab 循环、Escape 和焦点恢复。 - 浅色、深色、窄窗口和 reduced motion。 @@ -666,8 +690,9 @@ npm run build - Endpoint 域名变化属于客户端发布变更,不能依赖 301/302 迁移。 - 入口发布与 `goodbuddy` 产品启用状态、生产 TLS 和 v1 契约保持同一发布门槛。 - 此功能不迁移现有用户数据;只新增可重置的随机反馈安装身份。 -- 发布说明应明确:用户现在可以在“关于与更新”内直接提交反馈,邮箱和截图可选,应用不会 - 自动附加对话、日志、文件或凭据。 +- 发布说明应明确:用户现在可以在“关于与更新”内直接提交反馈,邮箱和截图可选;桌面诊断 + 默认不上传,只有用户勾选后才附加有界摘要,并且不会发送对话、Prompt、凭据、文件内容、 + 路径、Provider 原始响应或远端 Agent 日志。 --- diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index dc573bc7..2028d5f1 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -52,6 +52,7 @@ Detached GoodBuddy Agent -> Agent-owned OpenCode ACP Prompt Promise -> Prompt-scoped Provider gateway / model-call ledger -> 有界语义 transcript / page / ACK + -> 有界私有诊断记录 -> 直接拥有的 Runtime 进程 ``` @@ -369,6 +370,16 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture 终态,语义 `latest=ACK=29`,清理计数同样为 0。最终补充验证共 3 次真实文本模型调用。 采证后已按 PID/starttime/executable 停止精确隔离 daemon 并删除隔离 Host 测试根目录; `/root/.goodbuddy` 下的共享 Agent 安装和进程未被修改。 +- 2026-08-31 使用当前工作树短期测试签名 Linux x64 候选,在全新隔离 HOME 和 installation + 中验证有界诊断:bootstrap、detached daemon、attach、relay 断开与同 controller 重连、 + `controller/resume`、Runtime candidate 激活和 OpenCode ACP 进程启动均通过;新 + `diagnostics --installation-id` 命令在 daemon 重启后读取到 23 条完整 JSONL。诊断目录 + 为 `0700`、文件为 `0600`,单文件 3719 bytes,未超过 64 KiB。六类测试哨兵覆盖 Prompt、 + 密钥、环境、SSH 参数、用户文件内容和原始错误消息,CLI 输出与磁盘均未出现哨兵。把隔离 + diagnostics 目录临时设为不可写后,Attach 和 `agent/status` 仍正常;随后已恢复权限。 + 本次未注入模型凭据或执行 Prompt,真实文本模型调用 0 次;三文件轮转继续由聚焦测试覆盖。 + 测试结束前按 PID、UID、starttime、executable 和唯一 installation 核对并停止所有本次 + daemon,本地及 Host 临时目录均已清理,共享 current Agent 和 `/root/.goodbuddy` 未修改。 - 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认 OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。 - 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生 @@ -420,6 +431,31 @@ v32 保留项目及关联用户数据,并加入 remote-recoverable Task/messag 执行空间表保持 `project_id/kind/root_path/ssh_host_id` 四列,并删除旧 Runtime 验证表。 +## Agent 有界诊断 + +Detached Agent 不依赖 stdio 留存故障证据。每个 installation 在固定私有目录 +`~/.goodbuddy/state//diagnostics/` 中维护 +`agent-diagnostics.jsonl`、`.1` 和 `.2`,每个文件不超过 64 KiB,目录权限为 `0700`, +文件权限为 `0600`。记录覆盖 daemon 启停、detached 启动、连接、恢复和 Runtime +启动/退出;每条只包含固定事件、时间、PID、可选工作模式、固定原因和白名单错误码。 + +诊断写入使用最多 64 条的有界异步队列。连接、恢复和 Runtime 回调只把已经归一化的固定 +记录入队,不等待磁盘;原始 `Error` 不进入队列。队列满、目录不可写或轮转失败只丢弃诊断, +不得改变 Attach、协议、Runtime 或退出结果。daemon 停止和 bootstrap 生命周期结束时执行 +有界 flush。多个 Agent 进程通过 GoodBuddy 私有短期锁协调 append 和轮转,不把长期锁作为 +运行前提。 + +已安装 Agent 提供只读固定命令: + +```text +goodbuddy-agent diagnostics --installation-id +``` + +该命令只能从 installation 对应的固定 state 目录读取上述有界文件,并逐行输出已验证 JSONL; +不接受任意路径,也不扩展 Agent protocol。诊断不得包含 accepted Prompt、模型输入输出、 +模型密钥、完整配置、环境变量、SSH 参数、用户文件内容、路径或原始错误消息。应用内反馈的 +“附加最近桌面诊断记录”只读取 Desktop 诊断,不会自动取得或上传这些远端 Agent 记录。 + ## 资源与发布 - 所有桌面构建(包括 `portable` 与 `release:package`)只携带 diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md new file mode 100644 index 00000000..b015fbac --- /dev/null +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -0,0 +1,563 @@ +# GoodBuddy 产品、性能与体验综合改进计划 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档类型 | 跨功能实施路线图 | +| 状态 | 规划中 | +| 版本 | 1.0 | +| 日期 | 2026-08-31 | +| 适用范围 | GoodBuddy Desktop、GoodBuddy Agent、发布与质量流程 | +| 评审基线 | Desktop `0.11.13`、Agent lock `0.11.14` | + +## 1. 目的 + +本文把 2026-08-31 对当前仓库进行的功能、性能、体验和交付评审结果整理为一份可分批实施、 +持续更新的权威清单。后续每一批工作都从本文选择明确范围,完成后更新状态、验证结果和相关 +产品文档,不再维护平行的临时待办。 + +本计划优先完成已经存在但尚未形成真实用户闭环的能力,其次处理经过测量确认的性能问题, +再安排依赖外部平台或产品选择的增强能力。实施继续遵循以下原则: + +1. 先保证完整、可用的生产路径,再扩展能力范围。 +2. 性能优化必须有基线、目标和优化后的对比数据。 +3. 复用现有合同、IPC、Worker、通知、错误边界和界面组件,不引入不必要的框架。 +4. Ask 保持只读;Execute 继续代表当前本机或 SSH 账号的完整授权。 +5. 每批只处理列明的范围,不借机进行无关重构。 + +## 2. 明确排除的工作 + +本计划**不包含初始 Renderer Bundle 优化**,包括但不限于: + +- 不为了缩小入口包继续拆分轻量同级页面。 +- 不单独延迟 KaTeX、全局样式或其他入口依赖。 +- 不调整现有 Bundle 预算来制造通过或失败。 +- 不以 Bundle 大小为理由改变当前页面交互语义。 + +现有构建预算和按路由拆包继续作为回归保护保留。只有后续获得首屏解析或执行耗时证据,并由 +用户单独确认范围后,才重新讨论该项。 + +## 3. 状态与优先级 + +### 3.1 状态 + +| 状态 | 含义 | +| --- | --- | +| 待开始 | 范围已确认,尚未实施 | +| 进行中 | 已进入当前实施批次 | +| 阻塞 | 存在明确外部依赖,已记录解除条件 | +| 已完成 | 代码、真实路径、测试和文档均完成 | +| 不做 | 经重新评审后明确移出范围,并记录原因 | + +### 3.2 优先级 + +| 优先级 | 含义 | +| --- | --- | +| P0 | 影响正式交付、安装或已声明生产路径,发布前处理 | +| P1 | 阻断核心工作流或造成明显错误、卡顿、误导 | +| P2 | 重要增强或规模化问题,可在核心闭环后实施 | + +## 4. 分批实施总览 + +批次表示建议顺序,不表示必须一次完成整批。每次可以从当前批次选择一组边界清晰、能够独立 +验收的事项。 + +| 批次 | 主题 | 事项 | 开始条件 | 完成门槛 | +| --- | --- | --- | --- | --- | +| 0 | 正式交付闭环 | `REL-01` 至 `REL-03`、`QA-03` | 发布资源和目标平台明确 | 正式产物可安装、可启动,版本与验证声明准确 | +| 1 | 核心工作流与恢复体验 | `FUN-01`、`FUN-04`、`UX-01` 至 `UX-12`、`OPS-01` 至 `OPS-03` | 无 | 用户可理解状态、从常见失败中直接恢复,并能导出有界诊断 | +| 2 | 通道、更新和真实 Runtime | `FUN-02`、`FUN-03`、`FUN-05`、`QA-01`、`QA-02` | 对应平台测试配置可用 | 文件办公闭环、更新下载闭环和真实 Runtime E2E 均通过 | +| 3A | 性能基线 | `PERF-01`、`PERF-03`、`PERF-05`、`PERF-07`、`PERF-09` | 固定测试数据和设备 | 形成可重复基线、目标阈值和原始结果 | +| 3B | 已测量性能优化 | `PERF-02`、`PERF-04`、`PERF-06`、`PERF-08`、`PERF-10` | 对应 3A 指标确认存在瓶颈 | 指标改善且功能、取消、恢复语义不回退 | +| 4 | 增强能力 | `FUN-06` 至 `FUN-09` | 核心闭环稳定;首个电脑控制平台已选定 | 每项形成独立、完整、可真实使用的工作流 | + +## 5. 发布与交付 + +### REL-01 Desktop 与 Agent 版本闭环 + +- **优先级 / 状态:** P0 / 待开始 +- **目标:** 当前 Desktop 源码依赖的 Agent 能从正式渠道取得并完成真实 Host 验证。 +- **范围:** + - 发布并验证 Agent `0.11.14`,或在 Desktop 发布前恢复到已发布 Agent 契约。 + - 验证 Linux x64、Linux arm64 正式候选包。 + - 覆盖安装、Attach、Execute、取消和断线恢复。 + - 覆盖 GitHub、镜像和离线 GoodBuddy 传输路径。 +- **验收:** + - 干净用户目录能获取预期版本。 + - Desktop 到 Agent 的正常路径和恢复路径均使用当前候选产物通过。 + - 发布说明准确标注已验证架构和仍未验证范围。 +- **外部依赖:** Agent 发布审批、双架构正式产物、真实 Linux arm64 环境。 +- **起始证据:** `package.json`、`agent-runtime-lock.json`、 + `docs/architecture/remote-host-and-goodbuddy-agent-design.md`。 + +### REL-02 正式 Latest 签名门槛 + +- **优先级 / 状态:** P0 / 待开始 +- **目标:** 官网正式推荐下载不指向可能被系统默认安全策略阻止的测试产物。 +- **范围:** + - macOS 缺少签名或公证凭据时,不更新正式 Latest。 + - Windows 未签名构建不作为默认推荐安装包。 + - 未签名构建继续作为明确标注的测试 artifact。 +- **验收:** + - 发布工作流在签名条件不满足时不会切换正式 Latest。 + - 正式下载在全新 macOS、Windows 默认安全策略下完成首次启动。 +- **外部依赖:** Apple 和 Windows 代码签名凭据。 +- **起始证据:** `.github/workflows/packages.yml`、`BUILD.md`、`README.md`。 + +### REL-03 发布包启动冒烟 + +- **优先级 / 状态:** P0 / 待开始 +- **目标:** 发布成功代表打包应用能够启动,而不只是文件结构完整。 +- **范围:** + - 在对应 CI Runner 启动解包应用。 + - 等待 Main、Preload、Renderer ready 健康标识后退出。 + - Windows x64 接入现有 packaged DeepSeek Harness smoke。 + - 为启动设置总超时,并保留无用户内容的失败诊断。 +- **验收:** + - 缺 DLL、ASAR unpack、Preload 或 Main 启动错误会使发布任务失败。 + - 探针无凭据、无外网也能执行。 +- **起始证据:** `.github/workflows/packages.yml`、`build/build-release.cjs`、 + `build/run-packaged-deepseek-harness-smoke.cjs`。 + +## 6. 核心与增强功能 + +### FUN-01 定时任务编辑 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 用户无需删除并重建计划任务即可纠正配置。 +- **范围:** + - 新增共享 Update schema、Preload 方法和可信 IPC handler。 + - 复用定制任务创建对话框作为编辑模式。 + - 支持修改名称、Prompt、Ask/Execute、Runtime、首次运行时间和周期。 + - 保留原 Task、Conversation、Schedule 和历史运行关联。 +- **验收:** + - 从任务界面进入编辑,保存后列表和下一次运行时间立即更新。 + - 编辑不产生重复 Task 或 Schedule。 + - 正在运行的实例继续使用启动时快照,后续触发使用新配置。 +- **起始证据:** `CustomTaskDialog.tsx`、`TaskScheduleActions.tsx`、`src/main/ipc.ts`。 + +### FUN-02 企业微信附件收发 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 企业微信完成“接收文件或图片、处理、回传成果”的办公闭环。 +- **范围:** + - 接收官方接口支持的图片和普通文件。 + - 将附件接入现有远程消息上下文。 + - 支持回传当前任务生成的图片或文件。 + - 复用 `ChannelMediaAttachment`、现有大小限制和 outbox 清理。 +- **验收:** + - 收到支持的附件不会再返回 `attachment_not_supported`。 + - 任务生成的有界成果能发送到原会话。 + - 超限、下载失败和发送失败提供明确错误且临时文件得到清理。 +- **起始证据:** `src/main/channels/wecom-driver.ts`、 + `src/main/channels/wechat-sidecar.ts`。 + +### FUN-03 钉钉附件收发 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 钉钉非文本消息不再静默丢失,并完成文件办公闭环。 +- **范围:** + - 支持官方接口允许的图片和普通文件接收。 + - 支持任务成果附件回传。 + - 对暂不支持的消息类型返回明确说明。 +- **验收:** + - 支持的非文本消息进入对应远程 Conversation。 + - 不支持的消息有可见反馈,不被静默忽略。 + - 收发限制和清理行为与其他通道一致。 +- **起始证据:** `src/main/channels/dingtalk-driver.ts`、 + `src/main/channels/dingtalk-channel-driver.ts`。 + +### FUN-04 浏览器错误分类 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 浏览器任务失败后,用户知道失败阶段和下一步操作。 +- **范围:** + - 保留代理、DNS、会话创建、CDP、导航和截图等失败阶段。 + - 增加少量稳定错误码及一句修复建议。 + - 在任务记录和界面中呈现,不建立通用错误协议框架。 +- **验收:** + - 典型失败不再全部显示为“工具执行失败”。 + - 原始敏感内容不进入 Renderer 或诊断日志。 + - 取消继续被识别为取消,而不是错误。 +- **起始证据:** `src/main/agent/model-runtime.ts`、 + `docs/status/computer-control-implementation-status.md`。 + +### FUN-05 更新下载闭环 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 用户能在应用中取得当前平台安装文件并确认文件完整。 +- **范围:** + - 从当前更新源读取当前平台产物。 + - 下载到用户选择目录。 + - 使用现有发布清单校验大小和 SHA-256。 + - 校验成功后允许打开文件或所在目录。 + - 安装继续由用户手动触发。 +- **验收:** + - 下载源、版本、架构和当前系统匹配。 + - 损坏或哈希不匹配的文件不会被呈现为可安装。 + - 失败保留目标和错误上下文,可重新下载。 +- **起始证据:** `UpdateSettingsSection.tsx`、`src/main/version-checker.ts`、 + `src/main/ipc.ts`。 + +### FUN-06 定时任务常用周期 + +- **优先级 / 状态:** P2 / 待开始 +- **目标:** 覆盖常见办公日历,而不要求用户编写 Cron。 +- **范围:** 增加工作日和每月周期,并持续显示下一次实际执行时间。 +- **验收:** 创建、编辑、重启恢复和到期触发均按本地日历执行且不重复。 +- **依赖:** `FUN-01`、`FUN-07`。 +- **起始证据:** `src/shared/assistant-contracts.ts`、 + `docs/prd/task-and-job/scheduled-task-prd.md`。 + +### FUN-07 时区和错过执行策略 + +- **优先级 / 状态:** P2 / 待开始 +- **目标:** 用户明确控制任务在哪个时区运行,以及错过后是否补跑。 +- **范围:** + - 默认本机 IANA 时区。 + - 支持 `skip` 和 `run_once`。 + - 显示 DST 影响后的下一次实际运行时间。 +- **验收:** + - 重启或系统恢复后不会形成补跑风暴。 + - 时区和策略可创建、编辑、持久化并在界面复现。 +- **起始证据:** `src/main/assistant/assistant-database.ts`、 + `docs/prd/task-and-job/scheduled-task-prd.md`。 + +### FUN-08 旧版 Office 文件 + +- **优先级 / 状态:** P2 / 待开始 +- **目标:** DOC、XLS、PPT 能进入聊天附件和知识库解析路径。 +- **范围:** + - 检测本机 LibreOffice。 + - 使用 headless 转换到 OOXML 或 PDF 后复用现有解析器。 + - 未安装时提供明确安装或手工转换建议。 +- **验收:** + - 三种旧格式各用真实文件完成聊天解析和知识库导入。 + - 转换取消、超时和失败后清理 GoodBuddy 临时目录。 + - 不内置第二套文档解析引擎。 +- **起始证据:** `src/main/knowledge/document-parser.ts`、 + `DocumentParsingSettingsSection.tsx`。 + +### FUN-09 首个原生电脑控制适配器 + +- **优先级 / 状态:** P2 / 待开始 +- **目标:** 在一个明确的主要部署平台上真实控制桌面应用。 +- **范围:** + - 首先从 Windows UIA、macOS AX 或 Linux 原生方案中选择一个平台。 + - 完成窗口快照、点击、输入、取消和退出清理。 + - 接入现有 computer-control broker。 +- **验收:** + - 在真实桌面应用完成一次可重复的查看、点击和输入任务。 + - Ask 仍不可执行桌面变更,Execute 路径记录活动。 + - 取消和关闭后不遗留 GoodBuddy 控制会话。 +- **外部依赖:** 首个平台产品决策及对应真实设备。 +- **起始证据:** `src/main/capabilities/capability-service.ts`、 + `docs/status/computer-control-implementation-status.md`。 + +## 7. 性能 + +### PERF-01 启动性能测量 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 得到冷启动各阶段的可重复耗时,而不是依据代码顺序猜测。 +- **指标:** `app.whenReady`、窗口创建、数据库、知识库、Provider/Gateway、Runtime hydration、 + IPC 注册、`loadMainWindow`、`ready-to-show` 和 Renderer 首帧。 +- **验收:** 固定设备至少运行五次冷启动,报告中位数和 p95;诊断不包含用户内容。 + +### PERF-02 启动路径优化 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 尽早显示可理解的应用壳,并延后经测量确认的非首屏工作。 +- **实施条件:** `PERF-01` 证明具体阶段构成主要延迟。 +- **验收:** 首帧和可交互时间达到批次开始时确定的目标,初始化失败仍有明确状态和恢复入口。 + +### PERF-03 长流式回答基准 + +- **优先级 / 状态:** P1 / 待开始 +- **场景:** 100KB、500KB、1MB Markdown 回答,包含推理、代码块和常见富文本。 +- **指标:** React commit 数和 p95、Long Tasks、Markdown 渲染时间、输入延迟、滚动帧率、 + Renderer 堆内存。 +- **验收:** 测试数据、运行步骤和原始结果可重复执行。 + +### PERF-04 流式更新合并 + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 降低长回答期间的状态复制和完整 Markdown 重解析。 +- **实施条件:** `PERF-03` 确认 commit 或 Markdown 渲染是瓶颈。 +- **范围:** + - 同一请求的 text/reasoning delta 按 `requestAnimationFrame` 合并。 + - 每帧最多提交一次相关 React 状态。 + - 流结束后再执行完整富文本渲染。 +- **验收:** 目标指标改善;事件顺序、停止、重试、引用、工具和 Subagent 块不回退。 + +### PERF-05 向量检索规模基准 + +- **优先级 / 状态:** P1 / 待开始 +- **场景:** 1k、10k、50k chunks 的全文、向量和混合检索。 +- **指标:** 总耗时、`vectorScannedCount`、Main event-loop lag、取消响应和峰值内存。 +- **验收:** 固定语料和查询集产生可比较结果,并保留检索质量指标。 + +### PERF-06 向量计算移出 Main + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 大知识库向量扫描不阻塞 Electron Main。 +- **实施条件:** `PERF-05` 确认 Main event-loop lag 或取消响应不达标。 +- **范围:** 优先复用已有 Worker,减少重复扫描和复制;本项不自动引入 ANN。 +- **验收:** 检索排序和质量不回退,Main 延迟与取消指标达到目标。 + +### PERF-07 知识库 Snapshot 测量 + +- **优先级 / 状态:** P2 / 待开始 +- **指标:** SQL 时间、对象数量、structured-clone 估算字节、IPC 往返、Renderer commit 和 + 峰值堆内存。 +- **场景:** 小型、典型和大型文档与图谱数据集。 +- **验收:** 明确首屏实际需要的字段和超过目标阈值的数据规模。 + +### PERF-08 知识库按需加载 + +- **优先级 / 状态:** P2 / 待开始 +- **实施条件:** `PERF-07` 证明完整 Snapshot 是可见瓶颈。 +- **范围:** + - 首屏只返回列表字段、状态和计数。 + - 文档详情按选择加载。 + - 图实体、关系和证据仅在图谱视图加载。 + - 复用已有列表和 chunk 接口。 +- **验收:** 首开与切换指标改善,选择、刷新、取消和局部错误状态不回退。 + +### PERF-09 Keep-alive 压力测试 + +- **优先级 / 状态:** P2 / 待开始 +- **场景:** 连续打开 12 个会话,每个会话包含 80 条富 Markdown、图片和工具记录。 +- **指标:** DOM 节点、Renderer heap、GC pause 和流式 commit。 +- **验收:** 明确隐藏会话缓存是否构成实际内存或渲染问题。 + +### PERF-10 Keep-alive 轻量优化 + +- **优先级 / 状态:** P2 / 待开始 +- **实施条件:** `PERF-09` 确认当前缓存不达标。 +- **范围:** + - 优先 memoize 隐藏 `ChatHistoryPane` 并稳定 props。 + - 仍不达标时才调整缓存数量或一小时有效期。 + - 暂不引入完整虚拟化框架。 +- **验收:** 内存和 commit 指标改善,同时保留会话切换位置和快速恢复体验。 + +## 8. 体验与可访问性 + +### UX-01 统一 Ask/Execute 文案 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** + - 删除“Execute 高风险操作仍需审批”的冲突说明。 + - 统一表达为使用当前账号完整权限执行,调用记录到活动。 + - 同步定时任务、Composer、Runtime、帮助文案及中英文资源。 +- **验收:** 所有入口对 Ask/Execute 的描述与真实 Runtime 契约一致。 + +### UX-02 Runtime 显式状态 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 顶栏显示就绪、连接中、不可用等文字,并通过 `aria-describedby` 暴露原因。 +- **验收:** 不依赖颜色或 `title` 即可理解 Runtime 状态和发送不可用原因。 + +### UX-03 等待审批实时通知 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 审批卡首次出现时用 polite live region 播报等待状态和工具名;不抢焦点。 +- **验收:** 屏幕阅读器能发现新审批,右侧栏数量变化也有可访问状态。 + +### UX-04 长任务阶段播报 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 低频阶段使用 `role="status"`,失败终态使用 `role="alert"`,不逐字播报正文。 +- **验收:** 准备、等待、完成和失败可被辅助技术理解,流式输出不会形成播报风暴。 + +### UX-05 Composer 错误关联 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 附件和上下文错误使用稳定 ID、`role="alert"`、`aria-describedby` 和适当的 + `aria-invalid`。 +- **验收:** 键盘和屏幕阅读器用户能知道发送失败原因及对应操作区域。 + +### UX-06 设置离开确认焦点 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 确认出现时滚动到可见区域并聚焦“继续编辑”,或改用共享确认对话框。 +- **验收:** 在长设置页底部切换分类、关闭设置和应用导航时均不会出现不可见确认。 + +### UX-07 SSH Host 读取失败状态 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 读取失败与真实空列表分开,失败状态提供重新读取。 +- **验收:** 暂时读取失败不会诱导用户重复添加 Host,重试成功后恢复原列表。 + +### UX-08 工作区预览重试 + +- **优先级 / 状态:** P2 / 已完成 +- **范围:** 加载使用 `role="status"` 和 `aria-busy`;失败保留路径并提供重新读取。 +- **验收:** 网络抖动后无需返回目录重新查找文件即可恢复预览。 + +### UX-09 最近对话空状态 + +- **优先级 / 状态:** P2 / 已完成 +- **范围:** 区分首次为空、搜索无结果和加载失败,分别提供新建、清除搜索和重试。 +- **验收:** 三种状态文案和操作互不混用。 + +### UX-10 共享加载状态语义 + +- **优先级 / 状态:** P2 / 已完成 +- **范围:** 为共享 `EmptyState` 增加 loading 变体,并迁移知识库、智能心跳和 SSH Host。 +- **验收:** 加载状态统一输出 `role="status"`、`aria-live="polite"` 和 `aria-busy="true"`。 + +### UX-11 更新检查错误反馈 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** + - 启动检查失败进入去重后的全局错误通知。 + - 保留来源、网络和有界 HTTP 错误上下文。 + - 打开下载页失败也显示通知。 +- **验收:** 不再以空 `catch` 静默丢失用户可处理的更新错误。 + +### UX-12 更新源与自动检查解耦 + +- **优先级 / 状态:** P2 / 已完成 +- **范围:** + - 关闭启动检查后仍可选择 GitHub 或镜像。 + - 手动检查和下载继续使用所选来源。 + - 同步修订 `UI-DESIGN.md` 当前相反的规则。 +- **验收:** “关闭自动检查、切换镜像、手动检查”流程通过测试。 + +## 9. 诊断与质量 + +### OPS-01 桌面端有界诊断日志 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 轮转记录最近启动和 Runtime 失败的时间、组件、阶段和稳定错误码。 +- **限制:** 不记录 Prompt、凭据、文档正文或 Provider 原始响应。 +- **验收:** 重启后仍可定位最近一次人为制造的数据库或 Runtime 启动失败。 + +### OPS-02 远端 Agent 有界诊断日志 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** Detached Agent 保存有界轮转诊断,覆盖 Runtime 启动、退出、连接和恢复阶段。 +- **验收:** 人为制造远端失败后可导出根因阶段;关闭和轮转不会遗留无界文件。 +- **限制:** 不记录 accepted Prompt、模型密钥或完整模型配置。 + +### OPS-03 反馈可选附加诊断 + +- **优先级 / 状态:** P1 / 已完成 +- **范围:** 用户主动勾选后才附加诊断,提交前说明类别,保留现有截图和草稿重试。 +- **验收:** 默认反馈不附加诊断;勾选后只包含允许的有界记录。 +- **依赖:** `OPS-01`;本批明确不自动读取或附加 `OPS-02` 的远端 Agent 记录, + 远端记录通过 Agent 固定 CLI 独立导出。 + +### QA-01 真实 Runtime E2E + +- **优先级 / 状态:** P1 / 待开始 +- **范围:** + - 直连模型一次有界真实请求。 + - Bundled OpenCode 启动并执行文件任务。 + - Continue 启动并完成一次任务。 + - MCP 工具写入生产路径。 +- **验收:** 受保护发布候选任务运行通过,并记录精确模型调用次数和费用边界。 + +### QA-02 本地兼容 Provider E2E + +- **优先级 / 状态:** P1 / 待开始 +- **目标:** 常规 CI 无需公网或真实模型凭据也能覆盖打包 Runtime 启动和工具路径。 +- **范围:** 提供最小本地 OpenAI 兼容测试 Provider,覆盖流式响应和工具调用。 +- **验收:** SDK、Runtime 二进制、Provider 适配或工具写入回归会使常规 CI 失败。 + +### QA-03 Windows ARM64 覆盖纠正 + +- **优先级 / 状态:** P1 / 待开始 +- **范围:** + - 文档明确 Windows ARM64 当前为交叉打包。 + - 在真实设备验证 Electron、node-pty、Koffi、Canvas、OCR 和 Runtime 加载。 + - 有可用 ARM64 Runner 后再声明原生 CI。 +- **验收:** 文档和发布声明不再把文件架构验证描述为原生运行验证。 +- **外部依赖:** 真实 Windows ARM64 设备或 Runner。 + +## 10. 每批完成标准 + +一个事项只有同时满足以下条件才可标记为“已完成”: + +1. 正常 UI 操作到达生产实现并产生预期结果,不以 mock、注入 fake 或禁用入口代替。 +2. 已覆盖本事项声明的可达失败场景,用户输入和可恢复上下文不会被静默丢弃。 +3. 新增或更新聚焦测试;功能变化后运行: + + ```text + npm test + npm run typecheck + npm run lint + ``` + +4. 生产构建或发布脚本变化后运行 `npm run build`。 +5. 改动涉及 GoodBuddy Agent 或 Desktop-to-Agent 生产路径时,按 `AGENTS.md` 在共享 Linux x64 + Test Host 验证当前源码或候选包;Host 不可达时明确记录阻塞,不能标记完成。 +6. 检查并更新受影响的 PRD、架构、设计、功能矩阵、构建和运维文档。 +7. 在批次记录中填写实际验证命令、结果、剩余限制和对应提交。 + +## 11. 批次记录模板 + +开始一个批次时,在本节末尾复制以下模板: + +```markdown +### 批次 N:标题 + +- 状态:进行中 +- 范围:ITEM-01、ITEM-02 +- 明确不包含: +- 开始日期: +- 完成日期: +- 外部依赖: + +#### 验收结果 + +- [ ] 正常生产路径 +- [ ] 声明的失败与恢复路径 +- [ ] 聚焦测试 +- [ ] npm test +- [ ] npm run typecheck +- [ ] npm run lint +- [ ] npm run build(如适用) +- [ ] 真实 Host 或目标平台验证(如适用) +- [ ] 相关文档更新 + +#### 结果与剩余限制 + +- 提交: +- 验证: +- 剩余限制: +``` + +### 批次 1A:体验一致性与 P1 诊断 + +- 状态:已完成 +- 范围:`UX-01` 至 `UX-12`、`OPS-01` 至 `OPS-03` +- 明确不包含:`REL-*`、`FUN-*`、`PERF-*`、`QA-*`,以及初始 Renderer Bundle 优化 +- 开始日期:2026-08-31 +- 完成日期:2026-08-31 +- 外部依赖:无 + +#### 验收结果 + +- [x] 正常生产路径 +- [x] 声明的失败与恢复路径 +- [x] 聚焦测试 +- [x] `npm test` +- [x] `npm run typecheck` +- [x] `npm run lint` +- [x] `npm run build` +- [x] 共享 Linux x64 Host 的当前源码 Agent 候选验证 +- [x] 相关文档更新 + +#### 结果与剩余限制 + +- 提交:尚未提交 +- 验证:306 个测试文件、3322 项测试通过,49 项按既有条件跳过;Node/Web TypeScript、 + 完整 ESLint、生产构建和 Renderer Bundle 预算均通过。共享 Linux x64 Host 使用隔离 + installation 完成 bootstrap、attach、断开重连、controller resume、Runtime 进程启动、 + CLI 诊断导出、重启读取、权限和诊断写失败隔离;真实文本模型调用 0 次。 +- 剩余限制:真实 Host 本次没有通过 Agent-owned model bridge 执行 Prompt,因此未在该 + Host 上覆盖 Prompt 期间的 `runtime.starting/runtime.started` 诊断;三文件真实轮转仍由 + 聚焦测试覆盖。正式发布、功能增强、性能基线和 QA 路线均不属于本批。 diff --git a/src/agent-daemon/cli.test.ts b/src/agent-daemon/cli.test.ts index 56767f92..34d8115e 100644 --- a/src/agent-daemon/cli.test.ts +++ b/src/agent-daemon/cli.test.ts @@ -33,6 +33,7 @@ import { import { RuntimeBundleRegistry } from './runtime-bundle-registry' import type { VerifiedRuntimeBundle } from './runtime-bundle-verifier' import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts' +import { AgentDiagnosticLog } from './diagnostic-log' const temporaryPaths: string[] = [] @@ -246,6 +247,58 @@ describe('Agent CLI fixed command contract', () => { expect(bootstrap).toHaveBeenCalledTimes(2) }) + it('exports only bounded diagnostics for a fixed installation ID as JSONL', async () => { + const root = privateTemporaryDirectory() + const stateDirectory = resolve(root, 'state', 'install-1') + mkdirSync(stateDirectory, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + chmodSync(resolve(root, 'state'), 0o700) + chmodSync(stateDirectory, 0o700) + } + const log = new AgentDiagnosticLog(stateDirectory, { + pid: 42 + }) + log.record('daemon.ready', { + daemonBootId: 'boot-1' + }) + log.record('runtime.start.failed', { + runtimeId: 'opencode', + error: new Error('private Prompt and /private/path') + }) + await log.flush() + const io = cliIo() + + const result = await runAgentCli( + ['diagnostics', '--installation-id', 'install-1'], + { + installationPaths: () => ({ + executablePath: resolve(root, 'agent', 'goodbuddy-agent'), + stateDirectory, + socketPath: resolve(root, 'run', 'agent.sock') + }), + io + } + ) + + expect(result, io.error.read()?.toString()).toBe(0) + const output = io.output.read()!.toString() + expect(output).not.toContain('private Prompt') + expect(output).not.toContain('/private/path') + expect( + output.trimEnd().split('\n').map((line) => JSON.parse(line)) + ).toEqual([ + expect.objectContaining({ + event: 'daemon.ready', + daemonBootId: 'boot-1' + }), + expect.objectContaining({ + event: 'runtime.start.failed', + runtimeId: 'opencode', + error: { name: 'Error' } + }) + ]) + }) + it('dispatches generated attach argv using only derived managed paths', async () => { const root = privateTemporaryDirectory() const paths: ManagedInstallationPaths = { diff --git a/src/agent-daemon/cli.ts b/src/agent-daemon/cli.ts index f6f3291b..b5ec67dd 100644 --- a/src/agent-daemon/cli.ts +++ b/src/agent-daemon/cli.ts @@ -53,6 +53,7 @@ import { DetachedAgentLifecycle, type DetachedAgentLifecycleOptions } from './detached-agent-lifecycle' +import { readAgentDiagnostics } from './diagnostic-log' type CliIo = { input: Readable @@ -141,6 +142,9 @@ export async function runAgentCli( case 'doctor': await runDoctor(rest, dependencies, io) return 0 + case 'diagnostics': + runDiagnostics(rest, dependencies, io) + return 0 case 'runtime': await runRuntime(rest, dependencies, io) return 0 @@ -152,7 +156,7 @@ export async function runAgentCli( return 0 } throw new Error( - 'Expected daemon, attach-or-bootstrap, doctor, a lifecycle action, runtime, or model-bridge-helper' + 'Expected daemon, attach-or-bootstrap, doctor, diagnostics, a lifecycle action, runtime, or model-bridge-helper' ) } } catch (error) { @@ -353,6 +357,7 @@ async function runDaemon( runtimeFactory: async ({ events, workspaces, + diagnostics, outputSink, blobSink }) => @@ -367,6 +372,7 @@ async function runDaemon( workspaces, outputSink, blobSink, + diagnostics, agentExecutablePath: paths.executablePath, installationId }) @@ -501,6 +507,22 @@ async function runDoctor( })}\n`) } +function runDiagnostics( + argv: readonly string[], + dependencies: AgentCliDependencies, + io: CliIo +): void { + const options = parseOptions(argv, ['installation-id']) + requireOptions(options, ['installation-id']) + const installationId = validateInstallationId( + options['installation-id']! + ) + const paths = resolveInstallationPaths(installationId, dependencies) + for (const record of readAgentDiagnostics(paths.stateDirectory)) { + io.output.write(`${JSON.stringify(record)}\n`) + } +} + async function runLifecycle( argv: readonly string[], dependencies: AgentCliDependencies, diff --git a/src/agent-daemon/daemon.ts b/src/agent-daemon/daemon.ts index dd871c96..7a10edaa 100644 --- a/src/agent-daemon/daemon.ts +++ b/src/agent-daemon/daemon.ts @@ -36,6 +36,7 @@ import { AgentUnsupportedError } from './errors' import { WorkspaceGitService } from './workspace-git-service' import { createWorkspaceProtocolMethods } from './workspace-protocol-methods' import { WorkspaceRegistry } from './workspace-registry' +import { AgentDiagnosticLog } from './diagnostic-log' export type AgentDaemonOptions = { installationId: string @@ -67,6 +68,7 @@ export type AgentDaemonOptions = { runtimeFactory?: (context: { events: EventJournal workspaces: WorkspaceRegistry + diagnostics: AgentDiagnosticLog outputSink: ( frame: AgentFrame, context: { @@ -109,6 +111,7 @@ export class AgentDaemon { readonly #runtimeProtocol?: AgentDaemonOptions['runtimeProtocol'] readonly #runtimeFactory?: AgentDaemonOptions['runtimeFactory'] readonly #reportError: (message: string, error: unknown) => void + readonly #diagnostics: AgentDiagnosticLog #state: 'starting' | 'ready' | 'stopped' = 'stopped' #endpoint?: PrivateEndpoint #events?: EventJournal @@ -136,8 +139,11 @@ export class AgentDaemon { ) this.#stateDirectory = resolve(options.stateDirectory) this.#socketPath = resolve(options.socketPath) - this.#peerIdentityProvider = options.peerIdentityProvider this.#now = options.now ?? Date.now + this.#diagnostics = new AgentDiagnosticLog(this.#stateDirectory, { + now: this.#now + }) + this.#peerIdentityProvider = options.peerIdentityProvider this.#gitExecutable = options.gitExecutable this.#workspaceRequestTimeoutMs = options.workspaceRequestTimeoutMs this.#runtimeProtocol = options.runtimeProtocol @@ -186,6 +192,9 @@ export class AgentDaemon { this.#state = 'starting' try { ensurePrivateDirectory(this.#stateDirectory) + this.#diagnostics.tryRecord('daemon.starting', { + daemonBootId: this.#bootId + }) ensurePrivateDirectoryTree( resolve(this.#stateDirectory, 'journal'), this.#stateDirectory @@ -234,6 +243,7 @@ export class AgentDaemon { await this.#runtimeFactory?.({ events: this.#events, workspaces: this.#workspaces, + diagnostics: this.#diagnostics, outputSink: async (frame) => { if (protocolBox.current === undefined) { throw new Error( @@ -277,6 +287,10 @@ export class AgentDaemon { connectionId, category }) => { + this.#diagnostics.tryRecord('connection.failed', { + daemonBootId: this.#bootId, + reason: category + }) try { writePrivateFileAtomic( resolve( @@ -299,6 +313,20 @@ export class AgentDaemon { ) } }, + onConnectionClose: ({ category }) => { + if (category === undefined) { + this.#diagnostics.tryRecord('connection.closed', { + daemonBootId: this.#bootId, + reason: 'peer-closed' + }) + } + }, + onRecovery: ({ outcome, reason }) => { + this.#diagnostics.tryRecord(`recovery.${outcome}`, { + daemonBootId: this.#bootId, + ...(reason === undefined ? {} : { reason }) + }) + }, methods: mergeProtocolMethods( workspaceMethods, this.#activeRuntimeProtocol?.methods @@ -317,18 +345,40 @@ export class AgentDaemon { daemonBootId: this.#bootId, protocol: this.#protocol, onAttach: ({ socket, controller }) => { + this.#diagnostics.tryRecord('connection.attached', { + daemonBootId: this.#bootId + }) protocol.accept(socket, controller) + }, + onConnectionFailure: (error) => { + this.#diagnostics.tryRecord('connection.failed', { + daemonBootId: this.#bootId, + reason: 'authentication', + error + }) } }) await this.#endpoint.listen() this.#state = 'ready' + this.#diagnostics.tryRecord('daemon.ready', { + daemonBootId: this.#bootId + }) } catch (error) { + this.#diagnostics.tryRecord('daemon.start.failed', { + daemonBootId: this.#bootId, + error + }) await this.stop() throw error } } async stop(): Promise { + if (this.#state !== 'stopped') { + this.#diagnostics.tryRecord('daemon.stopping', { + daemonBootId: this.#bootId + }) + } const endpoint = this.#endpoint const events = this.#events const workspaces = this.#workspaces @@ -337,10 +387,14 @@ export class AgentDaemon { this.#events = undefined this.#workspaces = undefined this.#activeRuntimeProtocol = undefined + let stopError: unknown try { if (endpoint !== undefined) { await endpoint.close() } + } catch (error) { + stopError = error + throw error } finally { if (runtimeProtocol?.dispose !== undefined) { await Promise.resolve(runtimeProtocol.dispose()).catch( @@ -352,10 +406,25 @@ export class AgentDaemon { workspaces?.closeAll() events?.close() this.#state = 'stopped' + this.#diagnostics.tryRecord( + stopError === undefined + ? 'daemon.stopped' + : 'daemon.stop.failed', + { + daemonBootId: this.#bootId, + ...(stopError === undefined ? {} : { error: stopError }) + } + ) + await this.#diagnostics.dispose() } } #reportRuntimeShutdownError(error: unknown): void { + this.#diagnostics.tryRecord('daemon.stop.failed', { + daemonBootId: this.#bootId, + reason: 'runtime-dispose', + error + }) try { this.#reportError('Agent Runtime shutdown failed', error) } catch (reportError) { diff --git a/src/agent-daemon/detached-agent-lifecycle.test.ts b/src/agent-daemon/detached-agent-lifecycle.test.ts index bb6348f2..5c35bf00 100644 --- a/src/agent-daemon/detached-agent-lifecycle.test.ts +++ b/src/agent-daemon/detached-agent-lifecycle.test.ts @@ -25,6 +25,7 @@ import { } from './linux-process-identity' import { writePrivateFileAtomic } from './managed-paths' import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier' +import { readAgentDiagnostics } from './diagnostic-log' const parentPid = 1101 const childPid = 2202 @@ -73,6 +74,16 @@ describe('detached Agent lifecycle', () => { ) expect(harness.unref).toHaveBeenCalledOnce() expect(harness.probe).toHaveBeenCalled() + expect( + readAgentDiagnostics(harness.stateDirectory).map( + (record) => record.event + ) + ).toEqual( + expect.arrayContaining([ + 'detached.launching', + 'detached.spawned' + ]) + ) }) it('creates the managed state parent before bootstrapping', async () => { diff --git a/src/agent-daemon/detached-agent-lifecycle.ts b/src/agent-daemon/detached-agent-lifecycle.ts index 6f35ed0d..cb85be2f 100644 --- a/src/agent-daemon/detached-agent-lifecycle.ts +++ b/src/agent-daemon/detached-agent-lifecycle.ts @@ -23,6 +23,7 @@ import { writePrivateFileAtomic } from './managed-paths' import type { AttachWelcome } from '../shared/agent-protocol' +import { AgentDiagnosticLog } from './diagnostic-log' const DEFAULT_READINESS_TIMEOUT_MS = 15_000 const DEFAULT_STOP_TIMEOUT_MS = 5_000 @@ -140,6 +141,7 @@ export class DetachedAgentLifecycle { readonly #retireLegacyInstallation?: ( verified: VerifiedInstalledAgentBundle ) => void | Promise + readonly #diagnostics: AgentDiagnosticLog constructor(options: DetachedAgentLifecycleOptions) { this.#installationId = validateInstallationId(options.installationId) @@ -156,6 +158,10 @@ export class DetachedAgentLifecycle { this.#spawnDetached = options.spawnDetached ?? spawnDetachedProcess this.#currentPid = positivePid(options.currentPid ?? process.pid) this.#now = options.now ?? Date.now + this.#diagnostics = new AgentDiagnosticLog(this.#stateDirectory, { + now: this.#now, + pid: this.#currentPid + }) this.#sleep = options.sleep ?? (async (milliseconds) => @@ -215,6 +221,7 @@ export class DetachedAgentLifecycle { const launchArgv = ['daemon', '--installation-id', this.#installationId] const runtimePath = expectedRuntimePath(verified) + this.#diagnostics.tryRecord('detached.launching') const child = this.#spawnDetached(this.#executablePath, launchArgv, { env: detachedEnvironment(this.#environment), detached: true, @@ -226,6 +233,7 @@ export class DetachedAgentLifecycle { throw new Error('Detached Agent spawn did not return a process ID') } child.unref() + this.#diagnostics.tryRecord('detached.spawned') const processIdentity = await this.#waitForProcessIdentity( child.pid, runtimePath @@ -246,8 +254,18 @@ export class DetachedAgentLifecycle { throw new Error('Detached Agent readiness timed out') } return ready + } catch (error) { + this.#diagnostics.tryRecord('recovery.failed', { + reason: 'bootstrap', + error + }) + throw error } finally { - releaseLock() + try { + releaseLock() + } finally { + await this.#diagnostics.flush() + } } } diff --git a/src/agent-daemon/diagnostic-log.test.ts b/src/agent-daemon/diagnostic-log.test.ts new file mode 100644 index 00000000..d534e4c1 --- /dev/null +++ b/src/agent-daemon/diagnostic-log.test.ts @@ -0,0 +1,314 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + AGENT_DIAGNOSTIC_DIRECTORY_NAME, + AgentDiagnosticLog, + readAgentDiagnostics +} from './diagnostic-log' + +const temporaryPaths: string[] = [] + +afterEach(() => { + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } +}) + +describe('Agent diagnostic log', () => { + it('rotates within fixed file and byte bounds and remains readable after restart', async () => { + const stateDirectory = temporaryStateDirectory() + let now = Date.UTC(2026, 7, 31) + const first = new AgentDiagnosticLog(stateDirectory, { + maximumFileBytes: 512, + fileCount: 2, + now: () => now, + pid: 42 + }) + + for (let index = 0; index < 20; index += 1) { + first.record('connection.failed', { + daemonBootId: `boot-${index}`, + reason: 'socket-error', + error: Object.assign(new Error(`failure-${index}`), { + code: 'ECONNRESET' + }) + }) + now += 1_000 + } + await first.flush() + + const directory = resolve( + stateDirectory, + AGENT_DIAGNOSTIC_DIRECTORY_NAME + ) + const files = readdirSync(directory) + expect(files).toHaveLength(2) + for (const file of files) { + expect(statSync(resolve(directory, file)).size).toBeLessThanOrEqual(512) + } + + const restarted = new AgentDiagnosticLog(stateDirectory, { + maximumFileBytes: 512, + fileCount: 2, + pid: 43 + }) + const records = restarted.read() + expect(records.length).toBeGreaterThan(0) + expect(records.at(-1)).toMatchObject({ + event: 'connection.failed', + daemonBootId: 'boot-19', + error: { + name: 'Error', + code: 'ECONNRESET' + } + }) + expect(records.some((record) => record.daemonBootId === 'boot-0')).toBe( + false + ) + }) + + it('persists only allowlisted metadata and never error messages or sensitive payloads', async () => { + const stateDirectory = temporaryStateDirectory() + const secretPrompt = 'accepted Prompt: summarize private-file.txt' + const secretKey = 'sk-private-model-key' + const secretConfig = '{"providerUrl":"https://private.invalid"}' + const secretEnvironment = 'GOODBUDDY_SECRET=hidden' + const secretSsh = 'ssh -i /home/user/private-key user@host' + const secretOutput = 'MODEL_OUTPUT_PRIVATE' + const error = Object.assign( + new Error( + [ + secretPrompt, + secretKey, + secretConfig, + secretEnvironment, + secretSsh, + secretOutput + ].join(' ') + ), + { + code: secretKey, + name: secretConfig + } + ) + const log = new AgentDiagnosticLog(stateDirectory, { + pid: 42 + }) + + log.record('runtime.start.failed', { + runtimeId: 'opencode', + workMode: 'execute', + error + }) + await log.flush() + + const raw = readdirSync( + resolve(stateDirectory, AGENT_DIAGNOSTIC_DIRECTORY_NAME) + ) + .map((name) => + readFileSync( + resolve( + stateDirectory, + AGENT_DIAGNOSTIC_DIRECTORY_NAME, + name + ), + 'utf8' + ) + ) + .join('') + for (const sensitive of [ + secretPrompt, + secretKey, + secretConfig, + secretEnvironment, + secretSsh, + secretOutput + ]) { + expect(raw).not.toContain(sensitive) + } + expect(JSON.parse(raw)).toEqual({ + formatVersion: 1, + timestamp: expect.any(String), + level: 'error', + event: 'runtime.start.failed', + pid: 42, + runtimeId: 'opencode', + workMode: 'execute', + error: { + name: 'Error' + } + }) + expect(readAgentDiagnostics(stateDirectory)).toHaveLength(1) + }) + + it('keeps a single-file configuration bounded', async () => { + const stateDirectory = temporaryStateDirectory() + const log = new AgentDiagnosticLog(stateDirectory, { + maximumFileBytes: 512, + fileCount: 1, + pid: 42 + }) + + for (let index = 0; index < 20; index += 1) { + log.record('daemon.ready', { + daemonBootId: `boot-${index}` + }) + } + await log.flush() + + const files = readdirSync( + resolve(stateDirectory, AGENT_DIAGNOSTIC_DIRECTORY_NAME) + ) + expect(files).toHaveLength(1) + expect( + statSync( + resolve( + stateDirectory, + AGENT_DIAGNOSTIC_DIRECTORY_NAME, + files[0]! + ) + ).size + ).toBeLessThanOrEqual(512) + }) + + it('keeps the default three 64 KiB private JSONL files bounded', async () => { + const stateDirectory = temporaryStateDirectory() + const log = new AgentDiagnosticLog(stateDirectory, { + pid: 42 + }) + + for (let batch = 0; batch < 50; batch += 1) { + for (let index = 0; index < 60; index += 1) { + log.record('connection.failed', { + daemonBootId: `boot-${batch}-${index}`, + reason: 'socket-error', + error: Object.assign(new Error('private failure'), { + code: 'ECONNRESET' + }) + }) + } + await log.flush() + } + + const directory = resolve( + stateDirectory, + AGENT_DIAGNOSTIC_DIRECTORY_NAME + ) + const files = readdirSync(directory).sort() + expect(files).toEqual([ + 'agent-diagnostics.jsonl', + 'agent-diagnostics.jsonl.1', + 'agent-diagnostics.jsonl.2' + ]) + for (const file of files) { + const path = resolve(directory, file) + expect(statSync(path).size).toBeLessThanOrEqual(64 * 1024) + for (const line of readFileSync(path, 'utf8').trimEnd().split('\n')) { + expect(() => JSON.parse(line)).not.toThrow() + } + if (process.platform !== 'win32') { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + } + if (process.platform !== 'win32') { + expect(statSync(directory).mode & 0o777).toBe(0o700) + } + }) + + it('drops records beyond the fixed queue bound', async () => { + const stateDirectory = temporaryStateDirectory() + const log = new AgentDiagnosticLog(stateDirectory, { + maximumQueuedRecords: 4, + pid: 42 + }) + + for (let index = 0; index < 20; index += 1) { + log.tryRecord('daemon.ready', { + daemonBootId: `boot-${index}` + }) + } + await log.flush() + + expect(log.read().map((record) => record.daemonBootId)).toEqual([ + 'boot-0', + 'boot-1', + 'boot-2', + 'boot-3' + ]) + }) + + it('serializes independent process writers without a size cache', async () => { + const stateDirectory = temporaryStateDirectory() + const daemonLog = new AgentDiagnosticLog(stateDirectory, { + pid: 42 + }) + const attachLog = new AgentDiagnosticLog(stateDirectory, { + pid: 43 + }) + + daemonLog.tryRecord('daemon.ready', { + daemonBootId: 'daemon-boot' + }) + attachLog.tryRecord('detached.spawned') + await Promise.all([daemonLog.flush(), attachLog.flush()]) + + expect( + readAgentDiagnostics(stateDirectory).map((record) => record.pid).sort() + ).toEqual([42, 43]) + }) + + it('does not touch the disk synchronously from tryRecord', async () => { + const stateDirectory = temporaryStateDirectory() + const log = new AgentDiagnosticLog(stateDirectory, { + pid: 42 + }) + const directory = resolve( + stateDirectory, + AGENT_DIAGNOSTIC_DIRECTORY_NAME + ) + + log.tryRecord('connection.attached') + + expect(existsSync(directory)).toBe(false) + await log.flush() + expect(existsSync(directory)).toBe(true) + }) + + it('isolates asynchronous write failures from runtime callers', async () => { + const root = temporaryStateDirectory() + const unavailableStateDirectory = resolve(root, 'missing-state') + const log = new AgentDiagnosticLog(unavailableStateDirectory, { + pid: 42 + }) + let runtimeContinued = false + + expect(() => { + log.tryRecord('runtime.start.failed', { + error: new Error('private failure') + }) + runtimeContinued = true + }).not.toThrow() + await expect(log.flush()).resolves.toBeUndefined() + + expect(runtimeContinued).toBe(true) + }) +}) + +function temporaryStateDirectory(): string { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-diagnostics-')) + temporaryPaths.push(root) + if (process.platform !== 'win32') { + chmodSync(root, 0o700) + } + return root +} diff --git a/src/agent-daemon/diagnostic-log.ts b/src/agent-daemon/diagnostic-log.ts new file mode 100644 index 00000000..b2d64e43 --- /dev/null +++ b/src/agent-daemon/diagnostic-log.ts @@ -0,0 +1,608 @@ +import { constants } from 'node:fs' +import { + lstat, + open, + rename +} from 'node:fs/promises' +import { resolve } from 'node:path' +import { agentIdentifierSchema } from '../shared/agent-protocol' +import { + boundedInteger, + isNodeError, + settleWithin +} from './async-utils' +import { + assertPrivateRegularFile, + ensurePrivateDirectory, + ensurePrivateDirectoryTree, + readPrivateFile, + unlinkOwnedPrivateFile +} from './managed-paths' + +export const AGENT_DIAGNOSTIC_DIRECTORY_NAME = 'diagnostics' +export const AGENT_DIAGNOSTIC_FILE_NAME = 'agent-diagnostics.jsonl' +export const DEFAULT_AGENT_DIAGNOSTIC_MAXIMUM_FILE_BYTES = 64 * 1024 +export const DEFAULT_AGENT_DIAGNOSTIC_FILE_COUNT = 3 +export const DEFAULT_AGENT_DIAGNOSTIC_MAXIMUM_QUEUED_RECORDS = 64 +export const DEFAULT_AGENT_DIAGNOSTIC_FLUSH_TIMEOUT_MS = 1_000 +const AGENT_DIAGNOSTIC_LOCK_FILE_NAME = '.agent-diagnostics.lock' +const AGENT_DIAGNOSTIC_LOCK_ATTEMPTS = 10 +const AGENT_DIAGNOSTIC_LOCK_RETRY_MS = 10 + +export type AgentDiagnosticEvent = + | 'daemon.starting' + | 'daemon.ready' + | 'daemon.start.failed' + | 'daemon.stopping' + | 'daemon.stopped' + | 'daemon.stop.failed' + | 'detached.launching' + | 'detached.spawned' + | 'connection.attached' + | 'connection.closed' + | 'connection.failed' + | 'recovery.started' + | 'recovery.succeeded' + | 'recovery.failed' + | 'runtime.starting' + | 'runtime.started' + | 'runtime.start.failed' + | 'runtime.exited' + +export type AgentDiagnosticRecord = { + formatVersion: 1 + timestamp: string + level: 'info' | 'error' + event: AgentDiagnosticEvent + pid: number + daemonBootId?: string + reason?: string + runtimeId?: string + workMode?: 'ask' | 'execute' + outcome?: 'completed' | 'failed' | 'cancelled' | 'interrupted' | 'outcome-unknown' + error?: { + name: string + code?: string + } +} + +export type AgentDiagnosticInput = { + daemonBootId?: string + reason?: string + runtimeId?: string + workMode?: 'ask' | 'execute' + outcome?: AgentDiagnosticRecord['outcome'] + error?: unknown +} + +export type AgentDiagnosticLogOptions = { + maximumFileBytes?: number + fileCount?: number + maximumQueuedRecords?: number + now?: () => number + pid?: number +} + +/** + * A small, process-safe-enough JSONL diagnostic trail for detached operation. + * + * Callers can provide only fixed operational metadata. Arbitrary error + * messages are deliberately not persisted because they can contain prompts, + * credentials, command lines, paths, environment values, or Runtime output. + */ +export class AgentDiagnosticLog { + readonly directoryPath: string + readonly currentFilePath: string + readonly #maximumFileBytes: number + readonly #fileCount: number + readonly #maximumQueuedRecords: number + readonly #now: () => number + readonly #pid: number + readonly #queuedLines: string[] = [] + #pendingRecordCount = 0 + #drainPromise?: Promise + #disposed = false + + constructor( + stateDirectoryInput: string, + options: AgentDiagnosticLogOptions = {} + ) { + const stateDirectory = resolve(stateDirectoryInput) + this.directoryPath = diagnosticDirectoryForStateDirectory(stateDirectory) + this.currentFilePath = resolve( + this.directoryPath, + AGENT_DIAGNOSTIC_FILE_NAME + ) + this.#maximumFileBytes = boundedInteger( + options.maximumFileBytes ?? + DEFAULT_AGENT_DIAGNOSTIC_MAXIMUM_FILE_BYTES, + 512, + 1024 * 1024, + 'Agent diagnostic file size' + ) + this.#fileCount = boundedInteger( + options.fileCount ?? DEFAULT_AGENT_DIAGNOSTIC_FILE_COUNT, + 1, + 8, + 'Agent diagnostic file count' + ) + this.#maximumQueuedRecords = boundedInteger( + options.maximumQueuedRecords ?? + DEFAULT_AGENT_DIAGNOSTIC_MAXIMUM_QUEUED_RECORDS, + 1, + 256, + 'Agent diagnostic queue size' + ) + this.#now = options.now ?? Date.now + this.#pid = boundedInteger( + options.pid ?? process.pid, + 1, + Number.MAX_SAFE_INTEGER, + 'Agent diagnostic process ID' + ) + } + + record(event: AgentDiagnosticEvent, input: AgentDiagnosticInput = {}): void { + if (this.#disposed) { + throw new Error('Agent diagnostic log is disposed') + } + const line = this.#normalizeLine(event, input) + if (!this.#enqueue(line)) { + throw new Error('Agent diagnostic queue is full') + } + } + + tryRecord( + event: AgentDiagnosticEvent, + input: AgentDiagnosticInput = {} + ): void { + try { + if (this.#disposed) { + return + } + // Normalize now so the queue retains only bounded JSON, never the + // original Error object or any message that it may contain. + this.#enqueue(this.#normalizeLine(event, input)) + } catch { + // Diagnostics must never interfere with daemon, attach, or Runtime I/O. + } + } + + async flush( + timeoutMs = DEFAULT_AGENT_DIAGNOSTIC_FLUSH_TIMEOUT_MS + ): Promise { + const boundedTimeout = boundedInteger( + timeoutMs, + 1, + 30_000, + 'Agent diagnostic flush timeout' + ) + await settleWithin(this.#waitForIdle(), boundedTimeout) + } + + async dispose( + timeoutMs = DEFAULT_AGENT_DIAGNOSTIC_FLUSH_TIMEOUT_MS + ): Promise { + this.#disposed = true + await this.flush(timeoutMs) + } + + read(): AgentDiagnosticRecord[] { + return readAgentDiagnostics(resolve(this.directoryPath, '..'), { + maximumFileBytes: this.#maximumFileBytes, + fileCount: this.#fileCount + }) + } + + #normalizeLine( + event: AgentDiagnosticEvent, + input: AgentDiagnosticInput + ): string { + const record: AgentDiagnosticRecord = { + formatVersion: 1, + timestamp: new Date(this.#now()).toISOString(), + level: event.endsWith('.failed') ? 'error' : 'info', + event, + pid: this.#pid, + ...optionalToken('daemonBootId', input.daemonBootId, 128), + ...optionalToken('reason', input.reason, 96), + ...optionalToken('runtimeId', input.runtimeId, 64), + ...(input.workMode === undefined + ? {} + : { workMode: input.workMode }), + ...(input.outcome === undefined + ? {} + : { outcome: input.outcome }), + ...(input.error === undefined + ? {} + : { error: summarizeDiagnosticError(input.error) }) + } + const line = `${JSON.stringify(record)}\n` + if (Buffer.byteLength(line, 'utf8') > this.#maximumFileBytes) { + throw new Error('Agent diagnostic record exceeds its file bound') + } + return line + } + + #enqueue(line: string): boolean { + if (this.#pendingRecordCount >= this.#maximumQueuedRecords) { + return false + } + this.#queuedLines.push(line) + this.#pendingRecordCount += 1 + this.#scheduleDrain() + return true + } + + #scheduleDrain(): void { + if (this.#drainPromise !== undefined) { + return + } + this.#drainPromise = Promise.resolve() + .then(async () => await this.#drain()) + .catch(() => { + // Diagnostic write failures are deliberately isolated from callers. + }) + .finally(() => { + this.#drainPromise = undefined + if (this.#queuedLines.length > 0) { + this.#scheduleDrain() + } + }) + } + + async #drain(): Promise { + while (this.#queuedLines.length > 0) { + const lines = this.#queuedLines.splice(0) + try { + await this.#writeLines(lines) + } catch { + // Drop this bounded batch. Diagnostics may not affect the protocol. + } finally { + this.#pendingRecordCount -= lines.length + } + } + } + + async #waitForIdle(): Promise { + while (this.#pendingRecordCount > 0) { + const drain = this.#drainPromise + if (drain === undefined) { + this.#scheduleDrain() + } else { + await drain + } + } + } + + async #writeLines(lines: readonly string[]): Promise { + ensurePrivateDirectory(resolve(this.directoryPath, '..'), { + create: false + }) + ensurePrivateDirectoryTree( + this.directoryPath, + resolve(this.directoryPath, '..') + ) + const releaseLock = await acquireDiagnosticLock( + resolve(this.directoryPath, AGENT_DIAGNOSTIC_LOCK_FILE_NAME) + ) + try { + let nextLine = 0 + while (nextLine < lines.length) { + let currentBytes = await privateFileSize(this.currentFilePath) + if (currentBytes >= this.#maximumFileBytes) { + await this.#rotate() + currentBytes = 0 + } + const availableBytes = this.#maximumFileBytes - currentBytes + let batchBytes = 0 + let batchEnd = nextLine + while (batchEnd < lines.length) { + const lineBytes = Buffer.byteLength(lines[batchEnd]!, 'utf8') + if (batchBytes + lineBytes > availableBytes) { + break + } + batchBytes += lineBytes + batchEnd += 1 + } + if (batchEnd === nextLine) { + await this.#rotate() + continue + } + await appendPrivateFile( + this.currentFilePath, + lines.slice(nextLine, batchEnd).join('') + ) + nextLine = batchEnd + } + } finally { + releaseLock() + } + } + + async #rotate(): Promise { + const oldest = rotatedPath( + this.currentFilePath, + this.#fileCount - 1 + ) + try { + unlinkOwnedPrivateFile(oldest) + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + throw error + } + } + for (let index = this.#fileCount - 2; index >= 0; index -= 1) { + const source = rotatedPath(this.currentFilePath, index) + const target = rotatedPath(this.currentFilePath, index + 1) + try { + assertPrivateRegularFile(source) + await rename(source, target) + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + throw error + } + } + } + } +} + +async function acquireDiagnosticLock( + path: string +): Promise<() => void> { + for ( + let attempt = 0; + attempt < AGENT_DIAGNOSTIC_LOCK_ATTEMPTS; + attempt += 1 + ) { + try { + const descriptor = await open( + path, + constants.O_CREAT | + constants.O_EXCL | + constants.O_WRONLY | + (constants.O_NOFOLLOW ?? 0), + 0o600 + ) + try { + if (process.platform !== 'win32') { + await descriptor.chmod(0o600) + } + } finally { + await descriptor.close() + } + assertPrivateRegularFile(path) + return () => { + unlinkOwnedPrivateFile(path) + } + } catch (error) { + if (!isNodeError(error) || error.code !== 'EEXIST') { + throw error + } + assertPrivateRegularFile(path) + await new Promise((resolveRetry) => { + setTimeout(resolveRetry, AGENT_DIAGNOSTIC_LOCK_RETRY_MS) + }) + } + } + throw new Error('Agent diagnostic file is busy') +} + +async function privateFileSize(path: string): Promise { + try { + assertPrivateRegularFile(path) + return (await lstat(path)).size + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + throw error + } + return 0 + } +} + +async function appendPrivateFile( + path: string, + contents: string +): Promise { + const descriptor = await open( + path, + constants.O_CREAT | + constants.O_APPEND | + constants.O_WRONLY | + (constants.O_NOFOLLOW ?? 0), + 0o600 + ) + try { + if (process.platform !== 'win32') { + await descriptor.chmod(0o600) + } + await descriptor.writeFile(contents, 'utf8') + } finally { + await descriptor.close() + } + assertPrivateRegularFile(path) +} + +export function diagnosticDirectoryForStateDirectory( + stateDirectoryInput: string +): string { + return resolve(stateDirectoryInput, AGENT_DIAGNOSTIC_DIRECTORY_NAME) +} + +export function readAgentDiagnostics( + stateDirectoryInput: string, + options: Pick< + AgentDiagnosticLogOptions, + 'maximumFileBytes' | 'fileCount' + > = {} +): AgentDiagnosticRecord[] { + const directory = diagnosticDirectoryForStateDirectory( + resolve(stateDirectoryInput) + ) + const current = resolve(directory, AGENT_DIAGNOSTIC_FILE_NAME) + const maximumFileBytes = + boundedInteger( + options.maximumFileBytes ?? + DEFAULT_AGENT_DIAGNOSTIC_MAXIMUM_FILE_BYTES, + 512, + 1024 * 1024, + 'Agent diagnostic file size' + ) + const fileCount = + boundedInteger( + options.fileCount ?? DEFAULT_AGENT_DIAGNOSTIC_FILE_COUNT, + 1, + 8, + 'Agent diagnostic file count' + ) + const output: AgentDiagnosticRecord[] = [] + for (let index = fileCount - 1; index >= 0; index -= 1) { + const path = rotatedPath(current, index) + let contents: string + try { + contents = readPrivateFile(path, maximumFileBytes).toString('utf8') + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + continue + } + throw error + } + for (const line of contents.split('\n')) { + if (line.length === 0) { + continue + } + const parsed = parseDiagnosticRecord(line) + if (parsed !== undefined) { + output.push(parsed) + } + } + } + return output +} + +export function summarizeDiagnosticError(error: unknown): { + name: string + code?: string +} { + const name = diagnosticErrorName(error) + const candidate = + error !== null && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined + const code = + typeof candidate === 'string' && + SAFE_DIAGNOSTIC_ERROR_CODES.has(candidate) + ? candidate + : undefined + return { + name, + ...(code === undefined ? {} : { code }) + } +} + +function diagnosticErrorName(error: unknown): string { + if (error instanceof TypeError) { + return 'TypeError' + } + if (error instanceof RangeError) { + return 'RangeError' + } + if (error instanceof SyntaxError) { + return 'SyntaxError' + } + return error instanceof Error ? 'Error' : 'NonError' +} + +const SAFE_DIAGNOSTIC_ERROR_CODES = new Set([ + 'EACCES', + 'EADDRINUSE', + 'ECONNREFUSED', + 'ECONNRESET', + 'EEXIST', + 'EIO', + 'EISDIR', + 'ELOOP', + 'EMFILE', + 'ENAMETOOLONG', + 'ENFILE', + 'ENOENT', + 'ENOSPC', + 'ENOTDIR', + 'EPERM', + 'EPIPE', + 'EROFS', + 'ESRCH', + 'ETIMEDOUT' +] as const) + +function parseDiagnosticRecord(line: string): AgentDiagnosticRecord | undefined { + try { + const value: unknown = JSON.parse(line) + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) + ) { + return undefined + } + const record = value as Partial + if ( + record.formatVersion !== 1 || + typeof record.timestamp !== 'string' || + (record.level !== 'info' && record.level !== 'error') || + typeof record.event !== 'string' || + !isDiagnosticEvent(record.event) || + !Number.isSafeInteger(record.pid) || + (record.pid ?? 0) <= 0 + ) { + return undefined + } + return record as AgentDiagnosticRecord + } catch { + return undefined + } +} + +function rotatedPath(currentPath: string, index: number): string { + return index === 0 ? currentPath : `${currentPath}.${index}` +} + +function optionalToken( + key: Key, + value: string | undefined, + maximumLength: number +): Partial> { + if ( + value === undefined || + value.length > maximumLength || + !agentIdentifierSchema.safeParse(value).success + ) { + return {} + } + return { [key]: value } as Record +} + +function isDiagnosticEvent(value: string): value is AgentDiagnosticEvent { + return DIAGNOSTIC_EVENTS.has(value as AgentDiagnosticEvent) +} + +const DIAGNOSTIC_EVENTS = new Set([ + 'daemon.starting', + 'daemon.ready', + 'daemon.start.failed', + 'daemon.stopping', + 'daemon.stopped', + 'daemon.stop.failed', + 'detached.launching', + 'detached.spawned', + 'connection.attached', + 'connection.closed', + 'connection.failed', + 'recovery.started', + 'recovery.succeeded', + 'recovery.failed', + 'runtime.starting', + 'runtime.started', + 'runtime.start.failed', + 'runtime.exited' +]) diff --git a/src/agent-daemon/index.ts b/src/agent-daemon/index.ts index 02daed2a..fab19d90 100644 --- a/src/agent-daemon/index.ts +++ b/src/agent-daemon/index.ts @@ -6,6 +6,7 @@ export * from './cli' export * from './controller-registry' export * from './daemon' export * from './detached-agent-lifecycle' +export * from './diagnostic-log' export * from './direct-linux-stdio-process-owner' export * from './errors' export * from './entry' diff --git a/src/agent-daemon/private-endpoint.ts b/src/agent-daemon/private-endpoint.ts index 1b7d25f5..f5734fb7 100644 --- a/src/agent-daemon/private-endpoint.ts +++ b/src/agent-daemon/private-endpoint.ts @@ -76,6 +76,7 @@ export class PrivateEndpoint { readonly #challenge: InstallationChallengeVerifier readonly #controllers: ControllerRegistry readonly #onAttach: (attach: AuthenticatedAttach) => void | Promise + readonly #onConnectionFailure?: (error: unknown) => void readonly #installationId: string readonly #binaryDigest: string readonly #daemonBootId: string @@ -92,6 +93,7 @@ export class PrivateEndpoint { challenge: InstallationChallengeVerifier controllers: ControllerRegistry onAttach: (attach: AuthenticatedAttach) => void | Promise + onConnectionFailure?: (error: unknown) => void installationId: string binaryDigest: string daemonBootId: string @@ -103,6 +105,7 @@ export class PrivateEndpoint { this.#challenge = options.challenge this.#controllers = options.controllers this.#onAttach = options.onAttach + this.#onConnectionFailure = options.onConnectionFailure this.#installationId = agentIdentifierSchema.parse(options.installationId) this.#binaryDigest = sha256DigestSchema.parse(options.binaryDigest) this.#daemonBootId = agentIdentifierSchema.parse(options.daemonBootId) @@ -141,7 +144,10 @@ export class PrivateEndpoint { socket.once('close', () => { this.#sockets.delete(socket) }) - void this.#authenticate(socket).catch(() => socket.destroy()) + void this.#authenticate(socket).catch((error: unknown) => { + this.#onConnectionFailure?.(error) + socket.destroy() + }) }) await new Promise((resolve, reject) => { const server = this.#server diff --git a/src/agent-daemon/protocol-server.ts b/src/agent-daemon/protocol-server.ts index da904c46..6ca612fe 100644 --- a/src/agent-daemon/protocol-server.ts +++ b/src/agent-daemon/protocol-server.ts @@ -122,6 +122,15 @@ export class AgentProtocolServer { connectionId: string category: AgentProtocolFailureCategory }) => void + readonly #onConnectionClose?: (input: { + connectionId: string + category?: AgentProtocolFailureCategory + }) => void + readonly #onRecovery?: (input: { + connectionId: string + outcome: 'started' | 'succeeded' | 'failed' + reason?: 'invalid-request' | 'identity-mismatch' | 'takeover-rejected' + }) => void readonly #connections = new Map() constructor(options: { @@ -148,6 +157,15 @@ export class AgentProtocolServer { connectionId: string category: AgentProtocolFailureCategory }) => void + onConnectionClose?: (input: { + connectionId: string + category?: AgentProtocolFailureCategory + }) => void + onRecovery?: (input: { + connectionId: string + outcome: 'started' | 'succeeded' | 'failed' + reason?: 'invalid-request' | 'identity-mismatch' | 'takeover-rejected' + }) => void incomingQueueLimits?: Partial }) { this.#controllers = options.controllers @@ -159,6 +177,8 @@ export class AgentProtocolServer { this.#onBlobFrame = options.onBlobFrame this.#authorizeBlobFrame = options.authorizeBlobFrame this.#onProtocolFailure = options.onProtocolFailure + this.#onConnectionClose = options.onConnectionClose + this.#onRecovery = options.onRecovery this.#incomingQueueLimits = incomingQueueLimits( options.incomingQueueLimits ) @@ -239,12 +259,33 @@ export class AgentProtocolServer { }) }, 'controller/resume': async (params) => { - const request = controllerResumeRequestSchema.parse(params) + this.#onRecovery?.({ + connectionId: controller.connectionId, + outcome: 'started' + }) + let request: ReturnType< + typeof controllerResumeRequestSchema.parse + > + try { + request = controllerResumeRequestSchema.parse(params) + } catch (error) { + this.#onRecovery?.({ + connectionId: controller.connectionId, + outcome: 'failed', + reason: 'invalid-request' + }) + throw error + } const status = daemonStatusSchema.parse(await this.#status()) if ( request.daemonBootId !== status.daemonBootId || request.previousGeneration >= controller.generation ) { + this.#onRecovery?.({ + connectionId: controller.connectionId, + outcome: 'failed', + reason: 'identity-mismatch' + }) return controllerResumeResultSchema.parse({ resumed: false, generation: controller.generation, @@ -263,6 +304,11 @@ export class AgentProtocolServer { request ) } catch { + this.#onRecovery?.({ + connectionId: controller.connectionId, + outcome: 'failed', + reason: 'takeover-rejected' + }) return controllerResumeResultSchema.parse({ resumed: false, generation: controller.generation, @@ -276,6 +322,10 @@ export class AgentProtocolServer { this.#connections .get(request.previousConnectionId) ?.fence() + this.#onRecovery?.({ + connectionId: controller.connectionId, + outcome: 'succeeded' + }) return controllerResumeResultSchema.parse({ resumed: true, generation: resumed.generation, @@ -293,6 +343,10 @@ export class AgentProtocolServer { onBlobFrame: this.#onBlobFrame, incomingQueueLimits: this.#incomingQueueLimits, onClose: (category) => { + this.#onConnectionClose?.({ + connectionId: controller.connectionId, + ...(category === undefined ? {} : { category }) + }) if (category !== undefined) { this.#onProtocolFailure?.({ connectionId: controller.connectionId, diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index 7c1dd948..3dd1ab5b 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -273,6 +273,7 @@ function harness(input: { dispatch: ReturnType finalizePrompt: ReturnType } + launchError?: Error } = {}) { const workMode = input.workMode ?? 'ask' let now = input.now ?? 1_000 @@ -284,6 +285,9 @@ function harness(input: { scratch: string }> = [] const lifecycle: string[] = [] + const diagnostics = { + tryRecord: vi.fn() + } const bridgeCloses = vi.fn(async () => { if (input.bridgeCloseError) { throw new Error('broker close failed') @@ -315,6 +319,9 @@ function harness(input: { })), launchProcess: vi.fn(async (launch) => { lifecycle.push('launch') + if (input.launchError !== undefined) { + throw input.launchError + } launchModelBridges.push(launch.modelBridge) launches.push({ workMode: launch.workMode, @@ -325,6 +332,7 @@ function harness(input: { : process }), now: () => now, + diagnostics, outputSink: vi.fn(async (frame) => { await input.outputGate outputFrames.push(Buffer.from(frame.payload).toString()) @@ -366,6 +374,7 @@ function harness(input: { process, launches, lifecycle, + diagnostics, bridgeCloses, blobSink, get bridgeDispatch() { @@ -876,6 +885,56 @@ describe('RuntimeAcpBackend', () => { ) ) expect(fixture.bridgeCloses).toHaveBeenCalledOnce() + await vi.waitFor(() => + expect(fixture.diagnostics.tryRecord).toHaveBeenCalledWith( + 'runtime.exited', + { + runtimeId: 'opencode', + workMode: 'ask', + outcome: 'failed' + } + ) + ) + expect(fixture.diagnostics.tryRecord).toHaveBeenCalledWith( + 'runtime.starting', + { + runtimeId: 'opencode', + workMode: 'ask' + } + ) + expect(fixture.diagnostics.tryRecord).toHaveBeenCalledWith( + 'runtime.started', + { + runtimeId: 'opencode', + workMode: 'ask' + } + ) + await fixture.backend.dispose() + }) + + it('records a redacted Runtime launch failure at the unified preparation point', async () => { + const launchError = new Error( + 'accepted Prompt and API key must never be persisted' + ) + const fixture = harness({ launchError }) + await open(fixture) + + await expect( + invoke( + fixture, + 'runtime/preparePrompt', + fixture.preparation() + ) + ).rejects.toThrow('Runtime process launch failed') + + expect(fixture.diagnostics.tryRecord).toHaveBeenCalledWith( + 'runtime.start.failed', + { + runtimeId: 'opencode', + workMode: 'ask', + error: launchError + } + ) await fixture.backend.dispose() }) diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index 5e617f79..b85866ab 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -22,6 +22,10 @@ import { type AgentFrame } from '../shared/agent-protocol' import { canonicalJson } from '../shared/agent-protocol/canonical' +import type { + AgentDiagnosticLog, + AgentDiagnosticRecord +} from './diagnostic-log' import { acpJournalCursorSchema, remoteOwnedPromptAttachRequestSchema, @@ -279,6 +283,7 @@ export type RuntimeAcpBackendOptions = { }) => ModelBridgeBrokerServer semanticPrompts?: SemanticPromptStore modelGateway?: AgentModelGateway + diagnostics?: Pick now?: () => number limits?: Partial } @@ -1106,6 +1111,10 @@ export class RuntimeAcpBackend { if (binding.process === undefined) { let process: RuntimeAcpProcessOwner + this.#options.diagnostics?.tryRecord('runtime.starting', { + runtimeId: verified.manifest.runtimeId, + workMode: preparation.workMode + }) try { process = await this.#options.launchProcess({ manifest: verified.manifest, @@ -1126,7 +1135,12 @@ export class RuntimeAcpBackend { }) }) assertProcessIdentity(process.identity) - } catch { + } catch (error) { + this.#options.diagnostics?.tryRecord('runtime.start.failed', { + runtimeId: verified.manifest.runtimeId, + workMode: preparation.workMode, + error + }) await this.#closeModelBridge(binding, false).catch( () => undefined ) @@ -1199,6 +1213,10 @@ export class RuntimeAcpBackend { 'process' ) } + this.#options.diagnostics?.tryRecord('runtime.started', { + runtimeId: verified.manifest.runtimeId, + workMode: preparation.workMode + }) } else { try { await binding.process.beginPrompt({ @@ -2162,6 +2180,8 @@ export class RuntimeAcpBackend { ) let state: 'completed' | 'failed' | 'cancelled' | 'interrupted' = 'interrupted' + let diagnosticOutcome: AgentDiagnosticRecord['outcome'] = + 'interrupted' try { const reconciliation = await binding.process.reconcile() if ( @@ -2172,10 +2192,29 @@ export class RuntimeAcpBackend { reconciliation.processTree === 'empty' ) { state = terminalState(reconciliation.state) + diagnosticOutcome = state } - } catch { + } catch (error) { binding.state = 'outcome-unknown' + diagnosticOutcome = 'outcome-unknown' + this.#options.diagnostics?.tryRecord('runtime.exited', { + runtimeId: binding.resolvedBundle.entry.runtimeId, + ...(binding.workMode === undefined + ? {} + : { workMode: binding.workMode }), + outcome: diagnosticOutcome, + error + }) + this.#finishBinding(binding, state) + return } + this.#options.diagnostics?.tryRecord('runtime.exited', { + runtimeId: binding.resolvedBundle.entry.runtimeId, + ...(binding.workMode === undefined + ? {} + : { workMode: binding.workMode }), + outcome: diagnosticOutcome + }) this.#finishBinding(binding, state) } diff --git a/src/agent-daemon/runtime-composition.ts b/src/agent-daemon/runtime-composition.ts index b162f666..8e537e97 100644 --- a/src/agent-daemon/runtime-composition.ts +++ b/src/agent-daemon/runtime-composition.ts @@ -44,6 +44,7 @@ import { AgentModelCallLedger, AgentModelGateway } from './agent-model-gateway' +import type { AgentDiagnosticLog } from './diagnostic-log' const BWRAP_EXECUTABLE = '/usr/bin/bwrap' @@ -93,6 +94,7 @@ export type ProductionRuntimeCompositionOptions = { verificationEnvironment?: 'production' | 'test' filesystemPlatform?: NodeJS.Platform reportError?: (message: string, error: unknown) => void + diagnostics?: Pick } export async function createProductionRuntimeProtocol( @@ -304,6 +306,7 @@ export async function createProductionRuntimeProtocol( outputSink: options.outputSink, semanticPrompts, modelGateway, + diagnostics: options.diagnostics, ...(options.blobSink === undefined ? {} : { blobSink: options.blobSink }) diff --git a/src/main/agent/runtime-controller.test.ts b/src/main/agent/runtime-controller.test.ts index 7547dfad..46433173 100644 --- a/src/main/agent/runtime-controller.test.ts +++ b/src/main/agent/runtime-controller.test.ts @@ -106,7 +106,12 @@ describe('AgentRuntimeController', () => { it('fails retired runtime requests and disposes them after exit', async () => { const previous = new TestRuntime(true, true) const next = new TestRuntime() - const controller = new AgentRuntimeController(previous) + const observeFailure = vi.fn() + const controller = new AgentRuntimeController( + previous, + undefined, + observeFailure + ) const authorize = vi.fn(async () => 'once' as const) const approvedStream = controller.run( { @@ -137,6 +142,7 @@ describe('AgentRuntimeController', () => { await expect(controller.getStatus()).resolves.toMatchObject({ label: 'Test' }) + expect(observeFailure).not.toHaveBeenCalled() }) it('keeps a retiring runtime alive until its status probe finishes', async () => { @@ -449,6 +455,132 @@ describe('AgentRuntimeController', () => { expect(authorize).not.toHaveBeenCalled() }) + it('does not report cancellation, AbortError, or tool denial', async () => { + const observeFailure = vi.fn() + const canceledRuntime = new TestRuntime() + canceledRuntime.run = async function* ( + _request, + signal + ): AsyncGenerator { + yield* [] + signal.throwIfAborted() + await new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => reject(signal.reason), + { once: true } + ) + }) + } + const canceledController = new AgentRuntimeController( + canceledRuntime, + undefined, + observeFailure + ) + const abortController = new AbortController() + const canceled = canceledController.run( + { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53b18', + conversationId: 'conversation-canceled', + prompt: 'test', + workMode: 'ask' + }, + abortController.signal + ) + const pending = canceled.next() + abortController.abort() + await expect(pending).rejects.toMatchObject({ + name: 'AbortError' + }) + await canceledController.dispose() + + const abortErrorRuntime = new TestRuntime() + abortErrorRuntime.run = async function* (): AsyncGenerator< + AgentEvent, + void, + void + > { + yield* [] + throw new DOMException('aborted', 'AbortError') + } + const abortErrorController = new AgentRuntimeController( + abortErrorRuntime, + undefined, + observeFailure + ) + const aborted = abortErrorController.run( + { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53b19', + conversationId: 'conversation-abort-error', + prompt: 'test', + workMode: 'ask' + }, + new AbortController().signal + ) + await expect(aborted.next()).rejects.toMatchObject({ + name: 'AbortError' + }) + await abortErrorController.dispose() + + const deniedRuntime = new TestRuntime(false, false, true) + const deniedController = new AgentRuntimeController( + deniedRuntime, + undefined, + observeFailure + ) + const denied = deniedController.run( + { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53b20', + conversationId: 'conversation-tool-denied', + prompt: 'test', + workMode: 'execute' + }, + new AbortController().signal, + async () => 'deny' + ) + await expect(denied.next()).rejects.toThrow('tool denied') + await deniedController.dispose() + + expect(observeFailure).not.toHaveBeenCalled() + }) + + it('reports genuine Runtime run failures', async () => { + const runtime = new TestRuntime() + const failure = new Error('provider failed') + runtime.run = async function* (): AsyncGenerator< + AgentEvent, + void, + void + > { + yield* [] + throw failure + } + const observeFailure = vi.fn() + const controller = new AgentRuntimeController( + runtime, + undefined, + observeFailure + ) + const stream = controller.run( + { + requestId: '1c608898-ecb7-4081-8174-2b6a52f53b21', + conversationId: 'conversation-run-failed', + prompt: 'test', + workMode: 'ask' + }, + new AbortController().signal + ) + + await expect(stream.next()).rejects.toBe(failure) + expect(observeFailure).toHaveBeenCalledWith({ + component: 'runtime', + stage: 'run', + code: 'runtime.run.failed', + error: failure + }) + await controller.dispose() + }) + it('forwards per-tool authorization without adding a whole-run gate', async () => { const runtime = new TestRuntime(false, false, true) const controller = new AgentRuntimeController(runtime) @@ -490,4 +622,26 @@ describe('AgentRuntimeController', () => { '当前 Runtime 不支持工具执行' ) }) + + it('reports Runtime boundary failures without request content', async () => { + const runtime = new TestRuntime() + const failure = new Error('provider raw response: secret prompt') + runtime.getStatus = vi.fn(async () => { + throw failure + }) + const observeFailure = vi.fn() + const controller = new AgentRuntimeController( + runtime, + undefined, + observeFailure + ) + + await expect(controller.getStatus()).rejects.toBe(failure) + expect(observeFailure).toHaveBeenCalledWith({ + component: 'runtime', + stage: 'status', + code: 'runtime.operation.failed', + error: failure + }) + }) }) diff --git a/src/main/agent/runtime-controller.ts b/src/main/agent/runtime-controller.ts index e3a051f3..df985963 100644 --- a/src/main/agent/runtime-controller.ts +++ b/src/main/agent/runtime-controller.ts @@ -11,6 +11,9 @@ import type { RuntimeConversationCompactOutcome, RuntimeEvent } from './runtime' +import type { + DesktopDiagnosticFailureObserver +} from '../desktop-diagnostics' type RuntimeSlot = { runtime: AgentRuntime @@ -80,7 +83,9 @@ export class AgentRuntimeController implements AgentRuntime { constructor( runtime: AgentRuntime, - private readonly shutdownGraceMs = 2_000 + private readonly shutdownGraceMs = 2_000, + private readonly observeFailure?: + DesktopDiagnosticFailureObserver ) { this.current = { runtime, @@ -201,18 +206,22 @@ export class AgentRuntimeController implements AgentRuntime { } async getStatus(): Promise { - return this.probeStatus((runtime) => runtime.getStatus()) + return this.probeStatus( + 'status', + (runtime) => runtime.getStatus() + ) } async testConnection(): Promise { return this.probeStatus( + 'connection-test', (runtime) => runtime.testConnection?.() ?? runtime.getStatus() ) } async getNativeSnapshot(): Promise { - return this.invoke((runtime) => { + return this.invoke('native-snapshot', (runtime) => { if (!runtime.getNativeSnapshot) { throw new Error('当前 Runtime 不支持原生能力清单') } @@ -225,18 +234,23 @@ export class AgentRuntimeController implements AgentRuntime { signal: AbortSignal ): Promise { this.ownedConversationIds.add(request.conversationId) - return this.invoke((runtime) => { - if (!runtime.compactConversation) { - throw new Error('当前 Runtime 不支持手动压缩') - } - return runtime.compactConversation(request, signal) - }) + return this.invoke( + 'compact', + (runtime) => { + if (!runtime.compactConversation) { + throw new Error('当前 Runtime 不支持手动压缩') + } + return runtime.compactConversation(request, signal) + }, + signal + ) } private async probeStatus( + stage: string, operation: (runtime: AgentRuntime) => Promise ): Promise { - const status = await this.invoke(operation) + const status = await this.invoke(stage, operation) return { ...status, supportsToolExecution: this.current.runtime.supportsToolExecution @@ -244,7 +258,9 @@ export class AgentRuntimeController implements AgentRuntime { } private async invoke( - operation: (runtime: AgentRuntime) => Promise + stage: string, + operation: (runtime: AgentRuntime) => Promise, + signal?: AbortSignal ): Promise { if (this.closing) { throw new Error('Agent Runtime 正在关闭') @@ -257,6 +273,15 @@ export class AgentRuntimeController implements AgentRuntime { throw new Error('Runtime 已切换,请重试') } return status + } catch (error) { + if ( + slot === this.current && + !signal?.aborted && + !isAbortError(error) + ) { + this.reportFailure(stage, 'runtime.operation.failed', error) + } + throw error } finally { slot.activeRequests -= 1 if ( @@ -280,9 +305,21 @@ export class AgentRuntimeController implements AgentRuntime { const slot = this.current this.ownedConversationIds.add(request.conversationId) const toolsAllowed = request.workMode === 'execute' + let toolDenied = false const effectiveAuthorize: RuntimeAuthorizer | undefined = toolsAllowed - ? authorize - : async () => 'deny' + ? authorize === undefined + ? undefined + : async (authorizationRequest) => { + const decision = await authorize(authorizationRequest) + if (decision === 'deny') { + toolDenied = true + } + return decision + } + : async () => { + toolDenied = true + return 'deny' + } slot.activeRequests += 1 try { if (toolsAllowed && !slot.runtime.supportsToolExecution) { @@ -304,18 +341,31 @@ export class AgentRuntimeController implements AgentRuntime { throw new Error('用户拒绝了 Agent 工具执行') } } - for await (const event of slot.runtime.run( - request, - signal, - effectiveAuthorize - )) { + try { + for await (const event of slot.runtime.run( + request, + signal, + effectiveAuthorize + )) { + if (slot !== this.current && !slot.drainable) { + throw new Error('Runtime 已切换,当前请求已中断') + } + toolDenied = false + yield event + } if (slot !== this.current && !slot.drainable) { throw new Error('Runtime 已切换,当前请求已中断') } - yield event - } - if (slot !== this.current && !slot.drainable) { - throw new Error('Runtime 已切换,当前请求已中断') + } catch (error) { + if ( + !signal.aborted && + !toolDenied && + !isAbortError(error) && + (slot === this.current || slot.drainable) + ) { + this.reportFailure('run', 'runtime.run.failed', error) + } + throw error } } finally { slot.activeRequests -= 1 @@ -329,6 +379,23 @@ export class AgentRuntimeController implements AgentRuntime { } } + private reportFailure( + stage: string, + code: string, + error: unknown + ): void { + try { + this.observeFailure?.({ + component: 'runtime', + stage, + code, + error + }) + } catch { + // Diagnostics must not alter Runtime behavior. + } + } + async releaseConversation(conversationId: string): Promise { const results = await Promise.allSettled([ this.current.runtime.releaseConversation?.(conversationId), @@ -549,6 +616,17 @@ export class AgentRuntimeController implements AgentRuntime { } } +function isAbortError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false + } + try { + return 'name' in error && error.name === 'AbortError' + } catch { + return false + } +} + async function waitWithin( promise: Promise, timeoutMs: number diff --git a/src/main/agent/selected-runtime-manager.ts b/src/main/agent/selected-runtime-manager.ts index 4492d41d..d44e260e 100644 --- a/src/main/agent/selected-runtime-manager.ts +++ b/src/main/agent/selected-runtime-manager.ts @@ -13,6 +13,9 @@ import type { } from './runtime' import { AgentRuntimeController } from './runtime-controller' import type { ExecutionSpaceDescriptor } from '../execution-space' +import type { + DesktopDiagnosticFailureObserver +} from '../desktop-diagnostics' export type SelectedRuntimeResolver = { getRuntime( @@ -70,7 +73,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { private readonly maximumCachedRuntimes = DEFAULT_MAXIMUM_CACHED_RUNTIMES, private readonly maximumRetainedRuntimes = - DEFAULT_MAXIMUM_RETAINED_RUNTIMES + DEFAULT_MAXIMUM_RETAINED_RUNTIMES, + private readonly observeFailure?: + DesktopDiagnosticFailureObserver ) { if ( !Number.isSafeInteger(maximumCachedRuntimes) || @@ -129,7 +134,11 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { await runtime.dispose() throw new Error('Runtime 设置已更改,请重新选择') } - const controller = new AgentRuntimeController(runtime) + const controller = new AgentRuntimeController( + runtime, + undefined, + this.observeFailure + ) entry.controller = controller return controller } @@ -148,6 +157,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { if (this.entries.get(key) === entry) { this.entries.delete(key) } + this.reportFailure('create', error) throw error } } @@ -162,6 +172,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { this.tests.add(operation) try { return await operation + } catch (error) { + this.reportFailure('status', error) + throw error } finally { this.tests.delete(operation) } @@ -177,6 +190,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { this.tests.add(operation) try { return await operation + } catch (error) { + this.reportFailure('connection-test', error) + throw error } finally { this.tests.delete(operation) } @@ -196,6 +212,9 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { this.snapshots.add(operation) try { return await operation + } catch (error) { + this.reportFailure('native-snapshot', error) + throw error } finally { this.snapshots.delete(operation) } @@ -421,4 +440,17 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { void this.startRetiring(entry, false) } } + + private reportFailure(stage: string, error: unknown): void { + try { + this.observeFailure?.({ + component: 'runtime', + stage, + code: 'runtime.operation.failed', + error + }) + } catch { + // Diagnostics must not alter Runtime behavior. + } + } } diff --git a/src/main/desktop-diagnostics.test.ts b/src/main/desktop-diagnostics.test.ts new file mode 100644 index 00000000..a1b47514 --- /dev/null +++ b/src/main/desktop-diagnostics.test.ts @@ -0,0 +1,228 @@ +import { + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile +} from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + DesktopDiagnostics, + MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES, + normalizeDesktopDiagnosticRecord +} from './desktop-diagnostics' + +const temporaryDirectories: string[] = [] + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-desktop-diagnostics-') + ) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +describe('DesktopDiagnostics', () => { + it('rotates within fixed file and byte bounds', async () => { + const directory = await temporaryDirectory() + let sequence = 0 + const diagnostics = new DesktopDiagnostics(directory, { + maximumFileBytes: 420, + maximumFiles: 3, + now: () => new Date(sequence++ * 1_000) + }) + + for (let index = 0; index < 12; index += 1) { + await diagnostics.recordFailure({ + component: 'runtime', + stage: 'run', + code: 'runtime.run.failed', + error: new Error(`failure ${index}`) + }) + } + await diagnostics.dispose() + + const files = await readdir(directory) + expect(files).toHaveLength(3) + for (const file of files) { + expect((await stat(join(directory, file))).size).toBeLessThanOrEqual( + 420 + ) + } + + const reopened = new DesktopDiagnostics(directory, { + maximumFileBytes: 420, + maximumFiles: 3 + }) + const records = await reopened.readRecent() + expect(records.length).toBeGreaterThan(0) + expect(records.length).toBeLessThan(12) + expect(records.at(-1)).toMatchObject({ + component: 'runtime', + stage: 'run', + code: 'runtime.run.failed', + message: 'Runtime request failed' + }) + await reopened.dispose() + }) + + it('reads bounded recent records after restart', async () => { + const directory = await temporaryDirectory() + const first = new DesktopDiagnostics(directory, { + maximumRecords: 2 + }) + await first.recordFailure({ + component: 'desktop', + stage: 'startup', + code: 'desktop.startup.failed', + error: new TypeError('first') + }) + await first.recordFailure({ + component: 'remote-agent', + stage: 'connect', + code: 'remote.connection.network', + error: new Error('second') + }) + await first.recordFailure({ + component: 'runtime', + stage: 'status', + code: 'runtime.operation.failed', + error: new Error('third') + }) + await first.dispose() + + const restarted = new DesktopDiagnostics(directory, { + maximumRecords: 2 + }) + await expect(restarted.readRecent(100)).resolves.toMatchObject([ + { code: 'remote.connection.network' }, + { code: 'runtime.operation.failed' } + ]) + const exported = await restarted.exportRecent() + expect(exported.toString('utf8').trim().split('\n')).toHaveLength(2) + await restarted.dispose() + }) + + it('never persists raw error content or unknown error names', async () => { + const directory = await temporaryDirectory() + const diagnostics = new DesktopDiagnostics(directory) + const secret = + 'Prompt body password=hunter2 API_KEY=abc https://provider.test/path?token=raw-response' + const error = new Error(secret) + error.name = `Credential-${secret}` + + await diagnostics.recordFailure({ + component: 'runtime', + stage: secret, + code: secret, + error + }) + const exported = await diagnostics.exportRecent() + await diagnostics.dispose() + + expect(exported.toString('utf8')).not.toContain(secret) + expect(exported.toString('utf8')).not.toContain('hunter2') + expect(exported.toString('utf8')).not.toContain('provider.test') + expect(JSON.parse(exported.toString('utf8'))).toMatchObject({ + errorType: 'Error', + stage: 'unknown', + code: 'diagnostic.failure', + message: 'Runtime operation failed' + }) + const persisted = await readFile( + join(directory, 'desktop-diagnostics.ndjson'), + 'utf8' + ) + expect(persisted).not.toContain('API_KEY') + }) + + it('uses one safe normalizer for externally damaged records', async () => { + const directory = await temporaryDirectory() + const damaged = { + timestamp: '2026-08-25T01:02:03+00:00', + component: 'remote-agent', + stage: 'disconnect', + code: 'remote.connection.lost', + errorType: 'SensitiveProviderError', + message: 'password=hunter2' + } + expect(normalizeDesktopDiagnosticRecord(damaged)).toEqual({ + timestamp: '2026-08-25T01:02:03.000Z', + component: 'remote-agent', + stage: 'disconnect', + code: 'remote.connection.lost', + errorType: 'Error', + message: 'Remote connection was lost' + }) + await writeFile( + join(directory, 'desktop-diagnostics.ndjson'), + `${JSON.stringify(damaged)}\n`, + 'utf8' + ) + + const diagnostics = new DesktopDiagnostics(directory) + const records = await diagnostics.readRecent() + await diagnostics.dispose() + + expect(records).toEqual([ + { + timestamp: '2026-08-25T01:02:03.000Z', + component: 'remote-agent', + stage: 'disconnect', + code: 'remote.connection.lost', + errorType: 'Error', + message: 'Remote connection was lost' + } + ]) + expect(JSON.stringify(records)).not.toContain('hunter2') + }) + + it('normalizes errors immediately and bounds pending writes', async () => { + const directory = await temporaryDirectory() + const diagnostics = new DesktopDiagnostics(directory) + let errorNameReads = 0 + const writes = Array.from( + { + length: + MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES + 8 + }, + () => { + const error = new Error('raw provider failure') + Object.defineProperty(error, 'name', { + get: () => { + errorNameReads += 1 + return 'TypeError' + } + }) + return diagnostics.recordFailure({ + component: 'runtime', + stage: 'run', + code: 'runtime.run.failed', + error + }) + } + ) + + expect(errorNameReads).toBe(writes.length) + expect(diagnostics.pendingWriteCount).toBe( + MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES + ) + await Promise.all(writes) + expect(diagnostics.pendingWriteCount).toBe(0) + await expect(diagnostics.readRecent()).resolves.toHaveLength( + MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES + ) + await diagnostics.dispose() + }) +}) diff --git a/src/main/desktop-diagnostics.ts b/src/main/desktop-diagnostics.ts new file mode 100644 index 00000000..9e815a79 --- /dev/null +++ b/src/main/desktop-diagnostics.ts @@ -0,0 +1,442 @@ +import { + mkdir, + open, + readFile, + rename, + unlink, + type FileHandle +} from 'node:fs/promises' +import { join } from 'node:path' + +const DEFAULT_MAXIMUM_FILE_BYTES = 256 * 1024 +const DEFAULT_MAXIMUM_FILES = 4 +const DEFAULT_MAXIMUM_RECORDS = 1_000 +export const MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES = 32 +const DIAGNOSTIC_FILE_NAME = 'desktop-diagnostics.ndjson' +const allowedStages = new Set([ + 'startup', + 'create', + 'status', + 'connection-test', + 'native-snapshot', + 'compact', + 'run', + 'connect', + 'disconnect' +]) +const allowedCodes = new Set([ + 'desktop.startup.failed', + 'runtime.operation.failed', + 'runtime.run.failed', + 'remote.connection.network', + 'remote.connection.host-invalidated', + 'remote.connection.host-identity', + 'remote.connection.authentication', + 'remote.connection.installation', + 'remote.connection.protocol', + 'remote.connection.daemon-status', + 'remote.connection.platform', + 'remote.connection.capability', + 'remote.connection.shutdown', + 'remote.connection.lost' +]) + +const safeErrorTypes = new Set([ + 'AbortError', + 'AggregateError', + 'Error', + 'EvalError', + 'RangeError', + 'ReferenceError', + 'RemoteAgentConnectionError', + 'StartupPrerequisiteError', + 'SyntaxError', + 'TimeoutError', + 'TypeError', + 'URIError' +]) + +export type DesktopDiagnosticComponent = + | 'desktop' + | 'runtime' + | 'remote-agent' + +export type DesktopDiagnosticFailure = Readonly<{ + component: DesktopDiagnosticComponent + stage: string + code: string + error: unknown +}> + +export type DesktopDiagnosticFailureObserver = ( + failure: DesktopDiagnosticFailure +) => void + +export type DesktopDiagnosticRecord = Readonly<{ + timestamp: string + component: DesktopDiagnosticComponent + stage: string + code: string + errorType: string + message: string +}> + +export type DesktopDiagnosticsOptions = Readonly<{ + maximumFileBytes?: number + maximumFiles?: number + maximumRecords?: number + now?: () => Date +}> + +function safeErrorType(error: unknown): string { + if (!(error instanceof Error)) { + return 'NonError' + } + let name: unknown + try { + name = error.name + } catch { + return 'Error' + } + return typeof name === 'string' && safeErrorTypes.has(name) + ? name + : 'Error' +} + +function fixedMessage( + component: DesktopDiagnosticComponent, + stage: string +): string { + if (component === 'desktop') { + return 'Desktop startup failed' + } + if (component === 'remote-agent') { + return stage === 'disconnect' + ? 'Remote connection was lost' + : 'Remote connection failed' + } + return stage === 'run' + ? 'Runtime request failed' + : 'Runtime operation failed' +} + +function safeComponent(component: unknown): DesktopDiagnosticComponent { + switch (component) { + case 'runtime': + case 'remote-agent': + return component + default: + return 'desktop' + } +} + +function safeStage(stage: unknown): string { + return typeof stage === 'string' && allowedStages.has(stage) + ? stage + : 'unknown' +} + +function safeCode(code: unknown): string { + return typeof code === 'string' && allowedCodes.has(code) + ? code + : 'diagnostic.failure' +} + +function safeStoredErrorType(errorType: unknown): string { + return typeof errorType === 'string' && + (errorType === 'NonError' || safeErrorTypes.has(errorType)) + ? errorType + : 'Error' +} + +export function normalizeDesktopDiagnosticRecord( + value: unknown +): DesktopDiagnosticRecord | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + const candidate = value as Partial + if (typeof candidate.timestamp !== 'string') { + return undefined + } + const timestamp = new Date(candidate.timestamp) + if (Number.isNaN(timestamp.valueOf())) { + return undefined + } + const component = safeComponent(candidate.component) + const stage = safeStage(candidate.stage) + return Object.freeze({ + timestamp: timestamp.toISOString(), + component, + stage, + code: safeCode(candidate.code), + errorType: safeStoredErrorType(candidate.errorType), + message: fixedMessage(component, stage) + }) +} + +function ignoreMissing(error: unknown): void { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error + } +} + +export class DesktopDiagnostics { + readonly #directoryPath: string + readonly #maximumFileBytes: number + readonly #maximumFiles: number + readonly #maximumRecords: number + readonly #now: () => Date + #handle: FileHandle | undefined + #currentBytes = 0 + #initialized = false + #accepting = true + #pendingWrites = 0 + #queue: Promise = Promise.resolve() + + constructor( + directoryPath: string, + options: DesktopDiagnosticsOptions = {} + ) { + this.#directoryPath = directoryPath + this.#maximumFileBytes = + options.maximumFileBytes ?? DEFAULT_MAXIMUM_FILE_BYTES + this.#maximumFiles = + options.maximumFiles ?? DEFAULT_MAXIMUM_FILES + this.#maximumRecords = + options.maximumRecords ?? DEFAULT_MAXIMUM_RECORDS + this.#now = options.now ?? (() => new Date()) + if ( + !Number.isSafeInteger(this.#maximumFileBytes) || + this.#maximumFileBytes < 256 || + !Number.isSafeInteger(this.#maximumFiles) || + this.#maximumFiles < 1 || + this.#maximumFiles > 16 || + !Number.isSafeInteger(this.#maximumRecords) || + this.#maximumRecords < 1 || + this.#maximumRecords > 10_000 + ) { + throw new RangeError('Invalid desktop diagnostic bounds') + } + } + + initialize(): Promise { + return this.#enqueue(async () => this.#ensureInitialized()) + } + + get pendingWriteCount(): number { + return this.#pendingWrites + } + + recordFailure( + failure: DesktopDiagnosticFailure + ): Promise { + if (!this.#accepting) { + return Promise.resolve() + } + let record: DesktopDiagnosticRecord + try { + const normalized = normalizeDesktopDiagnosticRecord({ + timestamp: this.#now().toISOString(), + component: failure.component, + stage: failure.stage, + code: failure.code, + errorType: safeErrorType(failure.error) + }) + if (normalized === undefined) { + return Promise.reject( + new Error('Desktop diagnostic record is invalid') + ) + } + record = normalized + } catch (error) { + return Promise.reject(error) + } + if ( + this.#pendingWrites >= + MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES + ) { + return Promise.resolve() + } + this.#pendingWrites += 1 + return this.#enqueue(async () => { + try { + await this.#ensureInitialized() + const encoded = Buffer.from( + `${JSON.stringify(record)}\n`, + 'utf8' + ) + if (encoded.byteLength > this.#maximumFileBytes) { + throw new RangeError( + 'Desktop diagnostic record exceeds file bound' + ) + } + if ( + this.#currentBytes > 0 && + this.#currentBytes + encoded.byteLength > + this.#maximumFileBytes + ) { + await this.#rotate() + } + await this.#handle!.write(encoded) + this.#currentBytes += encoded.byteLength + } finally { + this.#pendingWrites -= 1 + } + }) + } + + readRecent( + limit = this.#maximumRecords + ): Promise { + const requestedLimit = Number.isFinite(limit) + ? Math.trunc(limit) + : this.#maximumRecords + const boundedLimit = Math.min( + Math.max(0, requestedLimit), + this.#maximumRecords + ) + if (boundedLimit === 0) { + return Promise.resolve([]) + } + return this.#enqueue(async () => { + await this.#ensureInitialized() + await this.#handle!.sync() + const records: DesktopDiagnosticRecord[] = [] + for (let index = this.#maximumFiles - 1; index >= 0; index -= 1) { + const contents = await this.#readFile(index) + if (contents === undefined) { + continue + } + for (const line of contents.split('\n')) { + if (line.length === 0) { + continue + } + try { + const parsed: unknown = JSON.parse(line) + const record = normalizeDesktopDiagnosticRecord(parsed) + if (record !== undefined) { + records.push(record) + } + } catch { + // Ignore a final partial or externally damaged record. + } + } + } + return Object.freeze(records.slice(-boundedLimit)) + }) + } + + async exportRecent( + limit = this.#maximumRecords + ): Promise { + const records = await this.readRecent(limit) + return Buffer.from( + records.map((record) => JSON.stringify(record)).join('\n') + + (records.length > 0 ? '\n' : ''), + 'utf8' + ) + } + + flush(): Promise { + return this.#enqueue(async () => { + if (this.#handle) { + await this.#handle.sync() + } + }) + } + + dispose(): Promise { + if (!this.#accepting) { + return this.#queue + } + this.#accepting = false + return this.#enqueue(async () => { + const handle = this.#handle + this.#handle = undefined + this.#initialized = false + if (handle) { + await handle.sync() + await handle.close() + } + }) + } + + #enqueue(operation: () => Promise): Promise { + const result = this.#queue.then(operation) + this.#queue = result.then( + () => undefined, + () => undefined + ) + return result + } + + async #ensureInitialized(): Promise { + if (this.#initialized) { + return + } + await mkdir(this.#directoryPath, { recursive: true }) + const currentPath = this.#filePath(0) + this.#handle = await open(currentPath, 'a+') + this.#currentBytes = (await this.#handle.stat()).size + this.#initialized = true + if (this.#currentBytes > this.#maximumFileBytes) { + await this.#handle.close() + this.#handle = undefined + await unlink(currentPath) + this.#handle = await open(currentPath, 'a+') + this.#currentBytes = 0 + } + } + + async #rotate(): Promise { + const handle = this.#handle + this.#handle = undefined + if (handle) { + await handle.close() + } + await unlink(this.#filePath(this.#maximumFiles - 1)).catch( + ignoreMissing + ) + for ( + let index = this.#maximumFiles - 2; + index >= 0; + index -= 1 + ) { + await rename( + this.#filePath(index), + this.#filePath(index + 1) + ).catch(ignoreMissing) + } + this.#handle = await open(this.#filePath(0), 'a+') + this.#currentBytes = 0 + } + + async #readFile(index: number): Promise { + try { + return await readFile(this.#filePath(index), 'utf8') + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return undefined + } + throw error + } + } + + #filePath(index: number): string { + return join( + this.#directoryPath, + index === 0 + ? DIAGNOSTIC_FILE_NAME + : `${DIAGNOSTIC_FILE_NAME}.${index}` + ) + } +} diff --git a/src/main/feedback/feedback-ipc.test.ts b/src/main/feedback/feedback-ipc.test.ts index e30e0fdd..ab4f81f2 100644 --- a/src/main/feedback/feedback-ipc.test.ts +++ b/src/main/feedback/feedback-ipc.test.ts @@ -50,6 +50,7 @@ describe('feedback IPC', () => { category: 'bug', title: ' Feedback title ', description: 'A useful feedback description.', + includeDiagnostics: false, locale: 'zh-CN', clientRequestId: '00000000-0000-4000-8000-000000000501' @@ -78,6 +79,12 @@ describe('feedback IPC', () => { endpoint: 'https://attacker.example/api/v1/feedback' }) ).toThrow() + expect(() => + handler(event, { + ...input, + diagnostics: [{ message: 'renderer supplied content' }] + }) + ).toThrow() expect(submit).toHaveBeenCalledOnce() remove() diff --git a/src/main/feedback/feedback-service.test.ts b/src/main/feedback/feedback-service.test.ts index 17446013..0700d24f 100644 --- a/src/main/feedback/feedback-service.test.ts +++ b/src/main/feedback/feedback-service.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it, vi } from 'vitest' -import type { FeedbackSubmitInput } from '../../shared/feedback-contracts' +import { + feedbackLimits, + type FeedbackSubmitInput +} from '../../shared/feedback-contracts' import { FeedbackIdentityStore } from './feedback-identity-store' import { FeedbackClientError, StrictFeedbackHttpClient } from './feedback-http-client' -import { FeedbackService } from './feedback-service' +import { + FeedbackService, + type FeedbackDiagnosticsProvider +} from './feedback-service' vi.mock('electron', () => ({ nativeImage: { @@ -17,6 +23,7 @@ const input: FeedbackSubmitInput = { category: 'feature', title: 'Feedback title', description: 'A useful feedback description.', + includeDiagnostics: false, contactEmail: 'user@example.com', locale: 'en-US', clientRequestId: '00000000-0000-4000-8000-000000000401' @@ -24,7 +31,9 @@ const input: FeedbackSubmitInput = { function service( submit: StrictFeedbackHttpClient['submit'], - clear = vi.fn(async () => undefined) + clear = vi.fn(async () => undefined), + readRecent: FeedbackDiagnosticsProvider['readRecent'] = + vi.fn(async () => []) ) { const identityStore = { getInstallationId: vi.fn( @@ -42,10 +51,12 @@ function service( platform: 'win32', architecture: 'x64', identityStore: identityStore as unknown as FeedbackIdentityStore, - client: client as unknown as StrictFeedbackHttpClient + client: client as unknown as StrictFeedbackHttpClient, + diagnosticsProvider: { readRecent } }), identityStore, - client + client, + readRecent } } @@ -64,21 +75,176 @@ describe('FeedbackService', () => { duplicate: false }) expect(submit).toHaveBeenCalledWith( - expect.objectContaining({ + { schemaVersion: 1, productKey: 'goodbuddy', + category: input.category, + title: input.title, + description: input.description, + contactEmail: input.contactEmail, installationId: '00000000-0000-4000-8000-000000000402', + clientRequestId: input.clientRequestId, environment: { appVersion: '0.11.0', platform: 'windows', architecture: 'x64', locale: 'en-US' } - }), + }, undefined, expect.any(AbortSignal) ) + expect(fixture.readRecent).not.toHaveBeenCalled() + }) + + it('appends bounded desktop diagnostics without changing public fields', async () => { + const submit = vi.fn( + async () => ({ + reference: 'GOODBUDDY-000013', + duplicate: false + }) + ) + const readRecent = vi.fn(async () => [ + { + timestamp: '2026-08-25T01:02:03.000Z', + component: 'runtime' as const, + stage: 'run', + code: 'runtime.run.failed', + errorType: 'TimeoutError', + message: 'password=hunter2' + } + ]) + const fixture = service( + submit, + vi.fn(async () => undefined), + readRecent + ) + await expect( + fixture.service.submit({ + ...input, + includeDiagnostics: true + }) + ).resolves.toMatchObject({ ok: true }) + expect(readRecent).toHaveBeenCalledWith( + feedbackLimits.maximumDiagnosticRecords + ) + const payload = submit.mock.calls[0]![0] + expect(payload).toMatchObject({ + schemaVersion: 1, + productKey: 'goodbuddy', + category: input.category, + title: input.title, + contactEmail: input.contactEmail + }) + expect(payload).not.toHaveProperty('includeDiagnostics') + expect(payload.description).toContain(input.description) + expect(payload.description).toContain( + '[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_BEGIN]\n' + ) + expect(payload.description).toContain( + '{"timestamp":"2026-08-25T01:02:03.000Z","component":"runtime","stage":"run","code":"runtime.run.failed","errorType":"TimeoutError","message":"Runtime request failed"}' + ) + expect(payload.description).not.toContain('hunter2') + expect(payload.description).toMatch( + /\n\[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END\]$/u + ) + expect(payload.description.length).toBeLessThanOrEqual( + feedbackLimits.maximumDescriptionCharacters + ) + }) + + it('states when no recent desktop diagnostics exist', async () => { + const submit = vi.fn( + async () => ({ + reference: 'GOODBUDDY-000014', + duplicate: false + }) + ) + const fixture = service(submit) + await fixture.service.submit({ + ...input, + includeDiagnostics: true + }) + expect(submit.mock.calls[0]![0].description).toContain( + '{"status":"no-recent-diagnostics"}' + ) + }) + + it('keeps a bounded summary on complete diagnostic lines', async () => { + const submit = vi.fn( + async () => ({ + reference: 'GOODBUDDY-000017', + duplicate: false + }) + ) + const records = Array.from({ length: 20 }, (_, index) => ({ + timestamp: new Date( + Date.UTC(2026, 7, 25, 1, index) + ).toISOString(), + component: 'runtime' as const, + stage: 'run', + code: 'runtime.run.failed', + errorType: 'TimeoutError', + message: 'Runtime request failed' + })) + const fixture = service( + submit, + vi.fn(async () => undefined), + vi.fn(async () => records) + ) + await fixture.service.submit({ + ...input, + includeDiagnostics: true + }) + const summary = submit.mock.calls[0]![0].description.split( + '\n\n' + )[1]! + expect(summary.length).toBeLessThanOrEqual( + feedbackLimits.maximumDiagnosticsSummaryCharacters + ) + const lines = summary.split('\n') + expect(lines[0]).toBe( + '[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_BEGIN]' + ) + expect(lines.at(-1)).toBe( + '[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END]' + ) + const included = lines + .slice(1, -1) + .map((line) => JSON.parse(line) as { timestamp: string }) + expect(included.length).toBeGreaterThan(0) + expect(included.length).toBeLessThan(records.length) + expect(included.at(-1)?.timestamp).toBe( + records.at(-1)?.timestamp + ) + }) + + it('does not send when selected diagnostics cannot be read', async () => { + const submit = vi.fn( + async () => ({ + reference: 'GOODBUDDY-000015', + duplicate: false + }) + ) + const readRecent = vi.fn(async () => { + throw new Error('read failed') + }) + const fixture = service( + submit, + vi.fn(async () => undefined), + readRecent + ) + await expect( + fixture.service.submit({ + ...input, + includeDiagnostics: true + }) + ).resolves.toEqual({ + ok: false, + error: 'diagnostics-unavailable' + }) + expect(submit).not.toHaveBeenCalled() }) it('coalesces concurrent submissions with the same client request ID', async () => { diff --git a/src/main/feedback/feedback-service.ts b/src/main/feedback/feedback-service.ts index b6cb2048..e639873a 100644 --- a/src/main/feedback/feedback-service.ts +++ b/src/main/feedback/feedback-service.ts @@ -1,10 +1,15 @@ import { + feedbackLimits, feedbackPublicPayloadSchema, feedbackSubmitInputSchema, type FeedbackPublicPayload, type FeedbackSubmitInput, type FeedbackSubmitResult } from '../../shared/feedback-contracts' +import { + normalizeDesktopDiagnosticRecord, + type DesktopDiagnosticRecord +} from '../desktop-diagnostics' import { FeedbackIdentityStore } from './feedback-identity-store' import { FeedbackClientError, @@ -15,6 +20,57 @@ import { normalizeFeedbackScreenshot } from './feedback-screenshot' +const diagnosticsBeginMarker = + '[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_BEGIN]' +const diagnosticsEndMarker = + '[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END]' +export type FeedbackDiagnosticsProvider = { + readRecent: ( + limit: number + ) => Promise +} + +function formatDiagnosticsSummary( + records: readonly DesktopDiagnosticRecord[] +): string { + const lines = records + .map(normalizeDesktopDiagnosticRecord) + .filter( + (record): record is DesktopDiagnosticRecord => + record !== undefined + ) + .map((record) => JSON.stringify(record)) + if (lines.length === 0) { + return [ + diagnosticsBeginMarker, + JSON.stringify({ status: 'no-recent-diagnostics' }), + diagnosticsEndMarker + ].join('\n') + } + + const included: string[] = [] + for (let index = lines.length - 1; index >= 0; index -= 1) { + const candidate = [lines[index]!, ...included] + const summary = [ + diagnosticsBeginMarker, + ...candidate, + diagnosticsEndMarker + ].join('\n') + if ( + summary.length > + feedbackLimits.maximumDiagnosticsSummaryCharacters + ) { + break + } + included.unshift(lines[index]!) + } + return [ + diagnosticsBeginMarker, + ...included, + diagnosticsEndMarker + ].join('\n') +} + function mapPlatform( platform: NodeJS.Platform ): FeedbackPublicPayload['environment']['platform'] { @@ -56,6 +112,7 @@ export class FeedbackService { architecture: string identityStore: FeedbackIdentityStore client: StrictFeedbackHttpClient + diagnosticsProvider: FeedbackDiagnosticsProvider } ) {} @@ -143,6 +200,23 @@ export class FeedbackService { signal: AbortSignal ): Promise { try { + let description = input.description + if (input.includeDiagnostics) { + try { + const records = + await this.options.diagnosticsProvider.readRecent( + feedbackLimits.maximumDiagnosticRecords + ) + description = `${description}\n\n${formatDiagnosticsSummary( + records + )}` + } catch { + return { + ok: false, + error: 'diagnostics-unavailable' + } + } + } const installationId = await this.options.identityStore.getInstallationId() const payload = feedbackPublicPayloadSchema.parse({ @@ -150,7 +224,7 @@ export class FeedbackService { productKey: 'goodbuddy', category: input.category, title: input.title, - description: input.description, + description, ...(input.contactEmail ? { contactEmail: input.contactEmail } : {}), diff --git a/src/main/index.ts b/src/main/index.ts index 45ce146f..a0446f11 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -152,6 +152,10 @@ import type { RuntimeSettings, RuntimeSettingsInput } from '../shared/contracts' +import { + DesktopDiagnostics, + type DesktopDiagnosticFailureObserver +} from './desktop-diagnostics' const legacyDefaultShortcut = defaultGlobalShortcutSettings.accelerator @@ -164,6 +168,14 @@ const portableUserDataPath = resolvePortableUserDataPath({ if (portableUserDataPath) { app.setPath('userData', portableUserDataPath) } +const desktopDiagnostics = new DesktopDiagnostics( + join(app.getPath('userData'), 'diagnostics') +) +const observeDesktopFailure: DesktopDiagnosticFailureObserver = ( + failure +) => { + void desktopDiagnostics.recordFailure(failure).catch(() => undefined) +} const installedWindowsBuild = isInstalledWindowsBuild({ packaged: app.isPackaged, platform: process.platform, @@ -677,7 +689,8 @@ if (hasSingleInstanceLock) { appPath: app.getAppPath(), resourcesPath: process.resourcesPath, packaged: app.isPackaged, - controlPlanePackageInstaller + controlPlanePackageInstaller, + observeFailure: observeDesktopFailure }) remoteAgentServices = startupRemoteAgentServices const startupManagedRemoteExecutionServices = @@ -776,7 +789,8 @@ if (hasSingleInstanceLock) { client: new StrictFeedbackHttpClient({ appVersion: app.getVersion(), dispatcher: createStrictFeedbackDispatcher() - }) + }), + diagnosticsProvider: desktopDiagnostics }) feedbackService = startupFeedbackService const startupEmbeddingModelManager = new EmbeddingModelManager({ @@ -1078,9 +1092,16 @@ if (hasSingleInstanceLock) { initialResolvedSettings ) ) - runtime = new AgentRuntimeController(configuredRuntime) + runtime = new AgentRuntimeController( + configuredRuntime, + undefined, + observeDesktopFailure + ) selectedRuntimeManager = new SelectedRuntimeManager( - createSelectedRuntime + createSelectedRuntime, + undefined, + undefined, + observeDesktopFailure ) const contextManager = new ContextManager({ parseDocument: documentParsingService.parse @@ -1394,6 +1415,12 @@ if (hasSingleInstanceLock) { } }) }).catch((error: unknown) => { + void desktopDiagnostics.recordFailure({ + component: 'desktop', + stage: 'startup', + code: 'desktop.startup.failed', + error + }).catch(() => undefined) console.error( 'GoodBuddy startup failed', createStartupFailureDiagnostic(error) @@ -1449,7 +1476,8 @@ app.on('before-quit', (event) => { [() => managedRemoteExecutionServices?.dispose()], [() => remoteAgentServices?.dispose()], [() => knowledgeGateway?.dispose()], - [() => knowledgeService?.dispose()] + [() => knowledgeService?.dispose()], + [() => desktopDiagnostics.dispose()] ]) globalShortcut.unregisterAll() tray?.destroy() diff --git a/src/main/remote-agent/remote-agent-connection-manager.test.ts b/src/main/remote-agent/remote-agent-connection-manager.test.ts index 82070589..888bf10d 100644 --- a/src/main/remote-agent/remote-agent-connection-manager.test.ts +++ b/src/main/remote-agent/remote-agent-connection-manager.test.ts @@ -26,6 +26,9 @@ import { RemoteAgentConnectionManager, type RemoteAgentSshPool } from './remote-agent-connection-manager' +import type { + DesktopDiagnosticFailureObserver +} from '../desktop-diagnostics' class MemoryStateFile implements ControllerStateFile { value?: unknown @@ -158,7 +161,8 @@ const installation = { function harness( connectGate?: Promise, reconnectResumeGate?: Promise, - idleTimeoutMs = 0 + idleTimeoutMs = 0, + observeFailure?: DesktopDiagnosticFailureObserver ) { let currentTarget = target() const release = vi.fn() @@ -206,6 +210,7 @@ function harness( controllerState: new ControllerStateStore(stateFile), goodBuddyVersion: '0.11.0', idleTimeoutMs, + observeFailure, connectTransport, createProtocolClient: () => { const client = new FakeProtocolClient(clients.length) @@ -482,6 +487,27 @@ describe('RemoteAgentConnectionManager', () => { await test.manager.dispose() }) + it('does not report acquisition canceled by its caller', async () => { + const observeFailure = vi.fn() + const test = harness(undefined, undefined, 0, observeFailure) + const controller = new AbortController() + const cancellation = new Error('caller canceled') + test.resolver.resolve.mockImplementationOnce(async () => { + controller.abort(cancellation) + throw cancellation + }) + + await expect( + test.manager.acquire( + 'host-1', + installation, + controller.signal + ) + ).rejects.toBe(cancellation) + expect(observeFailure).not.toHaveBeenCalled() + await test.manager.dispose() + }) + it('invalidates every connection for a Host without stopping its daemon', async () => { const test = harness() const lease = await test.manager.acquire('host-1', installation) @@ -497,7 +523,8 @@ describe('RemoteAgentConnectionManager', () => { const gate = new Promise((resolve) => { finishTransport = resolve }) - const test = harness(gate) + const observeFailure = vi.fn() + const test = harness(gate, undefined, 0, observeFailure) const acquiring = test.manager.acquire('host-1', installation) await vi.waitFor(() => expect(test.connectTransport).toHaveBeenCalledOnce() @@ -512,6 +539,7 @@ describe('RemoteAgentConnectionManager', () => { }) expect(test.clients[0]?.disposed).toBe(true) expect(test.release).toHaveBeenCalledOnce() + expect(observeFailure).not.toHaveBeenCalled() await test.manager.dispose() }) @@ -834,4 +862,46 @@ describe('RemoteAgentConnectionManager', () => { test.manager.acquire('host-1', installation) ).rejects.toMatchObject({ reason: 'shutdown' }) }) + + it('reports classified connect and disconnect failures once', async () => { + const observeFailure = vi.fn() + const failed = harness(undefined, undefined, 0, observeFailure) + failed.connectTransport.mockRejectedValueOnce( + new Error('provider unavailable') + ) + + await expect( + failed.manager.acquire('host-1', installation) + ).rejects.toThrow() + expect(observeFailure).toHaveBeenCalledWith( + expect.objectContaining({ + component: 'remote-agent', + stage: 'connect', + code: 'remote.connection.network' + }) + ) + await failed.manager.dispose() + + observeFailure.mockClear() + const connected = harness( + undefined, + undefined, + 0, + observeFailure + ) + const lease = await connected.manager.acquire( + 'host-1', + installation + ) + connected.clients[0]!.transportClose() + expect(observeFailure).toHaveBeenCalledWith( + expect.objectContaining({ + component: 'remote-agent', + stage: 'disconnect', + code: 'remote.connection.lost' + }) + ) + lease.release() + await connected.manager.dispose() + }) }) diff --git a/src/main/remote-agent/remote-agent-connection-manager.ts b/src/main/remote-agent/remote-agent-connection-manager.ts index cd39d797..0fd3562c 100644 --- a/src/main/remote-agent/remote-agent-connection-manager.ts +++ b/src/main/remote-agent/remote-agent-connection-manager.ts @@ -27,6 +27,9 @@ import { AgentProtocolClient, type AgentProtocolClientError } from './agent-protocol-client' +import type { + DesktopDiagnosticFailureObserver +} from '../desktop-diagnostics' import { ControllerStateStore, type ControllerAcpRecoveryBinding, @@ -170,6 +173,9 @@ export class RemoteAgentConnectionManager { readonly #goodBuddyVersion: string readonly #idleTimeoutMs: number readonly #attachTimeoutMs: number + readonly #observeFailure: + | DesktopDiagnosticFailureObserver + | undefined readonly #identityKey = randomBytes(32) readonly #entries = new Map() readonly #hostConnectionStates = new Map< @@ -192,6 +198,7 @@ export class RemoteAgentConnectionManager { createProtocolClient?: ( transport: AgentAttachTransport ) => AgentProtocolClient + observeFailure?: DesktopDiagnosticFailureObserver }) { this.#resolver = options.resolver this.#sshPool = options.sshPool @@ -206,6 +213,7 @@ export class RemoteAgentConnectionManager { options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS this.#attachTimeoutMs = options.attachTimeoutMs ?? DEFAULT_ATTACH_TIMEOUT_MS + this.#observeFailure = options.observeFailure if ( this.#goodBuddyVersion.length < 1 || Buffer.byteLength(this.#goodBuddyVersion, 'utf8') > 64 @@ -236,16 +244,30 @@ export class RemoteAgentConnectionManager { } signal?.throwIfAborted() const installation = validateInstallation(installationInput) - // One atomic resolver call supplies the complete current host target. - const target = await this.#resolver.resolve(hostId) - signal?.throwIfAborted() - if (target.host.id !== hostId) { - throw terminalError( - 'Resolved SSH Host identity does not match the request', - 'host-identity' - ) + let target: SshConnectionPoolTarget + let identity: RemoteAgentConnectionIdentity + try { + // One atomic resolver call supplies the complete current host target. + target = await this.#resolver.resolve(hostId) + signal?.throwIfAborted() + if (target.host.id !== hostId) { + throw terminalError( + 'Resolved SSH Host identity does not match the request', + 'host-identity' + ) + } + identity = this.#identity(target, installation) + } catch (error) { + const classified = classifyConnectionError(error) + if (!signal?.aborted) { + this.#reportFailure( + 'connect', + `remote.connection.${classified.reason}`, + classified + ) + } + throw error } - const identity = this.#identity(target, installation) let entry = this.#entries.get(identity.cacheKey) if (entry === undefined) { if (this.#entries.size >= MAXIMUM_CONNECTIONS) { @@ -515,6 +537,13 @@ export class RemoteAgentConnectionManager { transport?.dispose() sshLease?.release() const classified = classifyConnectionError(error) + if (!isAbortCancellation(entry.controller.signal)) { + this.#reportFailure( + 'connect', + `remote.connection.${classified.reason}`, + classified + ) + } if ( this.#entries.get(entry.key) === entry && entry.connectionState !== 'disposed' @@ -914,6 +943,11 @@ export class RemoteAgentConnectionManager { error ) entry.connectionState = 'offline' + this.#reportFailure( + 'disconnect', + 'remote.connection.lost', + entry.failure + ) this.#syncHostConnectionState(entry.hostId) active?.unsubscribeClose() active?.sshLease.release() @@ -922,6 +956,23 @@ export class RemoteAgentConnectionManager { } } + #reportFailure( + stage: string, + code: string, + error: unknown + ): void { + try { + this.#observeFailure?.({ + component: 'remote-agent', + stage, + code, + error + }) + } catch { + // Diagnostics must not alter remote connection behavior. + } + } + async #persistConnection( entry: ConnectionEntry, active: ActiveConnection, @@ -1070,6 +1121,21 @@ export class RemoteAgentConnectionManager { } } +function isAbortCancellation(signal: AbortSignal): boolean { + if (!signal.aborted) { + return false + } + const reason: unknown = signal.reason + if (typeof reason !== 'object' || reason === null) { + return true + } + try { + return !('name' in reason) || reason.name === 'AbortError' + } catch { + return true + } +} + function validateInstallation( input: RemoteAgentInstallationIdentity ): RemoteAgentInstallationIdentity { diff --git a/src/main/remote-agent/remote-agent-services.ts b/src/main/remote-agent/remote-agent-services.ts index 6bc4b780..9d473ec0 100644 --- a/src/main/remote-agent/remote-agent-services.ts +++ b/src/main/remote-agent/remote-agent-services.ts @@ -14,6 +14,9 @@ import { RemoteAgentConnectionManager, type RemoteAgentTargetResolver } from './remote-agent-connection-manager' +import type { + DesktopDiagnosticFailureObserver +} from '../desktop-diagnostics' const CONTROLLER_STATE_FILE_NAME = 'remote-agent-controller-state.json' @@ -34,6 +37,7 @@ type RemoteAgentServiceFactories = { sshPool: SshConnectionPool controllerState: ControllerStateStore goodBuddyVersion: string + observeFailure?: DesktopDiagnosticFailureObserver }) => RemoteAgentConnectionManager } @@ -45,6 +49,7 @@ export type RemoteAgentServicesOptions = { resourcesPath: string packaged: boolean controlPlanePackageInstaller: Buffer | string + observeFailure?: DesktopDiagnosticFailureObserver factories?: Partial } @@ -103,7 +108,8 @@ export class RemoteAgentServices { resolver: this.targetResolver, sshPool: this.sshPool, controllerState: this.controllerState, - goodBuddyVersion: options.goodBuddyVersion + goodBuddyVersion: options.goodBuddyVersion, + observeFailure: options.observeFailure }) } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index d7c9db96..7482c35b 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1358,6 +1358,19 @@ describe('App', () => { await screen.findByRole('heading', { name: '今天想一起完成什么?' }) + const runtimeStatus = document.querySelector( + '.runtime-status' + ) + expect(runtimeStatus).not.toBeNull() + expect( + within(runtimeStatus!).getByText('就绪') + ).toBeInTheDocument() + expect(runtimeStatus).toHaveAttribute( + 'aria-describedby', + 'topbar-runtime-detail' + ) + expect(screen.getByText('Ready', { selector: '.sr-only' })) + .toHaveAttribute('id', 'topbar-runtime-detail') expect( screen.getByText( /你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u @@ -2049,6 +2062,25 @@ describe('App', () => { within(conversationList).queryByText('其他项目里的 Alpha') ).not.toBeInTheDocument() }) + + fireEvent.change(search, { target: { value: 'missing topic' } }) + expect( + await within(conversationList).findByText('没有匹配的对话') + ).toBeInTheDocument() + expect( + within(conversationList).getByText( + '请修改搜索关键词,或清除搜索后查看全部会话。' + ) + ).toBeInTheDocument() + fireEvent.click( + within(conversationList).getByRole('button', { + name: '清除搜索' + }) + ) + expect(search).toHaveValue('') + expect( + await within(conversationList).findByText('标题里的 Alpha') + ).toBeInTheDocument() }) it('keeps Settings open when the interface language changes', async () => { @@ -2235,7 +2267,7 @@ describe('App', () => { } }) - it('does not disturb startup when updates are current or offline', async () => { + it('keeps current-version checks quiet and reports startup check failures', async () => { const currentResult = { updateAvailable: false, currentVersion: '0.8.4', @@ -2252,7 +2284,11 @@ describe('App', () => { const check = vi .fn() .mockResolvedValueOnce(currentResult) - .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce( + new Error( + "Error invoking remote method 'application:update:check': TypeError: fetch failed" + ) + ) api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, @@ -2289,13 +2325,39 @@ describe('App', () => { render() await waitFor(() => expect(check).toHaveBeenCalledTimes(2)) expect( - screen.queryByText(/发现 GoodBuddy|版本检查失败/u) - ).not.toBeInTheDocument() + await screen.findByRole('alert') + ).toHaveTextContent( + '启动更新检查失败(GitHub):无法连接更新源,请检查网络或代理后重试' + ) } finally { delete api.updates } }) + it('shows a recoverable recent-conversation load failure', async () => { + vi.mocked(api.projects.list) + .mockRejectedValueOnce(new Error('database busy')) + .mockResolvedValueOnce([project]) + + render() + + const loadError = await screen.findByRole('alert') + expect(loadError).toHaveTextContent('最近会话加载失败') + expect(loadError).toHaveTextContent('database busy') + fireEvent.click( + within(loadError).getByRole('button', { + name: '重新读取会话' + }) + ) + await waitFor(() => + expect(api.projects.list).toHaveBeenCalledTimes(2) + ) + await waitFor(() => + expect(screen.queryByText('最近会话加载失败')) + .not.toBeInTheDocument() + ) + }) + it('keeps rendering when an older preload has no browser bridge', async () => { Object.defineProperty(window, 'goodbuddy', { configurable: true, @@ -4134,9 +4196,10 @@ describe('App', () => { fireEvent.change(await screen.findByLabelText('默认工作区目录'), { target: { value: 'C:\\Unsaved from App' } }) - fireEvent.click( - screen.getByRole('button', { name: '知识库' }) - ) + const knowledgeNavigation = screen.getByRole('button', { + name: '知识库' + }) + fireEvent.click(knowledgeNavigation) expect( screen.getByRole('heading', { name: '设置中心' }) @@ -4150,6 +4213,43 @@ describe('App', () => { expect( await screen.findByRole('heading', { name: '知识库' }) ).toBeVisible() + await waitFor(() => expect(knowledgeNavigation).toHaveFocus()) + }) + + it('restores focus outside Settings after closing the page', async () => { + render() + + await screen.findByLabelText('向 GoodBuddy 提问') + const settingsTrigger = screen.getByRole('button', { + name: /本地工作区/u + }) + fireEvent.click(settingsTrigger) + await screen.findByRole('heading', { name: '设置中心' }) + + fireEvent.click( + screen.getByRole('button', { name: '关闭设置' }) + ) + + await waitFor(() => expect(settingsTrigger).toHaveFocus()) + }) + + it('focuses the Composer after closing automatically opened Settings', async () => { + vi.mocked(api.agent.getStatus).mockResolvedValueOnce({ + id: 'setup', + label: '未配置', + available: false, + supportsToolExecution: false, + detail: '尚未配置 Runtime' + }) + render() + + await screen.findByRole('heading', { name: '设置中心' }) + const composer = screen.getByLabelText('向 GoodBuddy 提问') + fireEvent.click( + screen.getByRole('button', { name: '关闭设置' }) + ) + + await waitFor(() => expect(composer).toHaveFocus()) }) it('updates and removes the composer shortcut hint immediately after saving Settings', async () => { @@ -5043,6 +5143,32 @@ describe('App', () => { }) }) + it('associates attachment errors with the Composer controls', async () => { + vi.mocked(api.context.selectFiles).mockRejectedValueOnce( + new Error('文件解析失败,请重新选择') + ) + render() + + const addButton = await screen.findByLabelText('添加附件') + const input = screen.getByLabelText('向 GoodBuddy 提问') + fireEvent.click(addButton) + + const error = await screen.findByRole('alert', { + name: '文件解析失败,请重新选择' + }) + expect(error).toHaveAttribute('id', 'composer-context-error') + expect(input).toHaveAttribute('aria-invalid', 'true') + expect(input).toHaveAttribute( + 'aria-describedby', + 'composer-context-error' + ) + expect(addButton).toHaveAttribute('aria-invalid', 'true') + expect(addButton).toHaveAttribute( + 'aria-describedby', + 'composer-context-error' + ) + }) + it('sends and renders five selected images together', async () => { const imageAttachments = Array.from({ length: 5 }, (_, index) => ({ id: `00000000-0000-4000-8000-00000000031${index}`, @@ -5279,11 +5405,13 @@ describe('App', () => { newConversationListener?.() }) - expect( - screen.getAllByRole('button', { - name: /^更多会话操作/u - }) - ).toHaveLength(2) + await waitFor(() => + expect( + screen.getAllByRole('button', { + name: /^更多会话操作/u + }) + ).toHaveLength(2) + ) }) it('opens a workspace Markdown file in the right-side preview', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 425cff48..f3ec3e5d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -222,7 +222,10 @@ import { type KeepAliveCacheEntry } from './keep-alive-cache' import { activateModalFocus, trapTabFocus } from './dialog-focus' -import { displayErrorMessage } from './error-message' +import { + displayErrorMessage, + displayNetworkAwareErrorMessage +} from './error-message' import { getProjectDisplayText } from './project-display' const knowledgeWorkspaceRoute = createPreloadableComponent( @@ -2304,6 +2307,12 @@ function App(): React.JSX.Element { Record >({}) const [view, setViewState] = useState('chat') + const settingsEntryFocusRef = useRef( + undefined + ) + const settingsExitFocusRef = useRef( + undefined + ) const settingsLeaveRequesterRef = useRef< SettingsLeaveRequester | undefined >(undefined) @@ -2319,6 +2328,7 @@ function App(): React.JSX.Element { ) const commitView = useCallback( (next: WorkspaceView): void => { + const previous = viewRef.current const now = Date.now() const runningConversationIds = new Set( [...activeRuns.current.values()].map((run) => run.conversationId) @@ -2355,6 +2365,25 @@ function App(): React.JSX.Element { }) ) setViewState(next) + if (previous === 'settings' && next !== 'settings') { + const preferred = settingsExitFocusRef.current + const returnTarget = settingsEntryFocusRef.current + settingsExitFocusRef.current = undefined + settingsEntryFocusRef.current = undefined + requestAnimationFrame(() => { + const target = + preferred?.isConnected + ? preferred + : returnTarget?.isConnected + ? returnTarget + : next === 'chat' + ? inputRef.current + : document.querySelector( + `.primary-nav [aria-current="page"]` + ) + target?.focus() + }) + } }, [] ) @@ -2364,6 +2393,19 @@ function App(): React.JSX.Element { typeof update === 'function' ? update(viewRef.current) : update + if ( + viewRef.current !== 'settings' && + next === 'settings' && + !settingsEntryFocusRef.current?.isConnected + ) { + const activeElement = document.activeElement + settingsEntryFocusRef.current = + activeElement instanceof HTMLElement && + activeElement !== document.body && + activeElement.isConnected + ? activeElement + : undefined + } if (viewRef.current === 'settings' && next !== 'settings') { const requestLeave = settingsLeaveRequesterRef.current if (requestLeave) { @@ -2442,6 +2484,9 @@ function App(): React.JSX.Element { const deferredSearchQuery = useDeferredValue(searchQuery) const [searchConversationSnapshot, setSearchConversationSnapshot] = useState(conversations) + const [conversationLoadError, setConversationLoadError] = + useState() + const [conversationLoadRetry, setConversationLoadRetry] = useState(0) const [conversationActionsId, setConversationActionsId] = useState('') const [confirmingConversationId, setConfirmingConversationId] = useState('') @@ -2629,7 +2674,15 @@ function App(): React.JSX.Element { requestAnimationFrame(() => sidebarToggleRef.current?.focus()) }, []) const navigateFromSidebar = useCallback( - (nextView: WorkspaceView): void => { + (nextView: WorkspaceView, trigger: HTMLElement): void => { + if (viewRef.current !== 'settings' && nextView === 'settings') { + settingsEntryFocusRef.current = trigger + } else if ( + viewRef.current === 'settings' && + nextView !== 'settings' + ) { + settingsExitFocusRef.current = trigger + } setView(nextView) if (narrowWindow) { closeNarrowSidebar() @@ -2849,6 +2902,7 @@ function App(): React.JSX.Element { return } startupUpdateCheckStartedRef.current = true + let updateCheckSource: 'github' | 'mirror' | undefined void updates .getSettings() .then(async (settings) => { @@ -2865,6 +2919,7 @@ function App(): React.JSX.Element { if (!settings.checkUpdatesOnStartup) { return } + updateCheckSource = settings.updateSource const result = await updates.check() if (result.updateAvailable) { notify({ @@ -2877,7 +2932,27 @@ function App(): React.JSX.Element { }) } }) - .catch(() => undefined) + .catch((reason: unknown) => { + if (!updateCheckSource) { + return + } + notify({ + tone: 'error', + message: i18n.t('notices.startupUpdateCheckFailed', { + ns: 'app', + source: i18n.t( + `notices.updateSources.${updateCheckSource}`, + { ns: 'app' } + ), + error: displayNetworkAwareErrorMessage( + reason, + i18n.t('notices.updateCheckFailed', { ns: 'app' }), + i18n.t('notices.updateCheckNetwork', { ns: 'app' }) + ) + }), + dedupeKey: 'startup-update-check' + }) + }) }, [i18n, setView]) useEffect(() => { @@ -4588,7 +4663,9 @@ function App(): React.JSX.Element { status: event.type === 'error' && !representedToolError ? event.message - : undefined, + : event.type === 'done' + ? tRef.current('chat.status.taskCompleted') + : undefined, contextCompression: event.type === 'error' && message.contextCompression?.state === 'compressing' @@ -5346,19 +5423,18 @@ function App(): React.JSX.Element { ) void initialization.catch((reason: unknown) => { if (active) { - notify({ - tone: 'error', - message: - reason instanceof Error - ? reason.message - : tRef.current('notices.projectReadFailed') - }) + setConversationLoadError( + displayErrorMessage( + reason, + tRef.current('notices.projectReadFailed') + ) + ) } }) return () => { active = false } - }, [notify, setActiveId]) + }, [conversationLoadRetry, setActiveId]) useEffect(() => { if (!activeProjectId) { @@ -7958,6 +8034,23 @@ function App(): React.JSX.Element { const mainSidebarOpen = narrowWindow && sidebarOpen const backgroundIsolated = mainSidebarOpen + const runtimeState = + runtimeSwitching || + !runtime || + runtimeStatusKey !== activeRuntimeSelectionKey + ? 'connecting' + : runtime.available + ? 'ready' + : 'unavailable' + const runtimeDetailId = 'topbar-runtime-detail' + const runtimeDetail = + runtimeState === 'connecting' + ? t('runtime.connecting') + : runtime?.detail || + (runtimeState === 'unavailable' + ? t('runtime.unavailable') + : t('runtime.state.ready')) + const composerContextErrorId = 'composer-context-error' return (
@@ -8037,7 +8130,9 @@ function App(): React.JSX.Element { className={ view === 'chat' ? 'nav-item nav-item--active' : 'nav-item' } - onClick={() => navigateFromSidebar('chat')} + onClick={(event) => + navigateFromSidebar('chat', event.currentTarget) + } type="button" >
@@ -8592,7 +8737,9 @@ function App(): React.JSX.Element { className="user-card" type="button" onFocus={() => preloadWorkspaceRouteOnIntent('settings')} - onClick={() => navigateFromSidebar('settings')} + onClick={(event) => + navigateFromSidebar('settings', event.currentTarget) + } onPointerEnter={() => preloadWorkspaceRouteOnIntent('settings') } @@ -8684,23 +8831,30 @@ function App(): React.JSX.Element { )}
{runtime?.label ?? t('runtime.detecting')} + + {t(`runtime.state.${runtimeState}`)} + {runtime?.capability === 'image-generation' && ( {t('runtime.imageGeneration')} )} + + {runtimeDetail} +