fix: stop subagent history from exhausting disk space
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Repeated full subagent progress snapshots could grow the desktop database to many gigabytes during ordinary long-running tasks. Local and managed SSH writes now retain lossless progress changes while preserving conversation snapshots, event identities, remote replay deduplication and transaction rollback. Upgrade existing history in bounded worker-thread batches, show progress and retry controls, and reclaim freed disk space after conversion. Schema 34 cannot be opened by older clients; a pre-upgrade backup is required for client rollback. Document reusable database migration guidance and the verified large-database, portable, local Runtime and Linux Host evidence. Prepare Desktop 0.13.1 with the approved bilingual notes and aligned feature lists. Agent remains 0.11.24. Final candidate validation and production builds will run on main CI, as requested, without additional local release testing. Release note: 修复长任务和子任务造成的数据库异常膨胀;升级时自动整理旧记录并回收空间,保留聊天、执行详情和结果,并支持退出后继续处理。
This commit is contained in:
@@ -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
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
+4
-2
@@ -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、生产构建命令。
|
||||
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
@@ -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,
|
||||
|
||||
@@ -42,6 +42,9 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
|
||||
|
||||
## 托管 Runtime 的并行与输出
|
||||
|
||||
子任务完整过程的显示与数据库差量存储相互独立,本机和托管 SSH 共用桌面存储层;
|
||||
旧库升级与空间回收规则见[执行记录存储与升级回收](./execution-history-storage.md)。
|
||||
|
||||
- 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费
|
||||
原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步
|
||||
计算阻塞同一原生进程中的其他会话。
|
||||
|
||||
@@ -41,6 +41,11 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
|
||||
|
||||
## 组件
|
||||
|
||||
桌面保存托管 SSH 子任务事件时,共用
|
||||
[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。
|
||||
Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号
|
||||
继续保留。该存储修复不要求单独升级 Agent。
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> 窄 Preload / Main IPC
|
||||
|
||||
@@ -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` 同时存在重复保存路径。
|
||||
|
||||
## 生产路径复用“测试”函数
|
||||
|
||||
**症状**:正常用户操作触发了只应在配置校验时出现的错误码,或多发了一次网络请求。
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>((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<void> {
|
||||
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<void>((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('历史执行记录优化意外结束,请重试。'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, ConversationMessageBlock[]>()
|
||||
const decoder = new Map<string, ConversationMessageBlock[]>()
|
||||
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<string, ConversationMessageBlock[]>()
|
||||
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<string, ConversationMessageBlock[]>()
|
||||
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)
|
||||
})
|
||||
@@ -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<string, ConversationMessageBlock[]>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<string, ConversationMessageBlock[]>
|
||||
): 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<string, ConversationMessageBlock[]>
|
||||
): 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<string, ConversationMessageBlock[]>
|
||||
}
|
||||
|
||||
/** The conversation keeps the display snapshot; its event history stores changes. */
|
||||
export class SubagentProgressStorage {
|
||||
private readonly tasks = new Map<string, TaskProgress>()
|
||||
|
||||
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<string, ConversationMessageBlock[]>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<void> | 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(),
|
||||
|
||||
+11
-3
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppInfo>,
|
||||
show: async () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(<StorageUpgrade />)
|
||||
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(<StorageUpgrade />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('磁盘空间不足')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(act).toHaveBeenCalledWith('retry'))
|
||||
})
|
||||
@@ -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<AssistantStorageProgress>()
|
||||
const [rate, setRate] = useState<number>()
|
||||
const [actionError, setActionError] = useState('')
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
let sample: { count: number; time: number } | undefined
|
||||
const poll = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
try {
|
||||
setActionError('')
|
||||
await window.goodbuddy.storageUpgrade.act(action)
|
||||
} catch {
|
||||
setActionError(en ? 'The action failed. Please try again.' : '操作未完成,请重试。')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="storage-upgrade" aria-labelledby="storage-upgrade-title">
|
||||
<PageShell variant="standard">
|
||||
<PageHeader
|
||||
headingId="storage-upgrade-title"
|
||||
title={title}
|
||||
description={en
|
||||
? 'This update removes repeated copies of subagent progress. Your chats, execution details and results are preserved. Time depends on the history size and disk speed.'
|
||||
: '本次更新清理子任务进度的重复副本,保留聊天、执行详情和结果。处理时间取决于历史数据量和磁盘速度。'}
|
||||
/>
|
||||
<p role={stage === 'failed' ? 'alert' : 'status'}>{status}</p>
|
||||
{progress?.stage === 'converting' && progress.total > 0 && (
|
||||
<progress aria-label={title} max={progress.total} value={progress.processed} />
|
||||
)}
|
||||
{stage === 'converting' && rate !== undefined && (
|
||||
<p>{en ? 'Processing speed' : '处理速度'}:{Math.round(rate).toLocaleString()} {en ? 'events/s' : '条/秒'}</p>
|
||||
)}
|
||||
<p>{en
|
||||
? 'You can quit and retry on the next launch. Completed batches are preserved.'
|
||||
: '可以退出并在下次启动时继续处理,已完成的批次会保留。'}</p>
|
||||
{actionError && <p role="alert">{actionError}</p>}
|
||||
{stage === 'failed' && (
|
||||
<button className="primary-button" onClick={() => void act('retry')}>
|
||||
{en ? 'Retry' : '重试'}
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary-button" onClick={() => void act('quit')}>
|
||||
{en ? 'Quit and continue later' : '退出,稍后继续'}
|
||||
</button>
|
||||
</PageShell>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<UiLocaleProvider
|
||||
initialPreference={initialUiLocalePreference}
|
||||
>
|
||||
<App />
|
||||
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
|
||||
? <StorageUpgrade />
|
||||
: <App />}
|
||||
</UiLocaleProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -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'])
|
||||
@@ -1747,6 +1747,10 @@ export type KnowledgeSearchReference = {
|
||||
}
|
||||
|
||||
export type DesktopApi = {
|
||||
storageUpgrade: {
|
||||
getProgress: () => Promise<import('./assistant-storage-contracts').AssistantStorageProgress>
|
||||
act: (action: 'retry' | 'quit') => Promise<void>
|
||||
}
|
||||
app: {
|
||||
getInfo: () => Promise<AppInfo>
|
||||
show: () => Promise<void>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user