release: prepare Desktop 0.13.10

This commit is contained in:
mesalogo
2026-09-21 22:53:44 +08:00
parent 6c4c054180
commit a0b35d1192
24 changed files with 677 additions and 30 deletions
+1 -1
View File
@@ -294,7 +294,7 @@ Repository-wide release invariants:
## Validation
Run all validators after source changes:
Run all validators after source changes when function/fix done:
```text
npm test
+44
View File
@@ -0,0 +1,44 @@
# Runtime Storage And Background Reads
SQLite owns persisted task, event and conversation evidence. The desktop business
connection is long-lived. WAL permits readers alongside a writer; synchronous SQL
still blocks the thread that executes it. Memory caches are disposable derived data.
Startup storage upgrades run in `assistant-storage-worker` before the business
connection opens. Runtime readers must not invoke initialization, migrations or
recovery. `SubagentProgressStorage` caches event encoding state, not task statistics.
## Runtime Read Design
Execution statistics use one lazily started worker with a read-only connection.
The worker performs the existing evidence query inside a short read transaction.
Main supplies the current reply lease IDs and receives only scalar summaries.
There is no worker pool or duplicate persisted summary. The worker is terminated
when its owning database closes; errors reject reads, without a synchronous Main
fallback. In-memory test databases use the synchronous query because independent
connections cannot access the same private in-memory database.
Derived caches must specify scope, capacity, lifetime and invalidation. Statistics
keep at most eight scopes for 30 seconds. SQLite `data_version` detects other
connections' commits; `total_changes()` detects writes on the owning connection.
Reads inside an existing transaction clear and bypass the cache, so rolled-back
values cannot escape. Closing the connection clears its cache. Live leases bypass
the cache because elapsed time changes without a database write.
Read transactions end before replies are posted. Long-lived read transactions
would prevent WAL checkpoints from reclaiming old pages. Each reader has its own
SQLite page cache; adding workers increases memory usage and requires evidence of
parallel query demand. New consumers should reuse the same lifecycle and read-only
rules, without introducing a general query protocol before a second use case exists.
## Renderer Scheduling
Hidden task panels use React `Activity` to preserve state while scheduling hidden
render work below visible updates. Panel factories run inside that boundary.
Terminal and browser panels retain their existing mounted effects and sessions.
Statistics refresh only when the task center and document are visible. Results
must remain scoped and late responses from a previous selection must be discarded.
The [task statistics design](../features/task-and-job/technical-design.md) owns the
query contract and evidence rules. Tests and timings demonstrate only their fixture
conditions; they do not establish a latency guarantee for arbitrary retained history.
@@ -11,6 +11,7 @@
| [逻辑设计](./logic-design.md) | 整体架构图、图谱对象关系图、状态规则与需求映射 |
| [UI 设计](./ui-design.md) | 应用导航、工作回顾、侧栏反馈及页面状态 |
| [User Stories](./user-stories.md) | 监督者与故事线图谱的用户操作和验收场景 |
| [技术设计](./technical-design.md) | Main/Renderer 分层、数据模型、IPC、安全边界和实施顺序 |
| [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 |
统一应用的技术设计与实施进度尚未制定;逻辑待定项见[逻辑设计](./logic-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。
统一应用尚未接入产品,实施状态仍以代码和后续进度文档为准。逻辑待定项见[逻辑设计](./logic-design.md),实施边界与阶段见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。
@@ -0,0 +1,215 @@
# 监督者技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 目标技术设计,尚未实现 |
| 日期 | 2026-09-20 |
| 产品设计 | [监督者应用产品设计](./supervisor-prd.md) |
| 行为规则 | [监督者逻辑设计](./logic-design.md) |
| 界面设计 | [监督者 UI 设计](./ui-design.md) |
| 实施进度 | 尚未建立,首个实现阶段完成后补充 |
本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。
## 1. 总体方案
监督者作为 Main 进程中的一个领域服务,复用现有智能心跳的计划、运行领取、租约、重试和有界模型调用。图谱是监督者整理结果的查询视图,知识库继续拥有资料正文、索引和检索。
```mermaid
flowchart LR
UI["Renderer\n监督者页面 / 图谱 / 侧栏"]
IPC["Preload + IPC\n类型化输入输出"]
SVC["SupervisorService\n范围、运行、结果、用户确认"]
HB["HeartbeatService\n计划、领取、租约、重试"]
EVIDENCE["EvidenceCollector\n按范围读取有界证据"]
MODEL["BoundedSummarizer\n只读 JSON 输出"]
STORE["AssistantDatabase\nSQLite 事务与查询"]
KB["KnowledgeService\n本地资料、引用、检索"]
CONV["会话 / 笔记 / 任务"]
UI --> IPC --> SVC
HB --> SVC
SVC --> EVIDENCE
EVIDENCE --> CONV
EVIDENCE --> KB
SVC --> MODEL
SVC --> STORE
SVC --> UI
SVC -->|确认后补充本地条目| KB
```
职责边界如下:
| 模块 | 负责 | 不负责 |
| --- | --- | --- |
| `HeartbeatService` | 计划、到期运行、租约、重试、已有报告兼容 | 图谱实体合并、用户确认、知识库写入编排 |
| `SupervisorService` | 触发统一、证据收集、整理结果、图谱查询和修订 | 自己实现调度器、复制来源正文 |
| `EvidenceCollector` | 按冻结范围读取会话、笔记、任务、知识库引用 | 越过范围读取全部数据、调用工具 |
| `SupervisorSummarizer` | 以有界输入调用文本模型并解析结构化输出 | 执行工具、直接修改知识库 |
| `AssistantDatabase` | 事务、外键、分页、状态和来源定位持久化 | 生成模型内容 |
| `KnowledgeService` | 资料和本地知识条目的维护、检索及引用 | 管理监督者事件和关系 |
| Renderer | 展示和发起用户操作 | 访问 SQLite、决定权限或拼接 SQL |
## 2. 运行路径
手动回顾和心跳回顾必须经过同一个 `SupervisorService.run()`,只在触发来源上区分。这样可以保证两者使用相同的范围校验、输入预算、输出契约、结果状态和来源处理。
```mermaid
sequenceDiagram
participant T as 手动/智能心跳
participant S as SupervisorService
participant D as AssistantDatabase
participant C as EvidenceCollector
participant M as 文本模型
participant R as Renderer
T->>S: run(scope, timeRange, trigger)
S->>D: 创建 run,冻结范围与时间
S->>C: collect(frozenScope)
C-->>S: 有界证据 + source references
S->>M: 结构化整理请求,禁止工具
M-->>S: validated result
S->>D: 事务写入 result、events、entities、relations、references
D-->>S: completed / no-change / partial
S-->>R: 状态和定位信息
```
运行状态与内容确认状态分开保存:
- 运行状态:`pending`、`claimed`、`completed`、`no_change`、`partial`、`failed`、`cancelled`。
- 内容状态:来源事实、自动归纳、待核对、用户确认、用户修订、已移除关系。
- 运行成功只表示模型结果已按契约保存,不表示所有实体和关系已经被用户确认。
- 失败或取消保留上次成功结果;部分结果必须带覆盖范围和遗漏原因。
## 3. 数据模型
首版使用现有 `AssistantDatabase` 和同一 SQLite 数据库。新增表保持领域对象独立,但不复制会话、笔记或知识库正文。
```mermaid
erDiagram
supervision_runs ||--o{ supervision_results : produces
supervision_results ||--o{ story_events : contains
story_lines ||--o{ story_events : organizes
story_lines }o--o{ knowledge_entities : references
story_events }o--o{ knowledge_entities : changes
knowledge_entities ||--o{ knowledge_relations : source
knowledge_entities ||--o{ knowledge_relations : target
supervision_results ||--o{ source_references : cites
story_events ||--o{ source_references : cites
knowledge_entities ||--o{ source_references : cites
knowledge_relations ||--o{ source_references : cites
```
建议的最小持久化对象:
| 对象 | 关键字段 | 说明 |
| --- | --- | --- |
| `supervision_runs` | `id`, `trigger`, `scope_json`, `time_range_json`, `status`, `error`, `started_at`, `completed_at` | 一次手动或心跳整理;保存冻结范围,不从当前配置回推历史 |
| `supervision_results` | `id`, `run_id`, `summary`, `change_digest`, `coverage_json` | 工作回顾、监督反馈和图谱共同读取的结果版本 |
| `story_lines` | `id`, `scope_json`, `title`, `created_at`, `updated_at` | 用户选择的工作故事线,不等同于 Project |
| `story_events` | `id`, `story_line_id`, `result_id`, `occurred_at`, `title`, `description`, `event_type`, `confidence` | 时间轴上的事件;同日聚合只在查询或 UI 层处理 |
| `knowledge_entities` | `id`, `canonical_label`, `description`, `state`, `confirmation_state`, `updated_at` | 可被多条故事线引用的持续认识,不与知识库文档一一对应 |
| `entity_changes` | `event_id`, `entity_id`, `change_type`, `description`, `confirmation_state` | 表达提出、补充、验证和修订,避免覆盖实体正文 |
| `knowledge_relations` | `id`, `from_entity_id`, `to_entity_id`, `relation_type`, `reason`, `confirmation_state` | 关系独立拥有状态和来源;移除关系不删除来源 |
| `source_references` | `id`, `owner_type`, `owner_id`, `source_type`, `source_id`, `locator_json`, `availability` | 只保存来源标识和有界定位,不复制正文 |
外键和索引要求:
- 所有监督者对象使用 UUID;实体、来源和关系通过稳定 ID 关联,不能只按名称合并。
- `supervision_runs` 按 `created_at`、`scope` 查询;事件按 `story_line_id, occurred_at` 查询。
- 关系对 `(from_entity_id, to_entity_id, relation_type)` 建唯一约束,允许同一实体对存在不同关系类型。
- 删除故事线只删除其组织关系,不删除共享实体、来源或知识库资料;删除知识库资料后来源标为不可用。
- 整理结果、实体变化、关系变化和来源引用在同一 SQLite 事务中提交,提交失败全部回滚。
- 早期不建立完整内容快照或独立向量索引;历史能力只依赖已保存的整理结果和原来源可用性。
模型输出使用版本化共享契约,至少包含 `events`、`entities`、`entityChanges`、`relations`、`summary`、`openItems` 和每项的 `sourceReferenceIds`。Main 必须做数量、字符、枚举、ID 所属范围和引用存在性校验,未知字段拒绝或按契约版本处理,不能直接把模型 JSON 写入数据库。
## 4. Main、IPC 与 Renderer
新增能力沿用现有边界:Renderer 只传业务输入,Main 重新校验项目、故事线、时间范围、来源和权限。
建议的 IPC 分组:
| 通道 | 用途 |
| --- | --- |
| `supervision:overview` | 工作回顾、运行状态、未解决事项和最近结果 |
| `supervision:run` | 手动开始一次整理,返回运行 ID |
| `supervision:cancel` | 取消当前运行 |
| `supervision:graph` | 按故事线、时间区间和回放时刻分页读取图谱 |
| `supervision:object` | 读取事件、实体、关系及来源详情 |
| `supervision:confirm` | 确认、修订或移除自动关系和实体变化 |
| `supervision:continue-context` | 生成继续讨论前的上下文预览 |
| `supervision:knowledge-preview` | 预览新建或补充本地知识条目 |
| `supervision:knowledge-commit` | 用户确认后调用知识库服务写入 |
IPC 返回图谱查询结果时使用分页和有界字段;详情中的来源正文复用现有会话、笔记和知识库读取路径。不要把完整图谱一次性发送给 Renderer,也不要把模型原始输出暴露为可执行内容。
侧栏反馈通过已有应用状态刷新机制读取结果。首版可以在手动运行完成后主动刷新,心跳完成后发布一个不含私人正文的变更通知,Renderer 再按权限读取摘要;不新增系统通知正文通道。
## 5. 安全与一致性
- 监督者模型调用固定为 `ask` 语义,输入来自 `EvidenceCollector` 的有界快照,工具授权始终拒绝。
- `Global`、Project、多项目和固定 Conversation/Task/Experiment 目标在 Main 冻结;模型不能生成或改变范围。
- 每个来源引用必须属于本次冻结范围,跨范围引用、伪造项目 ID 和不存在的来源 ID 直接使结果失败。
- 心跳运行沿用现有 `heartbeat_runs` 的领取、租约、重试和取消机制;监督者运行 ID 与心跳运行 ID 建立一对一关联,不复制另一套调度状态。
- 同一配置同时触发手动和定时运行时,沿用现有幂等键和运行领取规则;已完成结果不能重复写入。
- 用户确认或修订后的实体、关系不被后续整理静默覆盖;新证据以补充或冲突待核对状态写入。
- 外部知识库只读。图谱可以引用保存到本地的外部片段和定位信息,不调用远端写入、不承诺打开远端原文。
- 来源正文仍由原应用拥有。监督者删除关系、故事线或运行记录时不得删除会话、笔记、任务或知识库资料。
## 6. 实施顺序
### 阶段 A:先把现有心跳变成统一运行入口
- 抽出 `HeartbeatSummarizer` 的有界输入和结构化输出校验,使 `SupervisorService` 可以复用。
- 增加手动运行的统一服务入口,但先只生成现有回顾结果,不改变心跳页面行为。
- 为运行状态、范围冻结、失败保留旧结果补充 Main 单元测试。
### 阶段 B:接入证据引用和阶段回顾
- 实现 `EvidenceCollector`,先接入会话、任务和已有心跳输入;再接入魔法笔记与本地知识库引用。
- 建立 `supervision_runs`、`supervision_results` 和 `source_references`,让回顾结果可定位来源。
- 在现有智能心跳页面增加监督者工作回顾入口;不先改应用名称和导航 ID。
### 阶段 C:实现最小故事线图谱
- 建立事件、实体、实体变化和关系表及查询服务。
- 先完成平铺环绕时间轴、节点详情、来源展开、回放和列表模式;圆形视图继续作为后续布局。
- 同一实体通过稳定 ID 连接多个事件,禁止按每次事件复制实体。
- 用真实整理结果替换 Demo 的模拟数据,并保留 Demo 作为视觉回归 fixture。
### 阶段 D:接入用户确认和知识库双向定位
- 增加关系确认、修订、撤销和实体拆分的最小交互。
- 从知识库资料定位到图谱,从图谱返回本地资料及引用位置。
- 新建或补充本地条目必须经过预览和确认;外部知识库保持只读。
### 阶段 E:侧栏反馈与增量整理
- 将结果摘要投影到跟随/固定目标侧栏。
- 心跳只负责触发,监督者根据新证据决定有无变化和是否反馈。
- 在运行去重、用户修订保护和来源失效提示稳定后,再评估事件唤醒与自动阶段识别。
## 7. 验证策略
首版每层验证一条可追踪链路:
1. `EvidenceCollector` 测试范围裁剪、字符预算、来源定位和不可用来源。
2. `SupervisorService` 测试模型输出解析、引用完整性、重复运行、取消、失败和部分结果。
3. `AssistantDatabase` 测试迁移、事务回滚、外键、分页、关系移除不删来源。
4. IPC 测试不可信输入、越权 Project/Source ID、确认状态和知识库写入前置预览。
5. Renderer 测试从反馈进入图谱、点击实体高亮多个时间点、回放不显示未来结果,以及窄屏列表访问。
6. 使用一组固定脱敏 fixture 验证“监督反馈 → 实体 → 多个事件 → 来源 → 继续讨论”的完整路径。
首个生产验收不要求模型自动发现所有关系。必须先证明来源可追溯、失败可解释、用户修订不会丢失,以及图谱与工作回顾读取同一结果。
## 8. 暂不实现的内容
- 独立的图数据库、向量图谱或新的知识库正文存储。
- 让监督者拥有通用 Agent 工具执行能力。
- 自动把所有聊天消息变成事件或节点。
- 无依据的实体自动合并、旧观点自动失效和静默覆盖用户确认。
- 应用市场建设作为监督者开发前置依赖。
- 在没有增量和去重证据前实现事件触发、自动阶段识别和全量后台重算。
+1 -1
View File
@@ -2,7 +2,7 @@
Smart Heartbeat 已提供按计划或手动执行的有界回顾,生成报告及后续建议。条件或事件唤醒仍需后续设计。
2026-09-16 确定的目标定位:现有入口计划升级为“监督者”,智能心跳作为其定期唤醒机制,驱动检查与整理;应用内部提供工作回顾和故事线图谱。定义见[监督者应用产品设计](../conversation-supervision/supervisor-prd.md)。本轮仅更新文档,现有计划、范围与执行能力未改变,不能将 Agent 定位解释为已有自主工具执行能力。
2026-09-16 确定的目标定位:现有入口计划升级为“监督者”,智能心跳作为其定期唤醒机制,驱动检查与整理;应用内部提供工作回顾和故事线图谱。定义见[监督者应用产品设计](../conversation-supervision/supervisor-prd.md),实施方案见[监督者技术设计](../conversation-supervision/technical-design.md)。本轮仅更新文档,现有计划、范围与执行能力未改变,不能将 Agent 定位解释为已有自主工具执行能力。
## 文档导航
+2
View File
@@ -11,6 +11,8 @@
5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。
6. [技术设计](./technical-design.md):统计查询契约、请求证据与不完整数据边界,以及立即创建的事务和入队规则。
统计读取与共享存储、worker 生命周期和 Renderer 调度的通用约束见[运行时存储架构](../../architecture/runtime-storage.md)。
正式功能范围固定为当前项目:顶部显示当前会话消息总数和 Agent 累计回复时间,下方列出本项目全部会话的任务。创建默认立即执行并关联当前会话,可选择本项目其他会话、新会话或定时周期;产品与验收规则见 Task Center PRD 和 Scheduled Task PRD。
## 阅读顺序
+24 -4
View File
@@ -1,5 +1,25 @@
# Execution Statistics Query
## Runtime Read Path
WAL、连接所有权、内存缓存和后台读取的通用规则见
[运行时存储架构](../../architecture/runtime-storage.md)。
`tasks:execution-stats` 在 Main 校验来源、范围和当前回复租约,然后经
`AssistantDatabase.getExecutionStatsAsync` 调用独立的 `execution-stats-worker`。
worker 使用只读连接和短读事务执行下文查询,不运行数据库初始化或任务恢复。
查询错误返回调用方,关闭数据库时终止 worker 并拒绝未完成请求。
worker 内按项目或会话范围缓存结果,最多 8 项、30 秒;每次读取先检查 SQLite 连接版本。
活动回复绕过缓存,事务内读取清空并绕过缓存,关闭连接清空缓存。写入提交后的下一次读取
会重新计算,覆盖本地消息、远程事件、任务删除和恢复。统计数据仍以查询快照时刻为准。
Renderer 保留可见时 5 秒刷新以及 revision、focus、visibility 触发刷新;失败的范围显示
不可用,旧范围的迟到结果丢弃。重复缓存结果保留对象引用,更新通过 `startTransition`
提交,避免无变化的轮询反复更新 App 的任务时长映射。任务面板使用 React `Activity`
保留状态并降低隐藏时的渲染优先级;面板工厂在该边界内部调用。浏览器和终端沿用原有挂载
与 effect 生命周期。
## Contract
`window.goodbuddy.tasks.getExecutionStats(input)` invokes `tasks:execution-stats`.
@@ -61,10 +81,10 @@ Message counts used to detect missing history stay in Main; no bodies are return
`completed_at` as an execution endpoint. Missing or invalid timing also marks
the request incomplete; unknown time is not replaced with zero-length certainty.
- A non-aborted reply lease confirms current execution: an open local interval
with a reliable start contributes through `asOf` and is not marked incomplete
just because it is active. Manual context compression leases are excluded.
with a reliable start contributes through `asOf` and remains complete while active.
Manual context compression leases are excluded.
Without a live lease, stop at the latest evidence and mark an open interval
incomplete. Renderer must requery rather than extrapolate beyond `asOf`.
incomplete. Renderer must query again after `asOf`; it does not extrapolate.
- Remote-recoverable records, tasks in persisted SSH project execution spaces,
and events with remote operation provenance are
incomplete and contribute no duration: their receipt timestamps cannot establish
@@ -100,7 +120,7 @@ terminal events. Only status payloads use JSON extraction. Request membership an
remote provenance inspect scalar event columns; last-event and pre-interruption
timestamps use `(task_id, id)` index seeks. This preserves a tool/delta event as
the last reliable boundary without loading its body. Grouped assistant-message
counts still read task metadata from message rows rather than from every event.
counts read task metadata from message rows; event rows are not scanned for that count.
It adds no tables, columns, migration, or persisted timing totals. Stable task
lifecycle timestamps are never duration endpoints.
+1
View File
@@ -113,6 +113,7 @@ export default defineConfig({
input: {
index: resolve('src/main/index.ts'),
'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'),
'execution-stats-worker': resolve('src/main/execution-stats-worker.ts'),
'wechat-sidecar': resolve(
'src/main/channels/wechat-sidecar.ts'
),
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.13.9",
"version": "0.13.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.13.9",
"version": "0.13.10",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.13.9",
"version": "0.13.10",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+36
View File
@@ -1,6 +1,42 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.13.10",
"releasedAt": "2026-09-21",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**任务统计使用后台只读读取**:执行统计通过独立只读 worker 查询 WAL 数据,避免较重的统计 SQL 占用 Electron 主进程;统计缓存有明确的容量、有效期和失效边界。",
"**任务中心切换更顺畅**:隐藏任务面板保留运行状态并降低后台渲染优先级,切换任务中心、工作区、浏览器和成果页时减少重复渲染。"
],
"fixes": [
"**修复 Agent 回复期间任务中心阶段性卡顿**:减少任务事件写入、统计刷新和 tab 切换渲染叠加造成的 UI 阻塞。",
"**完善运行时存储边界**:明确 WAL、主数据库连接、启动迁移 worker、统计 worker 和 Renderer 的职责,避免把启动迁移缓存误当作运行时统计缓存。",
"**删除对话确认更简洁**:移除重复的底部警告文案,保留明确的取消和永久删除操作。"
],
"notices": [
"本次仅更新 Desktop,Agent 保持 **0.13.2**,无需更新 Host 环境。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Run execution statistics through a background read-only path**: Execution statistics now query WAL data through a dedicated read-only worker, keeping heavier aggregation SQL off the Electron Main thread with explicit cache capacity, lifetime, and invalidation rules.",
"**Smoother Task Center switching**: Hidden task panels preserve their running state while receiving lower-priority background rendering, reducing repeated work when switching between Task Center, Workspace, Browser, and Results."
],
"fixes": [
"**Reduce intermittent Task Center stalls during Agent replies**: Reduced UI contention caused by task-event persistence, statistics refreshes, and tab-switch rendering occurring at the same time.",
"**Clarify runtime storage boundaries**: Documented the separate responsibilities of WAL, the business database connection, the startup migration worker, the statistics worker, and Renderer scheduling.",
"**Simplify conversation deletion confirmation**: Removed the duplicate bottom warning text while keeping clear Cancel and Permanently Delete actions."
],
"notices": [
"This release updates Desktop only. Agent remains at **0.13.2**; no Host update is required."
]
}
}
},
{
"version": "0.13.9",
"releasedAt": "2026-09-21",
+83 -1
View File
@@ -3,6 +3,7 @@ import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
import { DatabaseSync } from 'node:sqlite'
import { ExecutionStatsReader } from './execution-stats-reader'
import { statSync } from 'node:fs'
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
@@ -1786,13 +1787,20 @@ function deleteProjectRecords(
}
export class AssistantDatabase {
private static readonly executionStatsCacheTtlMs = 30_000
private static readonly executionStatsCacheLimit = 8
private database?: DatabaseSync
private executionStatsReader?: ExecutionStatsReader
private readonly noteStorage: MagicNoteStorage
private readonly dirtyMagicNotes = new Set<string>()
private readonly pendingMagicNoteCleanup = new Set<string | undefined>()
private subagentProgress?: SubagentProgressStorage
private channelEventWrites = 0
private channelOutboxWrites = 0
private readonly executionStatsCache = new Map<
string,
{ value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number }
>()
constructor(
private readonly databasePath: string,
@@ -2196,6 +2204,9 @@ export class AssistantDatabase {
}
close(): void {
this.executionStatsReader?.close()
this.executionStatsReader = undefined
this.executionStatsCache.clear()
this.database?.close()
this.database = undefined
this.subagentProgress = undefined
@@ -4520,11 +4531,82 @@ export class AssistantDatabase {
activeRequestIds: ReadonlySet<string> = new Set()
): ExecutionStats {
const scope = executionStatsInputSchema.parse(input)
const cacheKey = 'conversationId' in scope
? `conversation:${scope.conversationId}`
: `project:${scope.projectId}`
const database = this.requireDatabase()
// Never retain values read from a transaction that its caller can roll back.
if (database.isTransaction || activeRequestIds.size > 0) {
this.executionStatsCache.clear()
return this.readExecutionStatsSnapshot(scope, activeRequestIds)
}
const dataVersion = Number((database.prepare('PRAGMA data_version').get() as { data_version: number }).data_version)
const totalChanges = Number((database.prepare('SELECT total_changes() AS total_changes').get() as { total_changes: number }).total_changes)
const cached = activeRequestIds.size === 0
? this.executionStatsCache.get(cacheKey)
: undefined
if (cached && cached.dataVersion === dataVersion && cached.totalChanges === totalChanges &&
Date.now() - cached.cachedAt < AssistantDatabase.executionStatsCacheTtlMs) {
this.executionStatsCache.delete(cacheKey)
this.executionStatsCache.set(cacheKey, cached)
return structuredClone(cached.value)
}
const result = this.readExecutionStatsSnapshot(scope, activeRequestIds)
this.executionStatsCache.delete(cacheKey)
this.executionStatsCache.set(cacheKey, {
value: structuredClone(result), cachedAt: Date.now(), dataVersion, totalChanges
})
while (this.executionStatsCache.size > AssistantDatabase.executionStatsCacheLimit) {
this.executionStatsCache.delete(this.executionStatsCache.keys().next().value!)
}
return result
}
getExecutionStatsAsync(
input: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string>,
workerPath: string
): Promise<ExecutionStats> {
this.requireDatabase()
const scope = executionStatsInputSchema.parse(input)
if (this.databasePath === ':memory:') {
return Promise.resolve(this.getExecutionStats(scope, activeRequestIds))
}
this.executionStatsReader ??= new ExecutionStatsReader(this.databasePath, workerPath)
return this.executionStatsReader.read(scope, activeRequestIds)
}
openReadOnly(): void {
if (this.database) throw new Error('Database already open')
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
}
private readExecutionStatsSnapshot(
scope: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string>
): ExecutionStats {
const database = this.requireDatabase()
if (database.isTransaction) return this.computeExecutionStats(scope, activeRequestIds)
database.exec('BEGIN')
try {
const result = this.computeExecutionStats(scope, activeRequestIds)
database.exec('COMMIT')
return result
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
private computeExecutionStats(
scope: ExecutionStatsInput,
activeRequestIds: ReadonlySet<string>
): ExecutionStats {
const database = this.requireDatabase()
const result: ExecutionStats = {
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
}
const database = this.requireDatabase()
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
@@ -67,6 +67,58 @@ describe('execution duration evidence', () => {
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
})
it('invalidates cached scopes for local messages, remote events and external deletion', () => {
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
database.saveLocalConversations([{
header: { id: conversationId, projectId, title: 'History', updatedAt: epoch },
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained', state: 'complete', createdAt: epoch }]
}])
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
const id = request()
database.getExecutionStats({ projectId })
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: id, type: 'done' } })
expect(database.getExecutionStats({ projectId }).incompleteRequestCount).toBe(1)
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
try {
raw.prepare('DELETE FROM messages WHERE conversation_id = ?').run(conversationId)
raw.prepare('DELETE FROM tasks WHERE id = ?').run(id)
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
} finally { raw.close() }
})
it('does not cache transaction snapshots across rollback, or serve a closed connection', () => {
const connection = (database as unknown as { database: DatabaseSync }).database
const id = request()
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
connection.exec('BEGIN')
text(id)
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
connection.exec('ROLLBACK')
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
database.close()
expect(() => database.getExecutionStats({ conversationId })).toThrow()
database.initialize(directory)
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
})
it('bounds cache scopes, expires idle entries and isolates returned objects', () => {
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
const reads = (): number => prepare.mock.calls.filter(([sql]) => sql.includes('WITH scoped AS MATERIALIZED')).length
try {
const first = database.getExecutionStats({ projectId })
first.requestCount = 999
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
expect(reads()).toBe(1)
for (let index = 0; index < 8; index++) database.getExecutionStats({ conversationId: randomUUID() })
database.getExecutionStats({ projectId })
expect(reads()).toBe(10)
at(31)
database.getExecutionStats({ projectId })
expect(reads()).toBe(11)
} finally { prepare.mockRestore() }
})
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
database.updateTaskStatus(schedule.taskId, 'running')
@@ -0,0 +1,68 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { build } from 'esbuild'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
import { ExecutionStatsReader } from './execution-stats-reader'
let directory: string
let workerPath: string
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-statistics-reader-'))
workerPath = process.env.GOODBUDDY_STATS_WORKER_PATH ?? join(directory, 'worker.cjs')
if (!process.env.GOODBUDDY_STATS_WORKER_PATH) {
await build({ entryPoints: [resolve('src/main/execution-stats-worker.ts')], outfile: workerPath,
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
}
})
afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) })
it('reads committed WAL evidence off-thread without recovering live tasks and refreshes external writes', async () => {
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize(directory)
const projectId = database.listProjects()[0]!.id
const conversationId = randomUUID()
const id = randomUUID()
database.createTask({ id, projectId, conversationId, title: 'Live', instructions: 'Reply', workMode: 'ask', visible: false })
database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
const sql = new DatabaseSync(path)
try {
const status = database.getTask(id).status
const [conversation, project] = await Promise.all([
database.getExecutionStatsAsync({ conversationId }, new Set([id]), workerPath),
database.getExecutionStatsAsync({ projectId }, new Set([id]), workerPath)
])
expect(conversation).toMatchObject({ requestCount: 1, activeRequestCount: 1, incompleteRequestCount: 0 })
expect(project.requestCount).toBe(1)
expect(database.getTask(id).status).toBe(status)
database.updateTaskStatus(id, 'completed')
expect(await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).toMatchObject({ requestCount: 1, activeRequestCount: 0 })
sql.exec('BEGIN IMMEDIATE')
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
sql.exec('ROLLBACK')
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(0)
// A returned result must not leave a read transaction pinning the WAL.
expect(sql.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()?.busy).toBe(0)
const pending = database.getExecutionStatsAsync({ projectId }, new Set(), workerPath)
const rejection = expect(pending).rejects.toThrow('closed')
database.close()
await rejection
} finally { sql.close(); database.close() }
})
it('rejects worker startup errors and permits the next read to retry', async () => {
const reader = new ExecutionStatsReader(join(directory, 'missing.sqlite'), workerPath)
try {
for (let attempt = 0; attempt < 2; attempt++) {
await expect(reader.read({ projectId: randomUUID() }, new Set())).rejects.toThrow()
}
} finally { reader.close() }
})
@@ -0,0 +1,52 @@
import { Worker } from 'node:worker_threads'
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
export class ExecutionStatsReader {
private worker?: Worker
private nextId = 0
private readonly pending = new Map<number, {
resolve: (value: ExecutionStats) => void
reject: (error: Error) => void
}>()
constructor(private readonly databasePath: string, private readonly workerPath: string) {}
read(scope: ExecutionStatsInput, activeRequestIds: ReadonlySet<string>): Promise<ExecutionStats> {
if (!this.worker) {
const worker = new Worker(this.workerPath, { workerData: { databasePath: this.databasePath } })
this.worker = worker
worker.on('message', (message: { id: number; result?: ExecutionStats; error?: string }) => {
const request = this.pending.get(message.id)
if (!request) return
this.pending.delete(message.id)
if (message.result) request.resolve(message.result)
else request.reject(new Error(message.error ?? 'Statistics query failed'))
})
const failed = (error: Error): void => {
if (this.worker !== worker) return
this.worker = undefined
for (const request of this.pending.values()) request.reject(error)
this.pending.clear()
void worker.terminate()
}
worker.once('error', failed)
worker.once('exit', (code) => failed(new Error(`Statistics worker exited (${code})`)))
worker.unref()
}
// Bound outstanding work when a window repeatedly changes scope during a slow read.
if (this.pending.size >= 16) return Promise.reject(new Error('Statistics reader is busy'))
const id = ++this.nextId
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.worker!.postMessage({ id, scope, activeRequestIds: [...activeRequestIds] })
})
}
close(): void {
const worker = this.worker
this.worker = undefined
for (const request of this.pending.values()) request.reject(new Error('Statistics reader closed'))
this.pending.clear()
if (worker) void worker.terminate()
}
}
+17
View File
@@ -0,0 +1,17 @@
import { parentPort, workerData } from 'node:worker_threads'
import type { ExecutionStatsInput } from '../shared/assistant-contracts'
import { AssistantDatabase } from './assistant/assistant-database'
const database = new AssistantDatabase((workerData as { databasePath: string }).databasePath)
database.openReadOnly()
parentPort!.on('message', (message: {
id: number; scope: ExecutionStatsInput; activeRequestIds: string[]
}) => {
try {
const result = database.getExecutionStats(message.scope, new Set(message.activeRequestIds))
parentPort!.postMessage({ id: message.id, result })
} catch (error) {
parentPort!.postMessage({ id: message.id, error: error instanceof Error ? error.message : String(error) })
}
})
parentPort!.on('close', () => database.close())
+5 -4
View File
@@ -1324,6 +1324,7 @@ describe('registerIpcHandlers model download source routing', () => {
vi.mock('electron', () => ({
nativeImage: { createFromBuffer: (buffer: Buffer) => ({ isEmpty: () => false, toPNG: () => buffer }) },
app: {
getAppPath: vi.fn(() => process.cwd()),
getName: vi.fn(() => 'GoodBuddy'),
getVersion: vi.fn(() => '0.1.0')
},
@@ -4382,7 +4383,7 @@ describe('registerIpcHandlers token usage', () => {
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
getExecutionStats: vi.fn(() => summary)
getExecutionStatsAsync: vi.fn(async () => summary)
}
const webContents = {
mainFrame: { url: 'file:///goodbuddy/index.html' },
@@ -4400,13 +4401,13 @@ describe('registerIpcHandlers token usage', () => {
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const id = '00000000-0000-4000-8000-000000000301'
for (const scope of [{ conversationId: id }, { projectId: id }]) {
expect(handler(event, scope)).toBe(summary)
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
await expect(handler(event, scope)).resolves.toEqual(summary)
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenLastCalledWith(scope, new Set(), expect.stringContaining('execution-stats-worker.js'))
}
expect(() => handler(event, {})).toThrow()
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenCalledTimes(2)
} finally {
await dispose()
}
+3 -2
View File
@@ -7504,9 +7504,10 @@ export function registerIpcHandlers(
})
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.getExecutionStats(
return assistantDatabase.getExecutionStatsAsync(
executionStatsInputSchema.parse(input),
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id))
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id)),
join(app.getAppPath(), 'out/main/execution-stats-worker.js')
)
})
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
+1 -5
View File
@@ -4372,11 +4372,7 @@ describe("App", () => {
const dialog = screen.getByRole("alertdialog", {
name: "确认永久删除对话 新对话",
});
expect(dialog).toHaveTextContent("将永久删除此会话的全部内容");
expect(dialog).toHaveTextContent(
"如果此会话有正在运行的任务,也会同时停止",
);
expect(dialog).toHaveTextContent("此操作不可恢复");
expect(dialog).not.toHaveTextContent("将永久删除此会话的全部内容");
const cancel = screen.getByRole("button", {
name: "取消删除对话 新对话",
});
-1
View File
@@ -9373,7 +9373,6 @@ function App(): React.JSX.Element {
}
disabled={deletingConversationId === conversation.id}
icon={<Trash2 aria-hidden="true" size={14} />}
message={t("conversation.delete.message")}
onCancel={() => setConfirmingConversationId("")}
onConfirm={() =>
void deleteConversation(conversation.id)
+29
View File
@@ -10,6 +10,7 @@ import {
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
useEffect,
useState,
type ReactNode
} from 'react'
@@ -86,6 +87,34 @@ function ControlledShell({
}
describe('WorkbarShell', () => {
it('preserves task state and latest props while keeping hidden terminal and browser effects live', () => {
const stopped = vi.fn()
function Panel({ id, value }: { id: string; value: string }): ReactNode {
const [count, setCount] = useState(0)
useEffect(() => () => stopped(id), [id])
return <button onClick={() => setCount(count + 1)}>{id}:{value}:{count}</button>
}
const props = {
instances: [...DEFAULT_WORKBAR_INSTANCES, terminalOne],
onActiveInstanceChange: vi.fn(), onCloseInstance: vi.fn(), onCreateInstance: vi.fn(),
onResolveTerminalTarget: (): WorkbarTargetRef => ({ type: 'local' })
}
const panel = (value: string) => (instance: WorkbarTabInstance): ReactNode =>
<Panel id={instance.appId} value={value} />
const { rerender } = render(<WorkbarShell {...props}
activeInstanceId={DEFAULT_WORKBAR_INSTANCES[0]!.id} renderPanel={panel('old')} />)
fireEvent.click(screen.getByRole('button', { name: 'tasks:old:0' }))
rerender(<WorkbarShell {...props} activeInstanceId={terminalOne.id} renderPanel={panel('new')} />)
expect(stopped).toHaveBeenCalledWith('tasks')
expect(stopped).not.toHaveBeenCalledWith('terminal')
expect(stopped).not.toHaveBeenCalledWith('browser')
rerender(<WorkbarShell {...props}
activeInstanceId={DEFAULT_WORKBAR_INSTANCES[0]!.id} renderPanel={panel('latest')} />)
expect(screen.getByRole('button', { name: 'tasks:latest:1' })).toBeVisible()
expect(stopped).not.toHaveBeenCalledWith('terminal')
expect(stopped).not.toHaveBeenCalledWith('browser')
})
it('shows overflow controls, scrolls without switching tabs, and hides them after widening', () => {
let resize = (): void => {}
vi.stubGlobal('ResizeObserver', class {
+15 -1
View File
@@ -10,6 +10,7 @@ import {
type LucideIcon
} from 'lucide-react'
import {
Activity,
useCallback,
useEffect,
useId,
@@ -77,6 +78,15 @@ export type WorkbarShellProps = {
className?: string
}
type WorkbarPanelProps = {
instance: WorkbarTabInstance
renderPanel: (instance: WorkbarTabInstance) => ReactNode
}
function WorkbarPanel({ instance, renderPanel }: WorkbarPanelProps): React.JSX.Element {
return <>{renderPanel(instance)}</>
}
function joinClassNames(...values: (string | false | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
@@ -525,7 +535,11 @@ export function WorkbarShell({
role="tabpanel"
tabIndex={0}
>
{renderPanel(instance)}
{instance.appId === 'tasks' ? (
<Activity mode={!catalogOpen && instance.id === activeInstanceId ? 'visible' : 'hidden'}>
<WorkbarPanel instance={instance} renderPanel={renderPanel} />
</Activity>
) : <WorkbarPanel instance={instance} renderPanel={renderPanel} />}
</div>
))}
@@ -49,6 +49,16 @@ afterEach(() => {
})
describe('useExecutionStats', () => {
it('retains snapshot references for identical cached polling results', async () => {
getExecutionStats.mockImplementation(async () => stats(10))
const { result } = renderStats()
await act(async () => {})
const before = result.current
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current.conversation).toBe(before.conversation)
expect(result.current.project).toBe(before.project)
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it.each(['project', 'conversation'] as const)(
'clears the previous %s selection and ignores its late polling result', async (scope) => {
const { result, rerender } = renderStats()
+13 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { startTransition, useEffect, useState } from 'react'
import type { ExecutionStats } from '../../shared/assistant-contracts'
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
@@ -26,11 +26,18 @@ export function useExecutionStats(
window.goodbuddy.tasks.getExecutionStats({ projectId })
])
pending = false
if (!cancelled) setSnapshot({
conversationId, projectId,
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
project: project.status === 'fulfilled' ? project.value : undefined
})
if (!cancelled) startTransition(() => setSnapshot((current) => {
const next = {
conversationId, projectId,
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
project: project.status === 'fulfilled' ? project.value : undefined
}
// IPC clones cached snapshots. Preserve references when their contents are unchanged
// so idle polling does not rerender App and rebuild task duration maps.
return current.conversationId === conversationId && current.projectId === projectId &&
JSON.stringify(current.conversation) === JSON.stringify(next.conversation) &&
JSON.stringify(current.project) === JSON.stringify(next.project) ? current : next
}))
}
void refresh()
const interval = window.setInterval(() => { void refresh() }, 5_000)