diff --git a/AGENTS.md b/AGENTS.md index f148b1e8..63bb6105 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,11 @@ additional hardening beyond the actual internal-network threat model: ## Data and Compatibility +- Before changing released database schemas or large-data migrations, read + [`docs/development/database-migrations.md`](./docs/development/database-migrations.md). + Keep feature-specific storage rules and validation evidence in the owning + feature documents. + - Preserve released user-owned data, migrations required by released data, and supported external contracts. - Compatibility does not apply to unshipped, branch-local, or safely disposable diff --git a/FEATURES.md b/FEATURES.md index c42c547d..036de2c8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -21,7 +21,7 @@ otherwise. packages target compatible x64 and arm64 environments, including UOS, Kylin, Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification. LoongArch has a separate experimental loong64 preview outside standard - releases and automatic updates; no preview is planned for 0.13.0. See the + releases and automatic updates; no preview is planned for 0.13.1. See the [preview boundaries](./docs/development/loongarch-preview-build.md). - [x] **Configurable global shortcut**: Enable, disable, or record an Electron accelerator under Platform Features / General. The default remains @@ -111,6 +111,14 @@ otherwise. ### Agent Runtimes and model connections +- [x] **Compact subagent history without lost details**: Local and managed SSH + subagent events store changes instead of repeated full progress snapshots. + The first upgrade automatically converts existing history and reclaims disk + space, with visible progress and quit/retry support. Chats, execution details, + results, and remote event deduplication are preserved; older clients cannot + reopen the upgraded database. See + [storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md). + - [x] **Direct model Runtime**: Supports question answering, knowledge synthesis, controlled tool execution, image generation, and reference-image editing through providers supporting the OpenAI-compatible image-editing @@ -215,7 +223,7 @@ otherwise. failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite database. Successful tool START/END events appear exactly once, with no Prompt, provider, or tool replay observed. The current Agent source lock is - `0.11.24`, while the current Desktop release candidate is `0.13.0`; formal + `0.11.24`, while the current Desktop release candidate is `0.13.1`; formal publication status follows the separate Agent and Desktop release channels. Previous macOS validation covered native package installation, detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in @@ -668,7 +676,7 @@ otherwise. ### Open source, builds, and releases -- Current source candidates are Desktop `0.13.0` and Agent `0.11.24`, with +- Current source candidates are Desktop `0.13.1` and Agent `0.11.24`, with OpenCode pinned to `1.18.29`. Publication status follows the independent Desktop and Agent release channels. - Candidate acceptance remains separate from version preparation. The current diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 302e53d2..9ac1413e 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -14,7 +14,7 @@ ### 桌面基础、工作空间与上下文 - [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。 -- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.0` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。 +- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.13.1` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。 - [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。 - [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。 - [x] **跨项目会话活动**:统一入口汇总各项目正在运行和需要处理的对话,保留仍有活动的较早会话,可直接打开对应对话;项目选择器也显示活动数量。 @@ -32,6 +32,8 @@ ### Agent Runtime 与模型连接 +- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。 + - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。 - [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。 - [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。可处理的读取或搜索错误允许模型调整参数后继续,部分搜索结果保留并明确提示覆盖不完整。 @@ -119,7 +121,7 @@ ### 开源、构建与发布 -- 当前源码候选为 Desktop `0.13.0`、Agent `0.11.24`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。 +- 当前源码候选为 Desktop `0.13.1`、Agent `0.11.24`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。 - 候选验收与版本准备分别记录。当前远程问答源码已通过真实 Linux x64 SSH 与 Electron 问答测试,但模型为确定性 loopback 服务,不是完整生产 Main/Preload 配合真实服务商;该真实模型链路和候选原生 CI/打包验证仍待完成,详见[远程问答证据](./docs/features/assistant-workbar/progress.md)。 - [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。 - [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。 diff --git a/docs/development/database-migrations.md b/docs/development/database-migrations.md new file mode 100644 index 00000000..e85bc3b3 --- /dev/null +++ b/docs/development/database-migrations.md @@ -0,0 +1,67 @@ +# 数据库迁移与大数据升级指南 + +本文约束已发布用户数据的数据库升级。具体字段、存储表示和功能行为由对应功能文档负责; +本文不建立通用迁移框架、回滚引擎或独立状态日志。 + +## 先确认问题和数据边界 + +1. 先只读统计文件大小、WAL、页数、空闲页、表与字段的字节数,不先删除或压缩数据库。 +2. 区分有效内容、重复存储、索引和已释放但未回收的空间。空闲页少时,单独 + `VACUUM` 不能消除重复记录。 +3. 查清实际写入、读取、删除、重放与恢复入口。修改 Runtime 相关持久化时,同时检查 + 本机和 GoodBuddy Agent 的桌面路径,不假设一个入口覆盖另一个入口。 +4. 明确用户必须保留的信息、稳定 ID、顺序、时间、引用与远程去重字段。 + 分支内未发布、可安全重建的内部记录不需要兼容机制。 + +## 实现原则 + +- 先修新增数据路径,再修历史数据。历史转换与新写入尽量使用同一套转换函数。 +- 普通 schema 变更使用既有 `PRAGMA user_version` 和 SQLite 事务; + 已升级 schema 不允许旧客户端强行打开,不通过回写版本号支持降级。 +- 大数据转换不得同步阻塞 Electron Main 或 Renderer。放到工作线程,在业务连接 + 打开前处理,并使用应用的共享字体、主题与页面组件显示进度和可执行的失败提示。 +- 根据实测设置有界读取批次与缓存,批次提交后再报告进度。保持 SQLite 事务与 + checkpoint,不靠关闭同步落盘、取消约束或一次性读入整库提速。 +- 从已提交数据直接重试和还原即可完成的迁移,不增加 durable snapshot、journal、 + receipt 或多阶段恢复状态。退出及失败至少回滚未提交批次,保留已完成部分。 +- 差量编码必须能精确还原原始事件。更新内容、删除或重排都要覆盖;涉及远程来源时, + 去重、冲突判断、消息投影和恢复序号不得失效。 +- 释放数据库页和归还文件系统空间是不同步骤。先减少实际存储内容,再执行需要的 + 空间回收;保留足够临时空间,磁盘不足时明确失败并允许重试。 +- SQLite 同库写入是串行的。是否增加读取/转换并行,应由实际读取、解析、写入、 + 提交与 checkpoint 的耗时决定,不把增加写线程当作默认优化。 + +## 验证与备份 + +### 合成回归 + +覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题 +必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。 + +### 真实旧库 + +1. 关闭占用源数据库的应用,确认 WAL 状态。没有 WAL 时可复制已关闭的数据库; + 有活动 WAL 时使用 SQLite 一致性备份机制,不只复制主文件。 +2. 备份到空间足够的位置,记录字节数并校验 SHA-256。备份和测试副本分离, + 不直接在唯一原件或唯一备份上试跑。 +3. 使用当前源码或用户要求的 portable 正常启动入口执行升级,不用手工 SQL 代替 + 产品升级过程。退出一次后重新启动,验证已提交数据可以继续处理。 +4. 比较升级前后消息、任务、成果等用户表的内容;对转换事件逐条还原后比较原始内容、 + ID、顺序、时间和去重来源,不只比较行数。日志只能包含统计和断言结果,不记录正文。 +5. 运行数据库完整性检查,记录主文件和 WAL 的实际字节数、回收比例、耗时、平台、 + 源 schema 与目标 schema。确认原件和备份未被改写。 +6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则 + 检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。 + +## 发布与维护 + +发布说明应告知升级范围、可能耗时、退出重试、额外磁盘空间和降级边界。不得把一个测试库 +的回收比例当作所有用户都能获得的保证。功能文档记录具体转换规则,进度文档记录实际 +证据和未完成项;未经验证的阶段不能标为完成。 + +### 已有实例 + +- Desktop 0.13.1:子任务累计过程重复保存导致数据库膨胀,转换到 schema 34 的差量事件 + 表示,并回收空间。具体规则见 + [执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md), + 数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。 diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index 985bb903..14c4d977 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -10,6 +10,7 @@ | [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 | | [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 | | [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 | +| [执行记录存储与升级回收](./execution-history-storage.md) | 子任务过程差量存储、远程去重、旧库转换、空间回收及升级界面 | | [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 | 当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD diff --git a/docs/features/assistant-workbar/execution-history-storage.md b/docs/features/assistant-workbar/execution-history-storage.md new file mode 100644 index 00000000..a072226a --- /dev/null +++ b/docs/features/assistant-workbar/execution-history-storage.md @@ -0,0 +1,53 @@ +# 执行记录存储与升级回收 + +通用迁移、备份、性能测量和发布要求见 +[数据库迁移与大数据升级指南](../../development/database-migrations.md)。 + +## 用户行为 + +子任务仍实时显示完整的文字、思考、工具过程和最终结果。运行时间较长、工具输出较多时, +数据库不再为每个流式更新重复保存此前的整份过程;本机和托管 SSH OpenCode 使用相同 +的桌面存储机制。此修复不关闭子任务、不截短已有过程,也不增加 Execute 权限门槛。 + +已有数据库在首次升级时自动处理。启动页铺满窗口,显示处理数量、全宽进度条和近期 +处理速度;处理时间取决于历史数据量与磁盘速度,不承诺固定几分钟。用户可以退出, +下次启动重新检查并继续转换未完成的数据;完成后回收空闲页并进入应用。失败时留在 +该页提供重试与退出,磁盘空间不足会明确说明,不删除聊天来腾出空间。 + +## 存储规则 + +- `messages.metadata_json` 继续保存供会话显示的完整子任务过程。 +- `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。 + `progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。 + 完全相同的过程使用空更新数组。其他事件内容不改变。 +- 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、 + 最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。 +- Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整 + `progress`;差量表示只存在于桌面数据库内部,不要求更新远端 Agent 协议。 +- 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验, + 相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID + 核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。 + +## 旧库升级 + +桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。 +按任务有序读取,每批最多 32 条,提交后报告进度;SQLite 保留事务原子性及正常 WAL +自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史 +快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。 + +转换可以混合读取旧完整记录与已经转换的记录,因此退出、磁盘写入失败后可以重新扫描 +并继续,不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非 +在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。 + +业务初始化随后将数据库升级为 schema 34,并建立子任务事件的部分索引。 +低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用 +升级前备份,不要修改 `user_version` 强行绕过版本检查。 + +## 验证要求 + +- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。 +- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。 +- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、 + 其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。 +- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。具体证据见 + [实现与验证进度](./progress.md)。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 9c4a7c4c..dafa9ea7 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,38 @@ # 工作栏实现与验证进度 +## 2026-09-13 Desktop 0.13.1 执行记录去重与大库升级 + +- 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发 + 要求见[数据库迁移指南](../../development/database-migrations.md)。本机和远程写入共用 + 差量编码,不修改 Runtime 公共事件或 Agent 协议;消息投影与来源去重继续保持事务。 +- 真实 portable 旧库先备份并校验 SHA-256,再在独立新版 portable 中启动升级: + 13,094,912 → 12,595,200 字节,schema 33 → 34。659 条消息、52 条会话、133 条成果、 + 笔记/待办及模型用量表逐表内容一致,完整性检查通过。 +- 另对真实膨胀库的核验备份副本运行同一 portable 启动入口: + 97,308,667,904 → 594,182,144 字节,即 90.63 GiB → 566.66 MiB, + 实际回收 90.07 GiB(99.39%),freelist 为 0。原库和原 portable 数据库未改写。 +- 全量还原比较 411,981 条事件,所有内容、事件 ID、时间及远程来源字段一致; + SQLite `integrity_check` 返回 `ok`。1,876 条消息、895 个任务、136 个成果及笔记、 + 待办、用量和活动记录等表内容一致。隔离 profile 没有复制模型配置,既有启动修复更新了 + 一条项目及一条会话的 Runtime 选择,项目 `updated_at` 随之更新;不是事件迁移修改。 +- 真实升级中通过“退出,稍后继续”停止,再用更新后的 portable 继续并完成。初版 UI + 只按内容高度展开且进度条过窄,用户指出后修复为全高画布和全宽进度条,并显示处理速度; + 实包测得页面高度约 761 CSS px、viewport 761 px,进度条宽约 960 px,截图人工复核通过。 +- 大库采样的读取耗时占约 65%、JSON 解析约 17%。将升级读取缓存由 8 MiB 提至 + 64 MiB,保留每批 32 条事务和 SQLite 自动 checkpoint,只在任务结束时截断 WAL。 + 相邻 2,048 条转换采样从约 12.0 秒降至 8.8 秒;样本和缓存状态不同,不作为固定加速比。 + 未增加同库并行写入,也未关闭事务或同步落盘。 +- 当前源码通过真实本地 OpenCode Execute 和共享 Linux x64 Host 的托管 Ask/Execute。 + 本机与远程各观察到 65 条子任务事件,最终过程仍可显示,远程重复事件不重复投影; + 测试资源均位于专用目录。共提交 3 次真实 Prompt,Runtime 上报 4 笔模型用量; + 子代理内部 HTTP 调用未单独计数,不将该上报数称为服务商请求总数。 +- 首轮完整 `npm test` 为 4,114 通过、66 跳过,637.97 秒;后续 8 项新增/修正的专项 + 回归通过,`npm run typecheck`、`npm run lint` 通过。生产构建及用户要求的 portable + 实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求 + 不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。 +- 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。 + 截至本记录尚未提交或打标签,准确候选提交、main CI 和原生发布结果待发布流程确认。 + ## 2026-09-13 原生浏览器与应用浮层修复 - 用户在“添加实例”中复现原生网页盖住弹窗。原先只有终端关闭确认释放 viewport, diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index 555610e8..613ca77b 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -42,6 +42,9 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答 ## 托管 Runtime 的并行与输出 +子任务完整过程的显示与数据库差量存储相互独立,本机和托管 SSH 共用桌面存储层; +旧库升级与空间回收规则见[执行记录存储与升级回收](./execution-history-storage.md)。 + - 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费 原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步 计算阻塞同一原生进程中的其他会话。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 62b0375e..de008c31 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -41,6 +41,11 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭 ## 组件 +桌面保存托管 SSH 子任务事件时,共用 +[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。 +Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号 +继续保留。该存储修复不要求单独升级 Agent。 + ```text Renderer -> 窄 Preload / Main IPC diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md index 063cdb9f..756eb6d2 100644 --- a/docs/quality/recurring-defects.md +++ b/docs/quality/recurring-defects.md @@ -71,6 +71,24 @@ Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。 **历史**:外部知识库绑定同时出现三处:`requireLibrary`(30+ 调用点,一处在循环内)、 `getKnowledgeSnapshot`(每个知识库两次全表扫描)、`listInstances`(每个实例过滤一次)。 +## 将累计过程当作增量事件反复落库 + +**症状**:聊天正文很小,执行记录数据库却增长到几十 GiB;只增加几个字节的子任务输出, +又写入接近 1 MiB 的旧过程副本。 + +**原因**:本机与远程 OpenCode 共用完整进度快照,两个桌面落库入口都按流式频率新增 +完整 JSON;正文流的缓冲器没有覆盖这种累计快照。 + +**规则**:界面快照不直接作为追加式日志的存储表示。子任务事件通过共享存储层记录变化, +保留逐条还原和远程来源去重;会话仍保存完整显示结果。旧用户数据通过同一编码器无损 +转换后回收空间,不靠删聊天或单独修本机数据库解决。 + +**防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、 +消息事务回滚及旧库中断续跑。真实旧库需验证逐条等价和物理文件缩小。 + +**历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB; +本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。 + ## 生产路径复用“测试”函数 **症状**:正常用户操作触发了只应在配置校验时出现的错误码,或多发了一次网络请求。 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f1671cdd..6ed0e401 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -112,6 +112,7 @@ export default defineConfig({ rollupOptions: { input: { index: resolve('src/main/index.ts'), + 'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'), 'wechat-sidecar': resolve( 'src/main/channels/wechat-sidecar.ts' ), diff --git a/package-lock.json b/package-lock.json index 3b7cef66..3c24dcc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.13.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.13.0", + "version": "0.13.1", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index bf97d3c2..271efd71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.13.0", + "version": "0.13.1", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/resources/release-notes.json b/resources/release-notes.json index 1eaa48cb..67aa6333 100644 --- a/resources/release-notes.json +++ b/resources/release-notes.json @@ -1,6 +1,40 @@ { "formatVersion": 1, "releases": [ + { + "version": "0.13.1", + "releasedAt": "2026-09-13", + "notes": { + "zh-CN": { + "highlights": [], + "features": [], + "fixes": [ + "**修复数据库异常膨胀**:长任务和子任务不再反复保存整份执行过程。首次启动自动整理旧记录并回收磁盘空间,保留聊天、执行详情和结果。", + "**浏览器不再遮挡弹窗**:打开项目设置、知识库实例等弹窗时,内置网页会正确让出显示区域。关闭弹窗后恢复原网页,不丢失页面状态。", + "**知识库配置更清楚**:长名称和长参数表单不再挤压来源选择控件。切换远端知识库时自动名称随之更新,手工填写的名称保持不变。", + "**更新说明恢复显示**:修复历史更新说明增长后,本次更新内容可能无法加载的问题。当前版本说明可正常显示并确认,重启后不再重复弹出。" + ], + "notices": [ + "整理较大的历史数据库可能需要较长时间和额外磁盘空间,支持退出后下次继续。升级后的数据库不能由旧版客户端打开,需要回退时请保留升级前备份。", + "Agent 保持 **0.11.24**,本次不重新发布 Agent。" + ] + }, + "en-US": { + "highlights": [], + "features": [], + "fixes": [ + "**Fix excessive database growth**: Long-running tasks and subagents no longer repeatedly save the entire execution history. The first launch compacts existing records and reclaims disk space while preserving chats, execution details, and results.", + "**Keep dialogs above embedded webpages**: Embedded webpages now yield their display area when project settings, knowledge-instance dialogs, and other overlays open. Closing the overlay restores the original webpage without losing its state.", + "**Clearer knowledge configuration**: Long names and parameter forms no longer squeeze source-selection controls. Automatically filled names follow the selected remote knowledge base, while manually entered names are preserved.", + "**Restore release-note display**: Fixed an issue where growing historical release notes could prevent the current update notes from loading. Current-version notes can be displayed and acknowledged without appearing again after restart." + ], + "notices": [ + "Large history databases may require substantial processing time and extra disk space; you can quit and continue on the next launch. Older clients cannot open the upgraded database, so retain a pre-upgrade backup if rollback is needed.", + "Agent remains at **0.11.24** and is not republished." + ] + } + } + }, { "version": "0.13.0", "releasedAt": "2026-09-13", diff --git a/src/main/assistant-storage-startup.ts b/src/main/assistant-storage-startup.ts new file mode 100644 index 00000000..1eb2cd20 --- /dev/null +++ b/src/main/assistant-storage-startup.ts @@ -0,0 +1,117 @@ +import { app, ipcMain, type BrowserWindow } from 'electron' +import { stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import { + assistantStorageActionSchema, + assistantStorageProgressSchema, + type AssistantStorageProgress +} from '../shared/assistant-storage-contracts' +import { ipcChannels } from '../shared/ipc-channels' +import { assertTrustedSender } from './trusted-ipc-sender' +import { loadMainWindow } from './window' + +export async function prepareAssistantStorage( + window: BrowserWindow, + databasePath: string, + signal: AbortSignal +): Promise { + const file = await stat(databasePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined + throw error + }) + if (!file) return + let progress: AssistantStorageProgress = { + stage: 'scanning', processed: 0, total: 0, bytesBefore: file.size + } + let shown = false + let retry: (() => void) | undefined + ipcMain.handle(ipcChannels.storageUpgradeProgress, (event) => { + assertTrustedSender(event, window) + return progress + }) + ipcMain.handle(ipcChannels.storageUpgradeAction, (event, input: unknown) => { + assertTrustedSender(event, window) + const action = assistantStorageActionSchema.parse(input) + if (action === 'quit') app.quit() + else if (progress.stage === 'failed') retry?.() + }) + const publish = (next: AssistantStorageProgress): void => { + progress = assistantStorageProgressSchema.parse(next) + if (window.isDestroyed()) return + if (!shown) { + shown = true + loadMainWindow(window, true) + } + window.setProgressBar(next.stage === 'failed' ? -1 : + next.total > 0 && next.stage === 'converting' + ? next.processed / next.total : 2) + } + try { + while (!signal.aborted) { + try { + await runStorageWorker(databasePath, signal, publish) + return + } catch (error) { + if (signal.aborted) return + publish({ + ...progress, stage: 'failed', + error: error instanceof Error ? error.message : '历史执行记录优化失败' + }) + await new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + retry = undefined + resolve() + } + retry = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } + } + } finally { + ipcMain.removeHandler(ipcChannels.storageUpgradeProgress) + ipcMain.removeHandler(ipcChannels.storageUpgradeAction) + if (!window.isDestroyed()) window.setProgressBar(-1) + } +} + +function runStorageWorker( + databasePath: string, + signal: AbortSignal, + publish: (progress: AssistantStorageProgress) => void +): Promise { + const cancellation = new SharedArrayBuffer(4) + const cancel = (): void => { + Atomics.store(new Int32Array(cancellation), 0, 1) + } + if (signal.aborted) return Promise.resolve() + signal.addEventListener('abort', cancel, { once: true }) + return new Promise((resolve, reject) => { + const worker = new Worker(join( + dirname(fileURLToPath(import.meta.url)), 'assistant-storage-worker.js' + ), { workerData: { databasePath, cancellation } }) + let failure: Error | undefined + let done = false + worker.on('message', (message: { + progress?: AssistantStorageProgress + error?: string + cancelled?: boolean + done?: boolean + }) => { + if (message.progress) publish(message.progress) + if (message.error && !message.cancelled) failure = new Error(message.error) + if (message.done || message.cancelled) done = true + }) + worker.once('error', () => { + failure = new Error('无法启动历史执行记录优化,请重启 GoodBuddy 后重试。') + }) + worker.once('exit', (code) => { + signal.removeEventListener('abort', cancel) + if (failure) reject(failure) + else if (done && code === 0) resolve() + else reject(new Error('历史执行记录优化意外结束,请重试。')) + }) + }) +} diff --git a/src/main/assistant-storage-worker.ts b/src/main/assistant-storage-worker.ts new file mode 100644 index 00000000..06a0434b --- /dev/null +++ b/src/main/assistant-storage-worker.ts @@ -0,0 +1,23 @@ +import { parentPort, workerData } from 'node:worker_threads' +import { upgradeAssistantStorage } from './assistant/assistant-storage-upgrade' + +const input = workerData as { + databasePath: string + cancellation: SharedArrayBuffer +} +const cancellation = new Int32Array(input.cancellation) +try { + upgradeAssistantStorage( + input.databasePath, + (progress) => parentPort?.postMessage({ progress }), + () => Atomics.load(cancellation, 0) !== 0 + ) + parentPort?.postMessage({ done: true }) +} catch (error) { + parentPort?.postMessage({ + error: error instanceof Error && /full|space/iu.test(error.message) + ? '磁盘空间不足,无法完成空间回收。请释放一些空间后重试。' + : '历史执行记录优化未完成。请重试;若仍失败,请备份数据并联系支持。', + cancelled: error instanceof Error && error.name === 'AbortError' + }) +} diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index d02e136a..957caa31 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -10,7 +10,10 @@ import { conversationSnapshotSchema, isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts' -import { AssistantDatabase } from './assistant-database' +import { + AssistantDatabase, + ASSISTANT_DATABASE_SCHEMA_VERSION +} from './assistant-database' import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' const temporaryDirectories: string[] = [] @@ -641,7 +644,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('migrates existing databases to schema version 33', async () => { + it('migrates existing databases to the current schema version', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) @@ -670,7 +673,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(33) + ).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) expect( current .prepare( @@ -840,7 +843,7 @@ describe('AssistantDatabase', () => { enableForeignKeyConstraints: true }) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 33 + user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected.prepare('SELECT * FROM projects ORDER BY rowid').all() @@ -938,7 +941,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 33 + user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected @@ -1015,7 +1018,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 33 + user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected @@ -1265,7 +1268,7 @@ describe('AssistantDatabase', () => { user_version: number } ).user_version - ).toBe(33) + ).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) expect( current .prepare( @@ -1403,7 +1406,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('PRAGMA user_version').get() - ).toEqual({ user_version: 33 }) + ).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected .prepare( @@ -3102,7 +3105,7 @@ describe('AssistantDatabase', () => { const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 33 + user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected.prepare('PRAGMA table_info(task_events)').all() diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index a49280b6..d648075b 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -95,8 +95,9 @@ import { setMagicNoteChecklistCompletion } from '../magic-notes/rich-content' import { computeNextHeartbeatRun } from './heartbeat-recurrence' +import { SubagentProgressStorage } from './subagent-progress-storage' -export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33 +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34 export type RemoteTaskEventInput = { taskId: string @@ -1750,6 +1751,7 @@ function deleteProjectRecords( export class AssistantDatabase { private database?: DatabaseSync + private subagentProgress?: SubagentProgressStorage private channelEventWrites = 0 private channelOutboxWrites = 0 @@ -1780,6 +1782,7 @@ export class AssistantDatabase { `) this.migrate(database) this.database = database + this.subagentProgress = new SubagentProgressStorage(database) this.channelEventWrites = ( database .prepare('SELECT COUNT(*) AS count FROM channel_events') @@ -2015,6 +2018,7 @@ export class AssistantDatabase { close(): void { this.database?.close() this.database = undefined + this.subagentProgress = undefined } clearAssistantData(): void { @@ -4931,7 +4935,9 @@ export class AssistantDatabase { kind: string, payload: unknown ): void { - this.requireDatabase() + const database = this.requireDatabase() + const payloadJson = this.subagentProgress!.serialize(taskId, kind, payload) + const result = database .prepare( `INSERT INTO task_events (task_id, run_id, kind, payload_json, created_at) @@ -4940,9 +4946,12 @@ export class AssistantDatabase { .run( taskId, kind.slice(0, 64), - JSON.stringify(payload), + payloadJson, new Date().toISOString() ) + this.subagentProgress!.inserted( + taskId, kind, Number(result.lastInsertRowid), payloadJson + ) } appendRemoteTaskEventOnce(input: RemoteTaskEventInput): boolean { @@ -5335,6 +5344,9 @@ export class AssistantDatabase { database: DatabaseSync, event: ValidatedRemoteTaskEvent ): boolean { + const payloadJson = this.subagentProgress!.serialize( + event.taskId, event.kind, JSON.parse(event.payloadJson) + ) const result = database .prepare( `INSERT OR IGNORE INTO task_events @@ -5346,7 +5358,7 @@ export class AssistantDatabase { .run( event.taskId, event.kind, - event.payloadJson, + payloadJson, new Date().toISOString(), event.bindingId, event.operationId, @@ -5354,11 +5366,14 @@ export class AssistantDatabase { event.eventIndex ) if (result.changes === 1) { + this.subagentProgress!.inserted( + event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson + ) return true } const existing = database .prepare( - `SELECT task_id, kind, payload_json + `SELECT id, task_id, kind, payload_json FROM task_events WHERE remote_binding_id = ? AND remote_operation_id = ? @@ -5372,6 +5387,7 @@ export class AssistantDatabase { event.eventIndex ) as | { + id: number task_id: string kind: string payload_json: string @@ -5380,7 +5396,10 @@ export class AssistantDatabase { if ( existing?.task_id === event.taskId && existing.kind === event.kind && - existing.payload_json === event.payloadJson + (existing.payload_json === event.payloadJson || + (event.kind === 'subagent' && this.subagentProgress!.matches( + event.taskId, existing.id, JSON.parse(event.payloadJson) + ))) ) { return false } @@ -9653,6 +9672,15 @@ export class AssistantDatabase { COMMIT; `) } + if (version.user_version < 34) { + database.exec(` + BEGIN IMMEDIATE; + CREATE INDEX IF NOT EXISTS task_events_subagent_idx + ON task_events(task_id, id) WHERE kind = 'subagent'; + PRAGMA user_version = 34; + COMMIT; + `) + } } private requireDatabase(): DatabaseSync { diff --git a/src/main/assistant/assistant-storage-upgrade.test.ts b/src/main/assistant/assistant-storage-upgrade.test.ts new file mode 100644 index 00000000..c8547321 --- /dev/null +++ b/src/main/assistant/assistant-storage-upgrade.test.ts @@ -0,0 +1,267 @@ +// @vitest-environment node +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' +import type { ConversationMessageBlock } from '../../shared/assistant-contracts' +import type { SubagentEvent } from '../../shared/contracts' +import { AssistantDatabase } from './assistant-database' +import { upgradeAssistantStorage } from './assistant-storage-upgrade' +import { + compactSubagentPayload, + restoreSubagentPayload +} from './subagent-progress-storage' + +const directories: string[] = [] +const databases: AssistantDatabase[] = [] +afterEach(async () => { + for (const database of databases.splice(0)) database.close() + await Promise.all(directories.splice(0).map( + (path) => rm(path, { recursive: true, force: true }) + )) +}) + +async function fixture(): Promise<{ + path: string; database: AssistantDatabase; taskId: string +}> { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-storage-upgrade-')) + directories.push(directory) + const path = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(path) + databases.push(database) + database.initialize(directory) + const taskId = randomUUID() + database.createTask({ + id: taskId, title: 'Storage regression', instructions: 'Synthetic fixture', + workMode: 'execute' + }) + return { path, database, taskId } +} + +function subagent(requestId: string): SubagentEvent { + return { + requestId, type: 'subagent', childTaskId: randomUUID(), + expertId: randomUUID(), expertName: 'general', routingMode: 'native', + runtimeCallId: 'test-call', state: 'running', progress: [] + } +} + +describe('subagent progress storage', () => { + it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => { + const event = subagent(randomUUID()) + const textId = randomUUID() + const toolId = randomUUID() + const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' } + const tool: ConversationMessageBlock = { + id: toolId, type: 'tool', tool: { name: 'read', state: 'completed', summary: 'Read', output: 'seed' } + } + const encoder = new Map() + const decoder = new Map() + for (const progress of [ + [], [text], [text, tool], + [{ ...text, content: 'seed tail' }, tool], + [{ ...text, content: 'corrected' }, tool], + [tool, text], [], [text] + ]) { + const full = { ...event, progress } + const compact = compactSubagentPayload(full, encoder) + restoreSubagentPayload(full, encoder) + expect(restoreSubagentPayload(compact, decoder)).toEqual(full) + } + }) + + it('stores small changes without repeating large tool outputs, including across reopen', async () => { + const fixtureData = await fixture() + let database = fixtureData.database + const event = subagent(fixtureData.taskId) + const tool: ConversationMessageBlock = { + id: randomUUID(), type: 'tool', + tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) } + } + const textId = randomUUID() + try { + for (let index = 0; index < 200; index++) { + event.progress = [tool, { id: textId, type: 'text', content: `seed${'中'.repeat(index)}` }] + database.appendTaskEvent(fixtureData.taskId, 'subagent', event) + if (index === 99) { + database.close() + database = new AssistantDatabase(fixtureData.path) + database.initialize(tmpdir()) + } + } + database.close() + const inspection = new DatabaseSync(fixtureData.path) + try { + const rows = inspection.prepare( + `SELECT payload_json FROM task_events WHERE kind = 'subagent' ORDER BY id` + ).all() as Array<{ payload_json: string }> + expect(rows).toHaveLength(200) + expect(rows.reduce((sum, row) => sum + row.payload_json.length, 0)).toBeLessThan(400_000) + const state = new Map() + let restored: unknown + for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state) + expect(restored).toEqual(event) + } finally { inspection.close() } + } finally { database.close() } + }) + + it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => { + const { database, taskId, path } = await fixture() + const event = subagent(taskId) + const textId = randomUUID() + const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' } + const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] } + const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] } + try { + expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true) + expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true) + expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false) + expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts') + } finally { database.close() } + const reopened = new AssistantDatabase(path) + reopened.initialize(tmpdir()) + try { + expect(reopened.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(false) + expect(reopened.getHighestCommittedRemoteTaskEventSequence('binding', 'operation')).toBe('2') + } finally { reopened.close() } + }) + + it('rebuilds progress after a remote batch rolls back', async () => { + const { database, taskId } = await fixture() + const event = subagent(taskId) + const textId = randomUUID() + const base = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' } + const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] } + try { + database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '2', payload: first }) + expect(() => database.appendRemoteTaskEventsBatch([ + { ...base, semanticSequence: '1', payload: { ...first, progress: [] } }, + { ...base, semanticSequence: '2', payload: { ...first, output: 'conflict' } } + ])).toThrow('conflicts') + expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(true) + expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(false) + } finally { database.close() } + }) + + it('keeps full remote conversation progress atomic with compact events', async () => { + const { database, path } = await fixture() + const hostId = randomUUID() + const project = database.createSshProject({ + project: { + name: 'Remote regression', description: '', rootPath: '/tmp/goodbuddy-storage-test', + defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } + }, + executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/tmp/goodbuddy-storage-test' }, + assertCurrent: () => undefined + }) + const conversationId = randomUUID() + const userMessageId = randomUUID() + const assistantMessageId = randomUUID() + database.saveLocalConversations([{ + header: { id: conversationId, projectId: project.id, title: 'Atomic regression', updatedAt: Date.now() }, + messages: [] + }]) + const remoteTaskId = randomUUID() + database.createTask({ + id: remoteTaskId, projectId: project.id, conversationId, + title: 'Atomic remote', instructions: 'Synthetic', workMode: 'execute', + remoteRecovery: { + recoverable: true, currentUserMessageId: userMessageId, + currentAssistantMessageId: assistantMessageId + } + }) + const event = subagent(remoteTaskId) + const textId = randomUUID() + const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] } + const second = { ...event, state: 'completed' as const, output: 'Complete', progress: [ + { id: textId, type: 'text' as const, content: 'seed tail' } + ] } + const owner = { + taskId: remoteTaskId, conversationId, assistantMessageId, + bindingId: 'atomic-binding', operationId: 'atomic-operation', eventIndex: 0 + } + const inspection = new DatabaseSync(path) + try { + database.appendRemoteConversationTaskEventOnce({ ...owner, semanticSequence: '1', event: first }) + inspection.exec(`CREATE TRIGGER reject_message BEFORE UPDATE ON messages + BEGIN SELECT RAISE(ABORT, 'fixture message write failed'); END;`) + expect(() => database.appendRemoteConversationTaskEventOnce({ + ...owner, semanticSequence: '2', event: second + })).toThrow('fixture message write failed') + expect(database.getHighestCommittedRemoteTaskEventSequence('atomic-binding', 'atomic-operation')).toBe('1') + inspection.exec('DROP TRIGGER reject_message') + expect(database.appendRemoteConversationTaskEventOnce({ + ...owner, semanticSequence: '2', event: second + })).toBe(true) + expect(database.appendRemoteConversationTaskEventOnce({ + ...owner, semanticSequence: '2', event: second + })).toBe(false) + const message = database.getConversation(conversationId).messages.find((row) => row.id === assistantMessageId) + expect(message?.subagents?.[0]).toMatchObject({ + state: 'completed', progress: second.progress, output: 'Complete' + }) + expect(inspection.prepare( + `SELECT COUNT(*) AS count FROM task_events WHERE task_id = ? AND kind = 'subagent'` + ).get(remoteTaskId)).toEqual({ count: 2 }) + } finally { inspection.close(); database.close() } + }) + + it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => { + const { database, taskId, path } = await fixture() + database.close() + const event = subagent(taskId) + const tool: ConversationMessageBlock = { + id: randomUUID(), type: 'tool', + tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(300_000) } + } + const textId = randomUUID() + const legacy = new DatabaseSync(path) + const expected: SubagentEvent[] = [] + try { + legacy.exec('PRAGMA user_version = 33; BEGIN') + const insert = legacy.prepare( + `INSERT INTO task_events(task_id, kind, payload_json, created_at) + VALUES (?, 'subagent', ?, ?)` + ) + for (let index = 0; index < 150; index++) { + const full = { + ...event, + progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }] + } + expected.push(full) + insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z') + } + legacy.exec('COMMIT') + } finally { legacy.close() } + const before = (await stat(path)).size + expect(before).toBeGreaterThan(40_000_000) + let cancelled = false + expect(() => upgradeAssistantStorage(path, (progress) => { + if (progress.processed >= 32) cancelled = true + }, () => cancelled)).toThrow('cancelled') + upgradeAssistantStorage(path, () => undefined) + const after = (await stat(path)).size + expect(after).toBeLessThan(before / 20) + const inspected = new DatabaseSync(path) + try { + expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) + const rows = inspected.prepare( + `SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id` + ).all() as Array<{ payload_json: string; created_at: string }> + expect(rows).toHaveLength(expected.length) + const blocks = new Map() + rows.forEach((row, index) => { + expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index]) + expect(row.created_at).toBe('2026-09-13T00:00:00Z') + }) + } finally { inspected.close() } + const reopened = new AssistantDatabase(path) + reopened.initialize(tmpdir()) + reopened.close() + const messages: unknown[] = [] + upgradeAssistantStorage(path, (value) => messages.push(value)) + expect(messages).toEqual([]) + }, 30_000) +}) diff --git a/src/main/assistant/assistant-storage-upgrade.ts b/src/main/assistant/assistant-storage-upgrade.ts new file mode 100644 index 00000000..81df9e32 --- /dev/null +++ b/src/main/assistant/assistant-storage-upgrade.ts @@ -0,0 +1,102 @@ +import { statSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import type { ConversationMessageBlock } from '../../shared/assistant-contracts' +import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts' +import { + compactSubagentPayload, + restoreSubagentPayload +} from './subagent-progress-storage' + +export function upgradeAssistantStorage( + databasePath: string, + onProgress: (progress: AssistantStorageProgress) => void, + isCancelled: () => boolean = () => false +): void { + const database = new DatabaseSync(databasePath, { timeout: 5_000 }) + const checkCancelled = (): void => { + if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError') + } + const bytesBefore = statSync(databasePath).size + try { + const version = database.prepare('PRAGMA user_version').get() as { + user_version: number + } + if (version.user_version < 1 || version.user_version >= 34) return + checkCancelled() + const progress: AssistantStorageProgress = { + stage: 'scanning', processed: 0, total: 0, bytesBefore + } + onProgress({ ...progress }) + database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA busy_timeout = 5000; + PRAGMA secure_delete = OFF; + PRAGMA cache_size = -65536; + CREATE INDEX IF NOT EXISTS task_events_subagent_idx + ON task_events(task_id, id) WHERE kind = 'subagent'; + `) + const tasks = database.prepare( + `SELECT task_id, COUNT(*) AS count FROM task_events + WHERE kind = 'subagent' GROUP BY task_id` + ).all() as Array<{ task_id: string; count: number }> + progress.total = tasks.reduce((sum, task) => sum + task.count, 0) + progress.stage = 'converting' + const select = database.prepare( + `SELECT id, payload_json FROM task_events + WHERE task_id = ? AND kind = 'subagent' AND id > ? + ORDER BY id LIMIT 32` + ) + const update = database.prepare( + 'UPDATE task_events SET payload_json = ? WHERE id = ?' + ) + for (const task of tasks) { + const blocks = new Map() + let afterId = 0 + for (;;) { + checkCancelled() + const rows = select.all(task.task_id, afterId) as Array<{ + id: number; payload_json: string + }> + if (!rows.length) break + database.exec('BEGIN IMMEDIATE') + try { + for (const row of rows) { + const payload: unknown = JSON.parse(row.payload_json) + const compact = compactSubagentPayload(payload, blocks) + // Replay mixed old/new rows on retry. Never delete events or provenance. + restoreSubagentPayload(payload, blocks) + if (compact !== payload) { + update.run(JSON.stringify(compact), row.id) + } + afterId = row.id + } + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + progress.processed += rows.length + onProgress({ ...progress }) + } + database.exec('PRAGMA wal_checkpoint(TRUNCATE)') + } + checkCancelled() + const free = database.prepare('PRAGMA freelist_count').get() as { + freelist_count: number + } + if (free.freelist_count > 0) { + progress.stage = 'compacting' + onProgress({ ...progress }) + // Rebuild the now-small live database, not a second copy of the bloated input. + database.exec('PRAGMA wal_checkpoint(TRUNCATE); VACUUM;') + database.exec('PRAGMA wal_checkpoint(TRUNCATE)') + } + checkCancelled() + onProgress({ + ...progress, stage: 'complete', + bytesAfter: statSync(databasePath).size + }) + } finally { + database.close() + } +} diff --git a/src/main/assistant/subagent-progress-storage.ts b/src/main/assistant/subagent-progress-storage.ts new file mode 100644 index 00000000..7c3d6a27 --- /dev/null +++ b/src/main/assistant/subagent-progress-storage.ts @@ -0,0 +1,207 @@ +import { z } from 'zod' +import { isDeepStrictEqual } from 'node:util' +import type { DatabaseSync } from 'node:sqlite' +import { + assistantIdSchema, + conversationMessageBlockSchema, + type ConversationMessageBlock +} from '../../shared/assistant-contracts' + +export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('append'), + id: assistantIdSchema, + blockType: z.enum(['text', 'reasoning']), + delta: z.string() + }).strict(), + z.object({ + type: z.literal('upsert'), + block: conversationMessageBlockSchema + }).strict(), + z.object({ + type: z.literal('reset'), + blocks: z.array(conversationMessageBlockSchema) + }).strict() +]) + +export type SubagentProgressUpdate = z.infer< + typeof subagentProgressUpdateSchema +> + +type ProgressEvent = { + progress?: ConversationMessageBlock[] + progressUpdates?: SubagentProgressUpdate[] +} + +export function applySubagentProgress( + previous: ConversationMessageBlock[] | undefined, + event: ProgressEvent +): ConversationMessageBlock[] | undefined { + let blocks = event.progress ?? previous + if (!event.progressUpdates?.length) return blocks + blocks = [...(blocks ?? [])] + for (const update of event.progressUpdates) { + if (update.type === 'reset') { + blocks = [...update.blocks] + continue + } + const id = update.type === 'upsert' ? update.block.id : update.id + const index = blocks.findIndex((block) => block.id === id) + const prior = blocks[index] + const block: ConversationMessageBlock = update.type === 'upsert' + ? update.block + : { + id, + type: update.blockType, + content: ( + prior?.type === update.blockType ? prior.content : '' + ) + update.delta + } + if (index < 0) blocks.push(block) + else blocks[index] = block + } + return blocks +} + +export function diffSubagentProgress( + previous: readonly ConversationMessageBlock[], + next: readonly ConversationMessageBlock[] +): SubagentProgressUpdate[] { + if ( + previous.length > next.length || + previous.some((block, index) => block.id !== next[index]?.id) + ) { + return [{ type: 'reset', blocks: [...next] }] + } + const updates: SubagentProgressUpdate[] = [] + for (let index = 0; index < next.length; index += 1) { + const block = next[index]! + const prior = previous[index] + if (block === prior) continue + if ( + (block.type === 'text' || block.type === 'reasoning') && + prior?.type === block.type && + block.content.startsWith(prior.content) + ) { + const delta = block.content.slice(prior.content.length) + if (delta) { + updates.push({ + type: 'append', id: block.id, blockType: block.type, delta + }) + } + } else if (!prior || !isDeepStrictEqual(prior, block)) { + updates.push({ type: 'upsert', block }) + } + } + return updates +} + +type StoredSubagent = ProgressEvent & { + childTaskId: string + [key: string]: unknown +} + +function asSubagent(payload: unknown): StoredSubagent | undefined { + if ( + typeof payload !== 'object' || payload === null || + !('childTaskId' in payload) || typeof payload.childTaskId !== 'string' + ) return undefined + return payload as StoredSubagent +} + +export function compactSubagentPayload( + payload: unknown, + progress: ReadonlyMap +): unknown { + const event = asSubagent(payload) + if (!event || !Array.isArray(event.progress)) return payload + const compact = { ...event } + delete compact.progress + compact.progressUpdates = diffSubagentProgress( + progress.get(event.childTaskId) ?? [], + event.progress + ) + return compact +} + +export function restoreSubagentPayload( + payload: unknown, + progress: Map +): unknown { + const event = asSubagent(payload) + if (!event) return payload + const blocks = applySubagentProgress( + progress.get(event.childTaskId), event + ) + if (blocks) progress.set(event.childTaskId, blocks) + if (!event.progressUpdates) return payload + const restored = { ...event, progress: blocks ?? [] } + delete restored.progressUpdates + return restored +} + +type TaskProgress = { + lastId: number + blocks: Map +} + +/** The conversation keeps the display snapshot; its event history stores changes. */ +export class SubagentProgressStorage { + private readonly tasks = new Map() + + constructor(private readonly database: DatabaseSync) {} + + serialize(taskId: string, kind: string, payload: unknown): string { + return JSON.stringify(kind === 'subagent' + ? compactSubagentPayload(payload, this.current(taskId).blocks) + : payload) + } + + inserted(taskId: string, kind: string, id: number, payloadJson: string): void { + if (kind !== 'subagent') return + const task = this.tasks.get(taskId) + if (!task) return + restoreSubagentPayload(JSON.parse(payloadJson), task.blocks) + task.lastId = id + } + + matches(taskId: string, eventId: number, payload: unknown): boolean { + const blocks = new Map() + const rows = this.database.prepare( + `SELECT id, payload_json FROM task_events + WHERE task_id = ? AND kind = 'subagent' AND id <= ? + ORDER BY id` + ).iterate(taskId, eventId) + for (const row of rows) { + const restored = restoreSubagentPayload( + JSON.parse(row.payload_json as string), blocks + ) + if (row.id === eventId) return isDeepStrictEqual(restored, payload) + } + return false + } + + private current(taskId: string): TaskProgress { + const last = this.database.prepare( + `SELECT id FROM task_events WHERE task_id = ? AND kind = 'subagent' + ORDER BY id DESC LIMIT 1` + ).get(taskId) as { id: number } | undefined + let task = this.tasks.get(taskId) + if (task?.lastId === (last?.id ?? 0)) return task + // A rolled-back transaction or reopened task is rebuilt from committed rows. + task = { lastId: last?.id ?? 0, blocks: new Map() } + const rows = this.database.prepare( + `SELECT payload_json FROM task_events + WHERE task_id = ? AND kind = 'subagent' ORDER BY id` + ).iterate(taskId) + for (const row of rows) { + restoreSubagentPayload(JSON.parse(row.payload_json as string), task.blocks) + } + this.tasks.delete(taskId) + this.tasks.set(taskId, task) + if (this.tasks.size > 16) { + this.tasks.delete(this.tasks.keys().next().value!) + } + return task + } +} diff --git a/src/main/index.ts b/src/main/index.ts index f54a170e..88bcda3f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -37,6 +37,7 @@ import { } from './ipc' import { KnowledgeService } from './knowledge/knowledge-service' import { AssistantDatabase } from './assistant/assistant-database' +import { prepareAssistantStorage } from './assistant-storage-startup' import { createModelGraphExtractor } from './knowledge/model-extractor' import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client' import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key' @@ -512,6 +513,9 @@ function buildTray(): Tray { return nextTray } +const storageUpgradeController = new AbortController() +let storageUpgrade: Promise | undefined + if (hasSingleInstanceLock) { app.on('second-instance', () => { if (mainWindow) { @@ -544,6 +548,13 @@ if (hasSingleInstanceLock) { mainWindow = createMainWindow(() => isQuitting) registerDesktopNotificationActivation(mainWindow) tray = buildTray() + storageUpgrade = prepareAssistantStorage( + mainWindow, + join(app.getPath('userData'), 'assistant.sqlite'), + storageUpgradeController.signal + ) + await storageUpgrade + if (storageUpgradeController.signal.aborted) return const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir() const secureCipher = { isAvailable: () => @@ -1478,6 +1489,7 @@ let cleanupComplete = false app.on('before-quit', (event) => { isQuitting = true + storageUpgradeController.abort() if (cleanupComplete) { return } @@ -1490,6 +1502,7 @@ app.on('before-quit', (event) => { try { const cleanup = settleCleanupPhases([ [ + () => storageUpgrade, () => feedbackService?.dispose(), () => removeFeedbackIpcHandler?.(), () => dshExtensionInstaller?.dispose(), diff --git a/src/main/window.ts b/src/main/window.ts index 6a774852..d51a9d01 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -105,11 +105,19 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow { return window } -export function loadMainWindow(window: BrowserWindow): void { +export function loadMainWindow( + window: BrowserWindow, + storageUpgrade = false +): void { if (process.env.ELECTRON_RENDERER_URL) { - void window.loadURL(process.env.ELECTRON_RENDERER_URL) + const url = new URL(process.env.ELECTRON_RENDERER_URL) + if (storageUpgrade) url.searchParams.set('storageUpgrade', '1') + void window.loadURL(url.href) } else { - void window.loadFile(join(currentDirectory, '../renderer/index.html')) + void window.loadFile( + join(currentDirectory, '../renderer/index.html'), + storageUpgrade ? { query: { storageUpgrade: '1' } } : undefined + ) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 39cd8d9c..03ab50b4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -205,6 +205,12 @@ import type { const desktopApi: DesktopApi = { + storageUpgrade: { + getProgress: () => ipcRenderer.invoke(ipcChannels.storageUpgradeProgress), + act: async (action) => { + await ipcRenderer.invoke(ipcChannels.storageUpgradeAction, action) + } + }, app: { getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise, show: async () => { diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index a611fcc6..1c759acc 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -181,6 +181,10 @@ const terminalSnapshot: TerminalSnapshot = { }; const api: DesktopApi = { + storageUpgrade: { + getProgress: vi.fn(), + act: vi.fn(async () => undefined) + }, app: { getInfo: vi.fn(async () => ({ name: "GoodBuddy", diff --git a/src/renderer/src/StorageUpgrade.css b/src/renderer/src/StorageUpgrade.css new file mode 100644 index 00000000..07006f87 --- /dev/null +++ b/src/renderer/src/StorageUpgrade.css @@ -0,0 +1,18 @@ +.storage-upgrade { + display: flex; + width: 100%; + min-height: 100vh; + background: var(--surface-raised); +} + +.storage-upgrade > .page-shell { + flex: 1; + min-height: 100vh; +} + +.storage-upgrade progress { + display: block; + width: 100%; + height: var(--space-2); + accent-color: var(--accent-solid); +} diff --git a/src/renderer/src/StorageUpgrade.test.tsx b/src/renderer/src/StorageUpgrade.test.tsx new file mode 100644 index 00000000..6a47dc0e --- /dev/null +++ b/src/renderer/src/StorageUpgrade.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { StorageUpgrade } from './StorageUpgrade' +import type { DesktopApi } from '../../shared/contracts' + +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + +it('shows real progress and lets the user quit without deleting records', async () => { + const act = vi.fn(async () => undefined) + vi.stubGlobal('goodbuddy', { storageUpgrade: { + getProgress: vi.fn(async () => ({ + stage: 'converting', processed: 32, total: 100, bytesBefore: 90_000_000_000 + })), + act + } } as unknown as DesktopApi) + render() + await waitFor(() => expect(screen.getByRole('progressbar')).toHaveAttribute('value', '32')) + expect(screen.getByText(/保留聊天、执行详情和结果/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '退出,稍后继续' })) + await waitFor(() => expect(act).toHaveBeenCalledWith('quit')) +}) + +it('keeps a failed upgrade actionable through retry', async () => { + const act = vi.fn(async () => undefined) + vi.stubGlobal('goodbuddy', { storageUpgrade: { + getProgress: vi.fn(async () => ({ + stage: 'failed', processed: 32, total: 100, bytesBefore: 90_000_000_000, + error: '磁盘空间不足' + })), + act + } } as unknown as DesktopApi) + render() + expect(await screen.findByRole('alert')).toHaveTextContent('磁盘空间不足') + fireEvent.click(screen.getByRole('button', { name: '重试' })) + await waitFor(() => expect(act).toHaveBeenCalledWith('retry')) +}) diff --git a/src/renderer/src/StorageUpgrade.tsx b/src/renderer/src/StorageUpgrade.tsx new file mode 100644 index 00000000..7be555cf --- /dev/null +++ b/src/renderer/src/StorageUpgrade.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react' +import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts' +import { PageHeader, PageShell } from './WorkspacePrimitives' +import { useUiLocale } from './i18n/UiLocaleProvider' +import './StorageUpgrade.css' + +export function StorageUpgrade(): React.JSX.Element { + const { resolvedLocale } = useUiLocale() + const en = resolvedLocale === 'en-US' + const [progress, setProgress] = useState() + const [rate, setRate] = useState() + const [actionError, setActionError] = useState('') + useEffect(() => { + let active = true + let timer: ReturnType + let sample: { count: number; time: number } | undefined + const poll = async (): Promise => { + try { + const next = await window.goodbuddy.storageUpgrade.getProgress() + if (active) { + setProgress(next) + if (next.stage === 'converting') { + const now = performance.now() + if (sample && now - sample.time >= 2_000) { + setRate(Math.max(0, (next.processed - sample.count) * 1_000 / (now - sample.time))) + sample = { count: next.processed, time: now } + } else if (!sample) { + sample = { count: next.processed, time: now } + } + } else { + sample = undefined + setRate(undefined) + } + } + } catch { + // The normal application replaces this page when startup continues. + } + if (active) timer = setTimeout(() => void poll(), 500) + } + void poll() + return () => { active = false; clearTimeout(timer) } + }, []) + const title = en ? 'Optimizing saved execution history' : '正在优化历史执行记录' + const stage = progress?.stage + const status = stage === 'failed' + ? progress?.error + : stage === 'compacting' + ? en ? 'Reclaiming disk space…' : '正在回收磁盘空间…' + : stage === 'complete' + ? en ? 'Optimization complete. Starting GoodBuddy…' : '优化完成,正在启动 GoodBuddy…' + : progress?.stage === 'converting' + ? `${en ? 'Processed' : '已处理'} ${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}` + : en ? 'Checking saved execution history…' : '正在检查历史执行记录…' + const act = async (action: 'retry' | 'quit'): Promise => { + try { + setActionError('') + await window.goodbuddy.storageUpgrade.act(action) + } catch { + setActionError(en ? 'The action failed. Please try again.' : '操作未完成,请重试。') + } + } + return ( +
+ + +

{status}

+ {progress?.stage === 'converting' && progress.total > 0 && ( + + )} + {stage === 'converting' && rate !== undefined && ( +

{en ? 'Processing speed' : '处理速度'}:{Math.round(rate).toLocaleString()} {en ? 'events/s' : '条/秒'}

+ )} +

{en + ? 'You can quit and retry on the next launch. Completed batches are preserved.' + : '可以退出并在下次启动时继续处理,已完成的批次会保留。'}

+ {actionError &&

{actionError}

} + {stage === 'failed' && ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index fe222144..58ffc28f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -4,6 +4,7 @@ import '@fontsource-variable/noto-sans-sc/wght.css' import 'katex/dist/katex.min.css' import './markdown-math.css' import App from './App' +import { StorageUpgrade } from './StorageUpgrade' import { installBundledUiFonts } from './fonts' import { changeUiLocale } from './i18n' import { @@ -53,7 +54,9 @@ createRoot(root).render( - + {new URLSearchParams(window.location.search).get('storageUpgrade') === '1' + ? + : } ) diff --git a/src/shared/assistant-storage-contracts.ts b/src/shared/assistant-storage-contracts.ts new file mode 100644 index 00000000..655553ec --- /dev/null +++ b/src/shared/assistant-storage-contracts.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +export const assistantStorageProgressSchema = z.object({ + stage: z.enum(['scanning', 'converting', 'compacting', 'complete', 'failed']), + processed: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + bytesBefore: z.number().nonnegative(), + bytesAfter: z.number().nonnegative().optional(), + error: z.string().optional() +}).strict() + +export type AssistantStorageProgress = z.infer< + typeof assistantStorageProgressSchema +> +export const assistantStorageActionSchema = z.enum(['retry', 'quit']) diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 66dd7d76..e4b8020f 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1747,6 +1747,10 @@ export type KnowledgeSearchReference = { } export type DesktopApi = { + storageUpgrade: { + getProgress: () => Promise + act: (action: 'retry' | 'quit') => Promise + } app: { getInfo: () => Promise show: () => Promise diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index c5949f66..66462809 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -1,4 +1,6 @@ export const ipcChannels = { + storageUpgradeProgress: 'app:storage-upgrade:progress', + storageUpgradeAction: 'app:storage-upgrade:action', externalInstancesList: 'knowledge:external-instances:list', externalInstancesSave: 'knowledge:external-instances:save', externalInstancesTest: 'knowledge:external-instances:test',