release: prepare Desktop 0.13.10
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 工具执行能力。
|
||||
- 自动把所有聊天消息变成事件或节点。
|
||||
- 无依据的实体自动合并、旧观点自动失效和静默覆盖用户确认。
|
||||
- 应用市场建设作为监督者开发前置依赖。
|
||||
- 在没有增量和去重证据前实现事件触发、自动阶段识别和全量后台重算。
|
||||
@@ -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 定位解释为已有自主工具执行能力。
|
||||
|
||||
## 文档导航
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 阅读顺序
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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: "取消删除对话 新对话",
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user