Compare commits

...
2 Commits
Author SHA1 Message Date
mesalogo 49377206cc 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
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: 修复长任务和子任务造成的数据库异常膨胀;升级时自动整理旧记录并回收空间,保留聊天、执行详情和结果,并支持退出后继续处理。
2026-09-13 20:50:33 +08:00
mesalogo 1c22e1a06d fix: keep browser overlays and knowledge controls usable
Native browser pages could cover application dialogs and overlapping menus
or notifications. Suspend only the viewport while covered and restore the
same active page afterward, including nested dialogs and terminal prompts.

Keep knowledge source controls at their full height, constrain long library
cards and titles, and update automatic names when switching remote targets
without overwriting edited or saved names. Restore packaged release-note
loading now that the history exceeds its previous size limit.

Add focused regressions and real Electron UI evidence. Clean up temporary
Harness fixture directories at test completion instead of accumulating them.

Release note: 修复内嵌浏览器遮挡弹窗和浮层、知识库来源选项被压缩及长名称显示异常;切换远端知识库时自动名称正确更新,并恢复更新说明显示。
2026-09-13 19:46:37 +08:00
51 changed files with 1615 additions and 49 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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、生产构建命令。
+6
View File
@@ -210,6 +210,8 @@
- 选项数量为 2 至 4 个。
- 使用带 `aria-pressed` 的按钮组语义,支持方向键切换。
- 不能用于多选筛选、页面导航或执行即时命令。
- 与 `PageTabs` 一样,共享控件根节点必须 `flex: 0 0 auto`,不能被同级长表单或滚动区域
压缩高度。横向空间不足时保留控件自身滚动,不把标签裁成一条窄缝。
- 控件宽度由内容决定,除移动窄屏外不默认等分整行。
### 6.3 筛选工具栏
@@ -395,6 +397,10 @@
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见
[浏览器多实例技术设计](./docs/features/assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport)。
- 遮罩必须 `inset: 0` 铺满整窗,并由 `styles.css` 中统一的遮罩选择器声明
`-webkit-app-region: no-drag`,否则 `.brand` 与 `.topbar` 的原生拖动区域会截获指针,
使遮罩上的控件无法悬停和点击。新增整窗遮罩时扩展该共享选择器,不要在各自规则里复制
+67
View File
@@ -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
@@ -77,8 +77,16 @@ Record<ConversationId, Record<BrowserTabId, BrowserLiveState>>
非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏
收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。
普通导航、快照与页面操作只同步轻量导航状态,不自动截图、等待画面或传输 JPEG;只有显式
`browser_screenshot` 调用生成图片。侧栏内终端关闭确认显示期间,同样隐藏原生浏览器视图,
确认或取消后恢复当前实例,避免原生子视图遮挡 HTML 确认按钮。
`browser_screenshot` 调用生成图片。
`BrowserViewport` 统一观察 body Portal 的插入、移除和可见属性变化,以及浮层尺寸、
窗口尺寸、滚动和过渡结束。`browser-viewport-occlusion.ts` 按共享语义识别
`aria-modal="true"`、dialog、alertdialog、menu 和应用通知:可见应用级 Modal 始终释放
viewport,其余浮层只在与浏览器区域相交时释放。隐藏节点和隐藏祖先不阻塞浏览器。
最后一个遮挡消失后,只有当前活动实例恢复同一 viewport 租约;网页、历史和页面状态不变。
终端关闭确认复用此机制,不再维护单独的隐藏条件。页面无需逐个登记弹窗,也不能靠提高
CSS `z-index` 覆盖原生子视图。此逻辑仅涉及 Desktop Renderer 的显示租约,不改变
Runtime、MCP 或远端 Agent 的请求、导航与生命周期。
浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。
切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B
@@ -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,71 @@
# 工作栏实现与验证进度
## 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,
现在所有应用 Modal 与相交浮层共用
[浏览器遮挡机制](./browser-tabs-technical-design.md#4-renderer-与-viewport)。
新增五个组件回归在修复前全部失败,修复后通过;独立检测器另有十项回归。
- Windows 当前 Main、Preload、Renderer,通过 `electron-vite dev` 和独立 profile
启动完整 App,不是旧安装包或仅有 Renderer 的 fixture。浏览器导航到本机合成页后,
实例管理与项目设置均验证原生视图 `visible → hidden → visible`;视图 ID 不变且未销毁。
本次不修改 Runtime、Agent、MCP 或远程生产路径。
- 实例编辑器在浅深主题、目标 1280×800、960×720、720×640、640×420 下完成八组
几何、关闭按钮命中和原生视图隐藏断言。Windows 缩放产生不足 1 CSS px 的取整差异,
记录实际尺寸而非把目标尺寸当测量值。PNG 实际审阅包括浅色 1280、720 和深色 640;
其余尺寸执行程序化检查,不将其描述为全部 PNG 人工审阅。
- 临时驱动曾出现新浏览器 target 抢占自动化目标、隐藏窗口截图阻塞及严格像素比较
误判;修正驱动后重新取证,不把这些失败改写为产品修复。最终使用 pinned renderer、
独立测试窗口和 CDP PNG;原生视图状态单独从 Main 读取,避免网页截图漏掉原生视图。
- 顺带复现已发布更新说明资源为 136,554 字节,超过原 128 KiB 读取上限。上限提高到
1 MiB,仍使用有界读取;回归直接读取实际打包资源并确认当前版本可展示、可确认。
完整 App 已显示并关闭 0.13.0 更新说明,重启后不再次显示。
- 首轮 `npm test -- --maxWorkers=1`:4,108 通过、66 跳过,608.30 秒;
`npm run typecheck`、`npm run lint` 通过。补长名称修正后的全量执行为 4,105 通过、
66 跳过、3 失败,586.51 秒;失败全部位于并行修改中的子代理进度测试。
未修改这些文件,随后定向重跑 ACP 与 OpenCode 子代理共 66 项全部通过。
全仓类型与 lint 检查期间先后遇到并行子代理/存储升级改动的缺失字段和未使用导入,
这些改动不纳入本次提交,不能把早先的通过结果当作当前并行工作区全绿。
- 两组 Harness 单测补 `onTestFinished` 回收临时目录。最后定向四文件 73 项通过,
运行前后检查新增残留目录为 0。测试清理不改变 deployed Runtime。
- 本次修改文档的 66 个相对链接目标存在,暂存差异无空白错误;最终发布仍需对确定的、
不再变动的候选提交执行发布验证,不能将未提交的并行改动混入候选。
- 临时脚本、截图和隔离构建按用户要求在提交后清理;这里只保留脱敏结论。知识库布局、
合成服务保存验证与调用计数见[知识库进度](../knowledge-base/progress.md#2026-09-13-知识库布局与自动名称修复)。
## 2026-09-13 OpenCode 事件流关闭顺序修复
候选检查中的 lifecycle 失败已稳定复现,4 路并行后 `openEventBodies` 为 3 而不是 0。
@@ -42,6 +42,9 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
## 托管 Runtime 的并行与输出
子任务完整过程的显示与数据库差量存储相互独立,本机和托管 SSH 共用桌面存储层;
旧库升级与空间回收规则见[执行记录存储与升级回收](./execution-history-storage.md)。
- 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费
原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步
计算阻塞同一原生进程中的其他会话。
@@ -68,6 +68,10 @@ Renderer 丢弃已失效的目录、详情和测试响应,不把切换前的
## 5. 创建与编辑
新建绑定时,用户尚未编辑的显示名称跟随当前目录选择更新;更换实例或手工输入另一个
目标 ID 时清除旧自动名称。用户手动填写过名称后保留该草稿。编辑或重新绑定已有库时,
始终保留已保存的本地名称,除非用户主动修改;此修正不自动重命名已有知识库。
流程为选择 `local | dify | fastgpt | ragflow`,外部类型再选择实例、目录项或手工 ID、
显示名称、配置与测试查询。表单测试成功后允许保存;服务保存前再次执行同一查询验证,
检查实例、绑定和唯一性是否在等待期间改变,再写入数据库。
@@ -31,6 +31,12 @@
| 检索结果 | 外部组件显示总耗时、返回数量、片段、定位、实际 ID 和原始评分,包括 FastGPT 评分数组;不显示本地全文、向量、图谱通道或本地上下文操作 |
| 聊天与引用 | 统一选择器显示 Provider、实例与状态;外部引用打开保存片段,显示实例 ID 和远端定位,不请求本地上下文或打开原始来源 |
创建类型分段控件复用全局不收缩规则,不能被目录和参数表单压扁;列表长名称的宽度约束见
[知识库 UI 设计](./ui-design.md#2-信息架构)。新建时自动名称跟随目录选择,手动改名和已保存
名称不被覆盖,具体规则见[逻辑设计](./external-knowledge-logic-design.md#5-创建与编辑)。
实例 Modal 和其他浮层统一参与[原生浏览器遮挡处理](../assistant-workbar/browser-tabs-technical-design.md#4-renderer-与-viewport),
不再仅验证没有浏览器的独立 Portal。
原 `knowledge-field` 的控件样式依赖知识库工作区祖先,实例 Modal 经 `createPortal` 挂到
body 后丢失该作用域。当前 `Field` 使用全局 `.field` 和标签 span,密码也复用同一字段
结构,确保 Portal 内的字体、输入边框、背景和焦点样式与应用一致。弹窗宽度为
+20
View File
@@ -16,6 +16,26 @@
## 验证证据
### 2026-09-13 知识库布局与自动名称修复
- 创建类型的共享分段控件被长表单压缩,修复共享不收缩规则,不添加页面专属高度。
长名称卡片增加零最小宽度的 Grid 轨道,名称单行省略、图标保持 15px;详情名称允许换行。
合成长名称实测列表横向溢出为 0;详情修复前溢出 658px,修复后为 0。
- 连续选择两个目录项时,未修改过的自动名称原先停留在第一个目标。回归先失败再修复,
现在跟随选择,手动名称和已保存名称不自动覆盖。规则见
[创建与编辑](./external-knowledge-logic-design.md#5-创建与编辑)。
- 当前完整 App 的真实 UI → Preload → Main → SQLite 路径完成实例保存、两个目录项
切换、零结果检索后添加绑定、详情展示和配置草稿保留。上游是 loopback Dify 合成服务,
不是真实知识平台;没有调用模型,也不能据此声称真实检索质量或远端验收。
- 本轮合成服务共收到 8 次请求:1 次初始目录、2 次初始详情、2 次检索(测试与保存)、
2 次后续详情、1 次后续目录;真实模型和真实知识服务请求均为 0。
- 创建类型在浅深主题、目标 1280×800、960×720、720×640、640×420 的八组尺寸中
检查控件高度至少 34px,并保存 PNG。实际审阅浅色 1280、深色 640 及最终添加后的
长名称详情 PNG,确认 Tab 不再成为窄缝,卡片边框和图标完整,详情无横向滚动条。
- 同期修复和通用验证结果见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-原生浏览器与应用浮层修复)。
临时脚本与合成 profile 在提交后清理,不把它们作为长期依赖;用户在另一测试窗口
手工创建的实例与绑定不属于本轮合成数据,不随清理删除。
### 2026-09-13 检索路径与绑定查询简化
对 `3b560c9..ca4a58c` 的复查发现三处与本功能相关的实现问题,已按 KISS 与性能优先修正:
@@ -25,6 +25,11 @@
标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。
“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。
本地与外部库共用的列表以 `minmax(0, 1fr)` 限制条目宽度,再对名称单行省略;选中卡片
的边框必须完整留在列表内,Provider/实例等辅助文字允许换行。不得用列表外层裁切
掩盖被长名称撑宽的卡片,库图标不能随名称一起收缩。外部详情的长名称和远端名称允许
换行,不能撑出横向滚动条。创建类型分段控件与详情页签保持固定内容高度,不随长表单收缩。
## 3. 首次使用
没有知识库时,页面空状态显示“新建知识库”主操作。创建时先选择类型;本地类型默认
@@ -41,6 +41,11 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
## 组件
桌面保存托管 SSH 子任务事件时,共用
[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。
Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号
继续保留。该存储修复不要求单独升级 Agent。
```text
Renderer
-> 窄 Preload / Main IPC
+49
View File
@@ -26,6 +26,37 @@
**历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖
的遮罩;后续复查改为共享规则加自动发现测试。
## 网页浮层遗漏原生浏览器视图
**症状**:知识库实例、项目设置等 Modal 的右侧被浏览器网页覆盖,通知或菜单也可能
被压在原生视图下面。
**原因**:`WebContentsView` 不参与 Renderer 的 CSS 层叠。先前只在关闭终端确认时
显式隐藏 viewport,没有覆盖其他页面的 Portal 和浮层。
**规则**:`BrowserViewport` 共用 `browser-viewport-occlusion.ts`,按 Modal 语义和
浮层相交状态释放显示租约,不销毁网页会话。不得再增加逐个弹窗的特殊隐藏条件。
**防复发**:组件回归覆盖 body Modal、嵌套 Modal、已打开 Modal、隐藏或非相交浮层、
通知、终端确认和非活动页签;实际 Electron 验证同时检查原生视图可见性,不能只看 DOM
的遮罩或截图。
**历史**:先前浏览器多实例修正只覆盖了终端关闭确认;2026-09-13 用户在知识库实例
Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
## Flex 滚动内容压缩页签和分段控件
**症状**:知识库来源选择被长参数表单压成窄缝,标签和按钮文字被裁掉。
**规则**:`PageTabs` 与 `SegmentedControl` 在共享样式上固定 `flex: 0 0 auto`;
直接包装层也必须保留高度。不要按页面复制高度常量。
**防复发**:`WorkspacePrimitives.test.tsx` 检查共享分段控件不收缩,已有平台功能、
能力和 MCP 页签回归继续保留;Electron 复验必须包含长表单与短窗口。
**历史**:设置页签曾补过包装层不收缩规则;2026-09-13 知识库创建类型的共享
`SegmentedControl` 又出现同类问题。
## 逐行查询代替主键查询
**症状**:列表渲染或校验函数随数据量线性变慢,且慢在单次操作内重复发生。
@@ -40,6 +71,24 @@
**历史**:外部知识库绑定同时出现三处:`requireLibrary`(30+ 调用点,一处在循环内)、
`getKnowledgeSnapshot`(每个知识库两次全表扫描)、`listInstances`(每个实例过滤一次)。
## 将累计过程当作增量事件反复落库
**症状**:聊天正文很小,执行记录数据库却增长到几十 GiB;只增加几个字节的子任务输出,
又写入接近 1 MiB 的旧过程副本。
**原因**:本机与远程 OpenCode 共用完整进度快照,两个桌面落库入口都按流式频率新增
完整 JSON;正文流的缓冲器没有覆盖这种累计快照。
**规则**:界面快照不直接作为追加式日志的存储表示。子任务事件通过共享存储层记录变化,
保留逐条还原和远程来源去重;会话仍保存完整显示结果。旧用户数据通过同一编码器无损
转换后回收空间,不靠删聊天或单独修本机数据库解决。
**防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、
消息事务回滚及旧库中断续跑。真实旧库需验证逐条等价和物理文件缩小。
**历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB;
本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。
## 生产路径复用“测试”函数
**症状**:正常用户操作触发了只应在配置校验时出现的错误码,或多发了一次网络请求。
+1
View File
@@ -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'
),
+2 -2
View File
@@ -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
View File
@@ -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",
+34
View File
@@ -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",
@@ -1,9 +1,9 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import {
DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
DEEPSEEK_HARNESS_CONTROL_VERSION,
@@ -33,6 +33,7 @@ async function fixture() {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-launcher-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const workspace = join(root, 'workspace')
const dshHome = join(root, 'home')
const hostPath = join(
+117
View File
@@ -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('历史执行记录优化意外结束,请重试。'))
})
})
}
+23
View File
@@ -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'
})
}
+12 -9
View File
@@ -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()
+34 -6
View File
@@ -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
}
}
+6 -2
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, onTestFinished } from 'vitest'
import {
createBoundedNdJsonStream,
installHarnessDiagnosticGuard,
startControlledDeepSeekHarnessHost
} from './deepseek-harness-host'
import { vi } from 'vitest'
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import type {
Agent,
CreateAgentOptions
@@ -65,6 +65,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-host-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const inbound = new TransformStream<
Record<string, unknown>,
Record<string, unknown>
@@ -125,6 +126,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-alias-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const alias = join(root, '..', basename(root))
const inbound = new TransformStream<
Record<string, unknown>,
@@ -157,6 +159,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-extensions-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const brokenEntrypoint = join(root, 'broken.mjs')
await writeFile(
brokenEntrypoint,
@@ -202,6 +205,7 @@ describe('controlled DeepSeek Harness host', () => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-skill-'))
)
onTestFinished(() => rm(root, { recursive: true, force: true }))
const skillDirectory = join(root, 'web-3d-game')
await mkdir(skillDirectory)
await writeFile(
+13
View File
@@ -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(),
+18 -2
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -70,6 +70,22 @@ afterEach(async () => {
})
describe('ReleaseNotesService', () => {
it('reads and acknowledges the actual packaged release history', async () => {
const { version } = JSON.parse(
await readFile(join(process.cwd(), 'package.json'), 'utf8')
) as { version: string }
const { filePath, service } = await createService(version)
await writeFile(
filePath,
await readFile(join(process.cwd(), 'resources', 'release-notes.json'))
)
await expect(service.getPending()).resolves.toMatchObject({
currentVersion: version,
releases: [{ version }]
})
await service.acknowledge({ version })
await expect(service.getPending()).resolves.toMatchObject({ releases: [] })
})
it('shows only the current release on a fresh installation', async () => {
const { service } = await createService('0.8.18')
@@ -135,7 +151,7 @@ describe('ReleaseNotesService', () => {
it('rejects an oversized release-notes resource with a bounded read', async () => {
const { filePath, service } = await createService('0.8.18')
await writeFile(filePath, ' '.repeat(128 * 1024 + 1), 'utf8')
await writeFile(filePath, ' '.repeat(1024 * 1024 + 1), 'utf8')
await expect(service.getPending()).rejects.toThrow(
'Release notes exceed the size limit'
+1 -1
View File
@@ -8,7 +8,7 @@ import {
import type { ApplicationSettingsStore } from './application-settings-store'
import { compareStrictSemVer } from './version-checker'
const maximumReleaseNotesBytes = 128 * 1024
const maximumReleaseNotesBytes = 1024 * 1024
export class ReleaseNotesService {
private releases?: ReleaseNote[]
+11 -3
View File
@@ -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
)
}
}
+6
View File
@@ -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 () => {
+4
View File
@@ -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",
@@ -32,6 +32,21 @@ function selectInstance(index = 0): void {
}
describe('external knowledge workflows', () => {
it('updates an automatic name when selecting another library but preserves an edited name', async () => {
bridge.externalCatalogList.mockResolvedValueOnce({
items: [{ id: 'remote-1', name: 'Handbook' }, { id: 'remote-2', name: 'Second library' }],
hasMore: false
})
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
selectInstance()
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
expect(screen.getByLabelText('Name')).toHaveValue('Handbook')
fireEvent.click(screen.getByRole('button', { name: /Second library/ }))
expect(screen.getByLabelText('Name')).toHaveValue('Second library')
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'My chosen name' } })
fireEvent.click(screen.getByRole('button', { name: /Handbook/ }))
expect(screen.getByLabelText('Name')).toHaveValue('My chosen name')
})
it('navigates the rich instance menu, skips disabled items and restores focus', async () => {
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'disabled', name: 'Disabled service', enabled: false }, { ...instance, id: 'other', name: 'Other service' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
const trigger = screen.getByRole('button', { name: 'External instance' })
@@ -97,6 +112,7 @@ describe('external knowledge workflows', () => {
it('keeps external tabs and filters from shrinking in the scrolling workspace', () => {
const css = readFileSync('src/renderer/src/external-knowledge.css', 'utf8')
expect(css).toMatch(/\.external-knowledge > \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.external-knowledge > header\s*\{[^}]*overflow-wrap: anywhere/)
expect(css).toMatch(/\.external-knowledge__filters\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.external-knowledge-modal__surface\s*\{[^}]*width: min\(560px, 100%\)/)
expect(css).toMatch(/\.external-knowledge__footer\s*\{[^}]*position: sticky/)
+5 -4
View File
@@ -187,6 +187,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
const [instanceId, setInstanceId] = useState(binding?.instanceId ?? '')
const [remoteId, setRemoteId] = useState(binding?.remoteKnowledgeBaseId ?? '')
const [name, setName] = useState(library?.name ?? '')
const [nameEdited, setNameEdited] = useState(Boolean(library))
const [detail, setDetail] = useState<ExternalKnowledgeCatalogItem>()
const [common, setCommon] = useState(binding?.commonConfig ?? commonDefaults)
const [config, setConfig] = useState(binding?.providerConfig ?? externalProviderDefaults(provider))
@@ -265,10 +266,10 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
<div className="external-knowledge__columns">
<section className="external-knowledge__fields">
<h3>{t('external.source')}</h3>
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); if (!nameEdited) setName(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
<button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button>
<SegmentedControl ariaLabel={t('external.remote')} value={manual ? 'manual' : 'catalog'} options={[{ value: 'catalog', label: t('external.browse') }, { value: 'manual', label: t('external.manual') }]} onChange={value => setManual(value === 'manual')} />
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value) }} /></Field> : <>
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value); if (!nameEdited) setName('') }} /></Field> : <>
<Field label={t('external.search')}><input type="search" maxLength={512} value={search} onChange={event => { setSearch(event.currentTarget.value); setPage(1) }} /></Field>
<div className="external-knowledge__actions"><button type="button" className="secondary-button" aria-label={t('external.refresh')} title={t('external.refresh')} onClick={() => { invalidate(); setRefresh(value => value + 1) }}><RefreshCw size={15} /></button>
{parentId && <button type="button" className="secondary-button" onClick={() => { setParentId(null); setPage(1) }}>{t('external.root')}</button>}</div>
@@ -280,7 +281,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
const bound = libraries.some(candidate => candidate.id !== library?.id && candidate.external?.instanceId === instanceId && candidate.external.remoteKnowledgeBaseId === item.id)
return <button type="button" className="external-knowledge__target" key={item.id} aria-pressed={item.kind === 'folder' ? undefined : remoteId === item.id} disabled={bound || busy} onClick={() => {
if (item.kind === 'folder') { setParentId(item.id); setPage(1); return }
invalidate(); setRemoteId(item.id); setDetail(item); if (!name) setName(item.name)
invalidate(); setRemoteId(item.id); setDetail(item); if (!nameEdited) setName(item.name)
}}>{item.kind === 'folder' ? <FolderOpen size={15} /> : <Database size={15} />}<span><strong>{item.name}</strong><small>{item.id}{bound ? ` · ${t('external.alreadyBound')}` : ''}</small></span></button>
})}
<div className="external-knowledge__actions"><button type="button" className="secondary-button" disabled={page === 1} onClick={() => setPage(value => value - 1)}>{t('external.previous')}</button><span>{page}</span><button type="button" className="secondary-button" disabled={!catalog.hasMore} onClick={() => setPage(value => value + 1)}>{t('external.next')}</button></div>
@@ -288,7 +289,7 @@ export function ExternalBindingForm({ provider, instances, libraries, library, o
</>}
{remoteId && <p className="external-knowledge__identifier">{remoteName}<br />{remoteId}</p>}
{duplicate && <p role="alert" id={`${errorId}-duplicate`}>{t('external.alreadyBound')}</p>}
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => setName(event.currentTarget.value)} /></Field>
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => { setName(event.currentTarget.value); setNameEdited(true) }} /></Field>
<Field label={t('external.query')}><textarea required maxLength={4000} value={query} onChange={event => { invalidate(); setQuery(event.currentTarget.value) }} /></Field>
</section>
<ExternalConfig common={common} config={config} detail={detail} onCommon={value => { invalidate(); setCommon(value) }} onConfig={value => { invalidate(); setConfig(value) }} />
@@ -1472,6 +1472,99 @@ describe('RightAssistantSidebar resizing', () => {
expect(browserA).toHaveAttribute('aria-selected', 'true')
})
it('releases the native browser for portalled modals until the last modal closes', async () => {
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
width: 400, x: 900, y: 100, toJSON: () => ({})
})
const modal = document.createElement('section')
modal.setAttribute('role', 'dialog')
modal.setAttribute('aria-modal', 'true')
const nested = modal.cloneNode() as HTMLElement
try {
renderSidebar({ tab: 'browser', activeConversationId: 'modal-browser' })
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'modal-browser', bounds: expect.any(Object) })
))
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
document.body.append(modal)
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
document.body.append(nested)
modal.remove()
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease })
nested.remove()
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'modal-browser', leaseToken: lease, bounds: expect.any(Object) })
))
expect(browserApi.closeTab).not.toHaveBeenCalled()
expect(browserApi.createTab).toHaveBeenCalledTimes(1)
} finally {
modal.remove()
nested.remove()
bounds.mockRestore()
}
})
it.each(['menu', 'dialog', 'notification'])('hides the native view only while an overlapping %s is visible', async (kind) => {
const popup = document.createElement('section')
if (kind === 'notification') popup.className = 'app-notification'
else popup.setAttribute('role', kind)
popup.hidden = true
let overlapping = true
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const left = this === popup && !overlapping ? 100 : 900
return {
bottom: 500, height: 400, left, right: left + 400, top: 100,
width: 400, x: left, y: 100, toJSON: () => ({})
}
})
try {
document.body.append(popup)
renderSidebar({ tab: 'browser', activeConversationId: 'popup-browser' })
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
))
const lease = browserApi.setViewport.mock.calls.at(-1)![0].leaseToken
popup.hidden = false
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
overlapping = false
fireEvent(window, new Event('resize'))
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
))
overlapping = true
fireEvent(window, new Event('resize'))
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith({ leaseToken: lease }))
popup.hidden = true
await waitFor(() => expect(browserApi.setViewport).toHaveBeenLastCalledWith(
expect.objectContaining({ conversationId: 'popup-browser', bounds: expect.any(Object) })
))
} finally {
popup.remove()
bounds.mockRestore()
}
})
it('does not acquire a native view behind an already open modal or restore an inactive tab', async () => {
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
width: 400, x: 900, y: 100, toJSON: () => ({})
})
const modal = document.createElement('section')
modal.setAttribute('aria-modal', 'true')
try {
document.body.append(modal)
renderSidebar({ tab: 'browser', activeConversationId: 'covered-browser' })
await waitFor(() => expect(browserApi.setViewport).toHaveBeenCalled())
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
fireEvent.click(screen.getByRole('tab', { name: '工作区' }))
modal.remove()
await act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
expect(browserApi.setViewport.mock.calls.every(([request]) => !('bounds' in request))).toBe(true)
} finally {
modal.remove()
bounds.mockRestore()
}
})
it('releases the native browser viewport while confirming another terminal close', async () => {
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
bottom: 500, height: 400, left: 900, right: 1300, top: 100,
+35 -9
View File
@@ -43,6 +43,10 @@ import type {
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { SegmentedControl } from './WorkspacePrimitives'
import {
browserOverlaySelector,
isBrowserViewportOccluded
} from './browser-viewport-occlusion'
import {
findTaskSchedule,
TaskScheduleActions
@@ -504,10 +508,11 @@ function BrowserViewport({
let animationFrame: number | undefined
let lastRequest = ''
let resizeObserver: ResizeObserver | undefined
const observedOverlays = new Set<Element>()
const panel = host.closest<HTMLElement>('[role="tabpanel"]')
const syncViewport = (): void => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
return
}
animationFrame = requestAnimationFrame(() => {
animationFrame = undefined
@@ -517,7 +522,8 @@ function BrowserViewport({
Boolean(conversationId) &&
panel?.hidden !== true &&
rect.width >= 1 &&
rect.height >= 1
rect.height >= 1 &&
!isBrowserViewportOccluded(host, rect)
const bounds = isVisible
? {
x: Math.max(0, Math.round(rect.left)),
@@ -545,6 +551,7 @@ function BrowserViewport({
if (panel?.hidden !== false) {
resizeObserver?.disconnect()
resizeObserver = undefined
observedOverlays.clear()
syncViewport()
return
}
@@ -552,17 +559,34 @@ function BrowserViewport({
resizeObserver = new ResizeObserver(syncViewport)
resizeObserver.observe(host)
}
if (resizeObserver) {
const overlays = new Set(document.querySelectorAll(browserOverlaySelector))
for (const overlay of observedOverlays) {
if (!overlays.has(overlay)) {
resizeObserver.unobserve(overlay)
observedOverlays.delete(overlay)
}
}
for (const overlay of overlays) {
if (!observedOverlays.has(overlay)) {
resizeObserver.observe(overlay)
observedOverlays.add(overlay)
}
}
}
syncViewport()
}
const mutationObserver = new MutationObserver(updateObservation)
if (panel) {
mutationObserver.observe(panel, {
attributes: true,
attributeFilter: ['hidden']
})
}
mutationObserver.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['hidden', 'aria-modal', 'role', 'class', 'style', 'open']
})
window.addEventListener('resize', syncViewport)
window.addEventListener('scroll', syncViewport, true)
document.addEventListener('transitionend', syncViewport, true)
document.addEventListener('animationend', syncViewport, true)
updateObservation()
return () => {
if (animationFrame !== undefined) {
@@ -572,6 +596,8 @@ function BrowserViewport({
mutationObserver.disconnect()
window.removeEventListener('resize', syncViewport)
window.removeEventListener('scroll', syncViewport, true)
document.removeEventListener('transitionend', syncViewport, true)
document.removeEventListener('animationend', syncViewport, true)
void browserApi.setViewport({ leaseToken }).catch(() => undefined)
}
}, [conversationId, tabId, visible])
@@ -2397,7 +2423,7 @@ export function RightAssistantSidebar({
: undefined
}
tabId={browserTabIds[instance.id]}
visible={open && instance.id === activeWorkbarInstanceId && !terminalCloseConfirmation}
visible={open && instance.id === activeWorkbarInstanceId}
/>
</section>
)}
+18
View File
@@ -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);
}
+36
View File
@@ -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'))
})
+94
View File
@@ -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>
)
}
@@ -111,6 +111,27 @@ describe('WorkspacePrimitives', () => {
expect(platformFeaturesTabs).toBeDefined()
expect(platformFeaturesTabs).toMatch(/flex:\s*0 0 auto;/u)
})
it('keeps shared segmented controls from collapsing beside scrollable content', () => {
const segmentedStyles = stylesheet.match(
/\.segmented-control,\s*\.page-tabs--segmented\s*\{(?<rules>[^}]*)\}/u
)?.groups?.rules
expect(segmentedStyles).toBeDefined()
expect(segmentedStyles).toMatch(/flex:\s*0 0 auto;/u)
})
it('bounds knowledge library cards before truncating long names', () => {
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-list\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/u
)
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-button\s*\{[^}]*min-width:\s*0;/u
)
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-meta\s*\{[^}]*overflow-wrap:\s*anywhere;/u
)
expect(stylesheet).toMatch(
/\.knowledge-workspace__library-identity > svg\s*\{[^}]*flex:\s*0 0 auto;/u
)
})
it('keeps Capabilities and tools tabs visible in scrollable Settings', () => {
const capabilitiesTabs = stylesheet.match(
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
afterEach(() => {
document.body.replaceChildren()
vi.restoreAllMocks()
})
function setup(attributes: Record<string, string>, overlaps = true) {
const host = document.createElement('div')
const parent = document.createElement('div')
const overlay = document.createElement('section')
for (const [name, value] of Object.entries(attributes)) {
overlay.setAttribute(name, value)
}
parent.append(overlay)
document.body.append(host, parent)
const viewport = new DOMRect(600, 100, 400, 500)
vi.spyOn(overlay, 'getBoundingClientRect').mockReturnValue(
new DOMRect(overlaps ? 700 : 10, 200, 200, 100)
)
return { host, parent, overlay, viewport }
}
describe('native browser overlay detection', () => {
it('blocks an application modal even when its surface does not overlap the page', () => {
const { host, viewport } = setup({ 'aria-modal': 'true' }, false)
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
})
it.each(['dialog', 'alertdialog', 'menu'])('detects overlapping %s without requiring modal focus setup', role => {
const { host, viewport } = setup({ role })
expect(isBrowserViewportOccluded(host, viewport)).toBe(true)
})
it.each(['hidden', 'display', 'visibility', 'ancestor-display'])('ignores a modal hidden by %s', kind => {
const { host, parent, overlay, viewport } = setup({ 'aria-modal': 'true' })
if (kind === 'hidden') parent.hidden = true
if (kind === 'display') overlay.style.display = 'none'
if (kind === 'visibility') overlay.style.visibility = 'hidden'
if (kind === 'ancestor-display') parent.style.display = 'none'
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
})
it('does not treat inline statuses and error messages as overlays', () => {
const { host, viewport } = setup({ role: 'alert' })
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
})
it('does not let a surrounding non-modal container hide its own browser', () => {
const { host, overlay, viewport } = setup({ role: 'dialog' })
overlay.append(host)
expect(isBrowserViewportOccluded(host, viewport)).toBe(false)
})
})
@@ -0,0 +1,35 @@
export const browserOverlaySelector = [
'[aria-modal="true"]',
'[role="dialog"]',
'[role="alertdialog"]',
'[role="menu"]',
'.app-notification'
].join(', ')
export function isBrowserViewportOccluded(
host: HTMLElement,
viewport: DOMRect
): boolean {
// WebContentsView is above the renderer, regardless of CSS z-index.
// Release its viewport, not its session, while renderer overlays need it.
return Array.from(
host.ownerDocument.querySelectorAll<HTMLElement>(browserOverlaySelector)
).some((overlay) => {
if (overlay.contains(host) || overlay.closest('[hidden]')) return false
const rect = overlay.getBoundingClientRect()
if (rect.width < 1 || rect.height < 1) return false
const style = getComputedStyle(overlay)
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
return false
}
for (let element: HTMLElement | null = overlay; element; element = element.parentElement) {
if (getComputedStyle(element).display === 'none') return false
}
return overlay.getAttribute('aria-modal') === 'true' || (
rect.left < viewport.right &&
rect.right > viewport.left &&
rect.top < viewport.bottom &&
rect.bottom > viewport.top
)
})
}
+1
View File
@@ -41,6 +41,7 @@
.external-knowledge dt { color: var(--text-secondary); }
.external-knowledge dd { margin: 0; overflow-wrap: anywhere; }
.external-knowledge > header h2 { margin: 0 0 var(--space-2); font-size: var(--font-section-title); }
.external-knowledge > header { overflow-wrap: anywhere; }
.external-knowledge > .page-tabs { flex: 0 0 auto; }
.external-knowledge__fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }
.external-knowledge__filters { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); flex: 0 0 auto; }
+4 -1
View File
@@ -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>
)
+8
View File
@@ -12731,6 +12731,7 @@ details.settings-section > :not(summary) + :not(summary) {
.segmented-control,
.page-tabs--segmented {
display: inline-flex;
flex: 0 0 auto;
max-width: 100%;
padding: 3px;
border: 1px solid var(--border-control);
@@ -13125,6 +13126,7 @@ details.settings-section > :not(summary) + :not(summary) {
.knowledge-workspace__library-list {
display: grid;
grid-template-columns: minmax(0, 1fr);
padding: 0;
margin: 0;
gap: 7px;
@@ -13133,6 +13135,7 @@ details.settings-section > :not(summary) + :not(summary) {
.knowledge-workspace__library-button {
width: 100%;
min-width: 0;
padding: 11px;
border: 1px solid transparent;
border-radius: var(--radius-control);
@@ -13155,13 +13158,18 @@ details.settings-section > :not(summary) + :not(summary) {
}
.knowledge-workspace__library-identity strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.knowledge-workspace__library-identity > svg {
flex: 0 0 auto;
}
.knowledge-workspace__library-meta {
display: block;
overflow-wrap: anywhere;
margin-top: 5px;
color: var(--text-muted);
font-size: var(--font-body);
+15
View File
@@ -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'])
+4
View File
@@ -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>
+2
View File
@@ -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',